diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index dce6827..0000000 --- a/.coveragerc +++ /dev/null @@ -1,7 +0,0 @@ -[run] -source= - src -omit= - venv/* - */__init__.py - */__main__.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21d1109..5f7fb98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,5 @@ jobs: pip install --upgrade pip pip install -r requirements.txt pip install -r requirements.dev.txt - - name: Tests with coverage - run: | - coverage run --branch -m pytest -vv - coverage report -m + - name: Test + run: pytest diff --git a/README.md b/README.md index 210bc55..d655e60 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # PyPony -[![Unit Tests](https://github.com/Bandwidth/pypony/actions/workflows/ci.yml/badge.svg)](https://github.com/Bandwidth/pypony/actions/workflows/ci.yml) -[![coverage badge](coverage/badge.svg)](https://github.com/Bandwidth/pypony/coverage/) +[![Unit Tests](https://github.com/Bandwidth/pypony/actions/workflows/ci.yml/badge.svg)](https://github.com/Bandwidth/pypony/actions/workflows/ci.yml) PyPony is a Python CLI tool for contract testing OpenAPI specifications against the live APIs that they define. diff --git a/coverage/badge.svg b/coverage/badge.svg deleted file mode 100644 index a43f6c6..0000000 --- a/coverage/badge.svg +++ /dev/null @@ -1 +0,0 @@ -coverage: 97.83%coverage97.83% \ No newline at end of file diff --git a/coverage/coverage_html.js b/coverage/coverage_html.js deleted file mode 100644 index 00e1848..0000000 --- a/coverage/coverage_html.js +++ /dev/null @@ -1,575 +0,0 @@ -// Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -// For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt - -// Coverage.py HTML report browser code. -/*jslint browser: true, sloppy: true, vars: true, plusplus: true, maxerr: 50, indent: 4 */ -/*global coverage: true, document, window, $ */ - -coverage = {}; - -// General helpers -function debounce(callback, wait) { - let timeoutId = null; - return function(...args) { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - callback.apply(this, args); - }, wait); - }; -}; - -function checkVisible(element) { - const rect = element.getBoundingClientRect(); - const viewBottom = Math.max(document.documentElement.clientHeight, window.innerHeight); - const viewTop = 30; - return !(rect.bottom < viewTop || rect.top >= viewBottom); -} - -// Helpers for table sorting -function getCellValue(row, column = 0) { - const cell = row.cells[column] - if (cell.childElementCount == 1) { - const child = cell.firstElementChild - if (child instanceof HTMLTimeElement && child.dateTime) { - return child.dateTime - } else if (child instanceof HTMLDataElement && child.value) { - return child.value - } - } - return cell.innerText || cell.textContent; -} - -function rowComparator(rowA, rowB, column = 0) { - let valueA = getCellValue(rowA, column); - let valueB = getCellValue(rowB, column); - if (!isNaN(valueA) && !isNaN(valueB)) { - return valueA - valueB - } - return valueA.localeCompare(valueB, undefined, {numeric: true}); -} - -function sortColumn(th) { - // Get the current sorting direction of the selected header, - // clear state on other headers and then set the new sorting direction - const currentSortOrder = th.getAttribute("aria-sort"); - [...th.parentElement.cells].forEach(header => header.setAttribute("aria-sort", "none")); - if (currentSortOrder === "none") { - th.setAttribute("aria-sort", th.dataset.defaultSortOrder || "ascending"); - } else { - th.setAttribute("aria-sort", currentSortOrder === "ascending" ? "descending" : "ascending"); - } - - const column = [...th.parentElement.cells].indexOf(th) - - // Sort all rows and afterwards append them in order to move them in the DOM - Array.from(th.closest("table").querySelectorAll("tbody tr")) - .sort((rowA, rowB) => rowComparator(rowA, rowB, column) * (th.getAttribute("aria-sort") === "ascending" ? 1 : -1)) - .forEach(tr => tr.parentElement.appendChild(tr) ); -} - -// Find all the elements with data-shortcut attribute, and use them to assign a shortcut key. -coverage.assign_shortkeys = function () { - document.querySelectorAll("[data-shortcut]").forEach(element => { - document.addEventListener("keypress", event => { - if (event.target.tagName.toLowerCase() === "input") { - return; // ignore keypress from search filter - } - if (event.key === element.dataset.shortcut) { - element.click(); - } - }); - }); -}; - -// Create the events for the filter box. -coverage.wire_up_filter = function () { - // Cache elements. - const table = document.querySelector("table.index"); - const table_body_rows = table.querySelectorAll("tbody tr"); - const no_rows = document.getElementById("no_rows"); - - // Observe filter keyevents. - document.getElementById("filter").addEventListener("input", debounce(event => { - // Keep running total of each metric, first index contains number of shown rows - const totals = new Array(table.rows[0].cells.length).fill(0); - // Accumulate the percentage as fraction - totals[totals.length - 1] = { "numer": 0, "denom": 0 }; - - // Hide / show elements. - table_body_rows.forEach(row => { - if (!row.cells[0].textContent.includes(event.target.value)) { - // hide - row.classList.add("hidden"); - return; - } - - // show - row.classList.remove("hidden"); - totals[0]++; - - for (let column = 1; column < totals.length; column++) { - // Accumulate dynamic totals - cell = row.cells[column] - if (column === totals.length - 1) { - // Last column contains percentage - const [numer, denom] = cell.dataset.ratio.split(" "); - totals[column]["numer"] += parseInt(numer, 10); - totals[column]["denom"] += parseInt(denom, 10); - } else { - totals[column] += parseInt(cell.textContent, 10); - } - } - }); - - // Show placeholder if no rows will be displayed. - if (!totals[0]) { - // Show placeholder, hide table. - no_rows.style.display = "block"; - table.style.display = "none"; - return; - } - - // Hide placeholder, show table. - no_rows.style.display = null; - table.style.display = null; - - const footer = table.tFoot.rows[0]; - // Calculate new dynamic sum values based on visible rows. - for (let column = 1; column < totals.length; column++) { - // Get footer cell element. - const cell = footer.cells[column]; - - // Set value into dynamic footer cell element. - if (column === totals.length - 1) { - // Percentage column uses the numerator and denominator, - // and adapts to the number of decimal places. - const match = /\.([0-9]+)/.exec(cell.textContent); - const places = match ? match[1].length : 0; - const { numer, denom } = totals[column]; - cell.dataset.ratio = `${numer} ${denom}`; - // Check denom to prevent NaN if filtered files contain no statements - cell.textContent = denom - ? `${(numer * 100 / denom).toFixed(places)}%` - : `${(100).toFixed(places)}%`; - } else { - cell.textContent = totals[column]; - } - } - })); - - // Trigger change event on setup, to force filter on page refresh - // (filter value may still be present). - document.getElementById("filter").dispatchEvent(new Event("change")); -}; - -coverage.INDEX_SORT_STORAGE = "COVERAGE_INDEX_SORT_2"; - -// Loaded on index.html -coverage.index_ready = function () { - coverage.assign_shortkeys(); - coverage.wire_up_filter(); - document.querySelectorAll("[data-sortable] th[aria-sort]").forEach( - th => th.addEventListener("click", e => sortColumn(e.target)) - ); - - // Look for a localStorage item containing previous sort settings: - const stored_list = localStorage.getItem(coverage.INDEX_SORT_STORAGE); - - if (stored_list) { - const {column, direction} = JSON.parse(stored_list); - const th = document.querySelector("[data-sortable]").tHead.rows[0].cells[column]; - th.setAttribute("aria-sort", direction === "ascending" ? "descending" : "ascending"); - th.click() - } - - // Watch for page unload events so we can save the final sort settings: - window.addEventListener("unload", function () { - const th = document.querySelector('[data-sortable] th[aria-sort="ascending"], [data-sortable] [aria-sort="descending"]'); - if (!th) { - return; - } - localStorage.setItem(coverage.INDEX_SORT_STORAGE, JSON.stringify({ - column: [...th.parentElement.cells].indexOf(th), - direction: th.getAttribute("aria-sort"), - })); - }); -}; - -// -- pyfile stuff -- - -coverage.LINE_FILTERS_STORAGE = "COVERAGE_LINE_FILTERS"; - -coverage.pyfile_ready = function () { - // If we're directed to a particular line number, highlight the line. - var frag = location.hash; - if (frag.length > 2 && frag[1] === 't') { - document.querySelector(frag).closest(".n").classList.add("highlight"); - coverage.set_sel(parseInt(frag.substr(2), 10)); - } else { - coverage.set_sel(0); - } - - const on_click = function(sel, fn) { - const elt = document.querySelector(sel); - if (elt) { - elt.addEventListener("click", fn); - } - } - on_click(".button_toggle_run", coverage.toggle_lines); - on_click(".button_toggle_mis", coverage.toggle_lines); - on_click(".button_toggle_exc", coverage.toggle_lines); - on_click(".button_toggle_par", coverage.toggle_lines); - - on_click(".button_next_chunk", coverage.to_next_chunk_nicely); - on_click(".button_prev_chunk", coverage.to_prev_chunk_nicely); - on_click(".button_top_of_page", coverage.to_top); - on_click(".button_first_chunk", coverage.to_first_chunk); - - coverage.filters = undefined; - try { - coverage.filters = localStorage.getItem(coverage.LINE_FILTERS_STORAGE); - } catch(err) {} - - if (coverage.filters) { - coverage.filters = JSON.parse(coverage.filters); - } - else { - coverage.filters = {run: false, exc: true, mis: true, par: true}; - } - - for (cls in coverage.filters) { - coverage.set_line_visibilty(cls, coverage.filters[cls]); - } - - coverage.assign_shortkeys(); - coverage.init_scroll_markers(); - coverage.wire_up_sticky_header(); - - // Rebuild scroll markers when the window height changes. - window.addEventListener("resize", coverage.build_scroll_markers); -}; - -coverage.toggle_lines = function (event) { - const btn = event.target.closest("button"); - const category = btn.value - const show = !btn.classList.contains("show_" + category); - coverage.set_line_visibilty(category, show); - coverage.build_scroll_markers(); - coverage.filters[category] = show; - try { - localStorage.setItem(coverage.LINE_FILTERS_STORAGE, JSON.stringify(coverage.filters)); - } catch(err) {} -}; - -coverage.set_line_visibilty = function (category, should_show) { - const cls = "show_" + category; - const btn = document.querySelector(".button_toggle_" + category); - if (btn) { - if (should_show) { - document.querySelectorAll("#source ." + category).forEach(e => e.classList.add(cls)); - btn.classList.add(cls); - } - else { - document.querySelectorAll("#source ." + category).forEach(e => e.classList.remove(cls)); - btn.classList.remove(cls); - } - } -}; - -// Return the nth line div. -coverage.line_elt = function (n) { - return document.getElementById("t" + n)?.closest("p"); -}; - -// Set the selection. b and e are line numbers. -coverage.set_sel = function (b, e) { - // The first line selected. - coverage.sel_begin = b; - // The next line not selected. - coverage.sel_end = (e === undefined) ? b+1 : e; -}; - -coverage.to_top = function () { - coverage.set_sel(0, 1); - coverage.scroll_window(0); -}; - -coverage.to_first_chunk = function () { - coverage.set_sel(0, 1); - coverage.to_next_chunk(); -}; - -// Return a string indicating what kind of chunk this line belongs to, -// or null if not a chunk. -coverage.chunk_indicator = function (line_elt) { - const classes = line_elt?.className; - if (!classes) { - return null; - } - const match = classes.match(/\bshow_\w+\b/); - if (!match) { - return null; - } - return match[0]; -}; - -coverage.to_next_chunk = function () { - const c = coverage; - - // Find the start of the next colored chunk. - var probe = c.sel_end; - var chunk_indicator, probe_line; - while (true) { - probe_line = c.line_elt(probe); - if (!probe_line) { - return; - } - chunk_indicator = c.chunk_indicator(probe_line); - if (chunk_indicator) { - break; - } - probe++; - } - - // There's a next chunk, `probe` points to it. - var begin = probe; - - // Find the end of this chunk. - var next_indicator = chunk_indicator; - while (next_indicator === chunk_indicator) { - probe++; - probe_line = c.line_elt(probe); - next_indicator = c.chunk_indicator(probe_line); - } - c.set_sel(begin, probe); - c.show_selection(); -}; - -coverage.to_prev_chunk = function () { - const c = coverage; - - // Find the end of the prev colored chunk. - var probe = c.sel_begin-1; - var probe_line = c.line_elt(probe); - if (!probe_line) { - return; - } - var chunk_indicator = c.chunk_indicator(probe_line); - while (probe > 1 && !chunk_indicator) { - probe--; - probe_line = c.line_elt(probe); - if (!probe_line) { - return; - } - chunk_indicator = c.chunk_indicator(probe_line); - } - - // There's a prev chunk, `probe` points to its last line. - var end = probe+1; - - // Find the beginning of this chunk. - var prev_indicator = chunk_indicator; - while (prev_indicator === chunk_indicator) { - probe--; - if (probe <= 0) { - return; - } - probe_line = c.line_elt(probe); - prev_indicator = c.chunk_indicator(probe_line); - } - c.set_sel(probe+1, end); - c.show_selection(); -}; - -// Returns 0, 1, or 2: how many of the two ends of the selection are on -// the screen right now? -coverage.selection_ends_on_screen = function () { - if (coverage.sel_begin === 0) { - return 0; - } - - const begin = coverage.line_elt(coverage.sel_begin); - const end = coverage.line_elt(coverage.sel_end-1); - - return ( - (checkVisible(begin) ? 1 : 0) - + (checkVisible(end) ? 1 : 0) - ); -}; - -coverage.to_next_chunk_nicely = function () { - if (coverage.selection_ends_on_screen() === 0) { - // The selection is entirely off the screen: - // Set the top line on the screen as selection. - - // This will select the top-left of the viewport - // As this is most likely the span with the line number we take the parent - const line = document.elementFromPoint(0, 0).parentElement; - if (line.parentElement !== document.getElementById("source")) { - // The element is not a source line but the header or similar - coverage.select_line_or_chunk(1); - } else { - // We extract the line number from the id - coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); - } - } - coverage.to_next_chunk(); -}; - -coverage.to_prev_chunk_nicely = function () { - if (coverage.selection_ends_on_screen() === 0) { - // The selection is entirely off the screen: - // Set the lowest line on the screen as selection. - - // This will select the bottom-left of the viewport - // As this is most likely the span with the line number we take the parent - const line = document.elementFromPoint(document.documentElement.clientHeight-1, 0).parentElement; - if (line.parentElement !== document.getElementById("source")) { - // The element is not a source line but the header or similar - coverage.select_line_or_chunk(coverage.lines_len); - } else { - // We extract the line number from the id - coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); - } - } - coverage.to_prev_chunk(); -}; - -// Select line number lineno, or if it is in a colored chunk, select the -// entire chunk -coverage.select_line_or_chunk = function (lineno) { - var c = coverage; - var probe_line = c.line_elt(lineno); - if (!probe_line) { - return; - } - var the_indicator = c.chunk_indicator(probe_line); - if (the_indicator) { - // The line is in a highlighted chunk. - // Search backward for the first line. - var probe = lineno; - var indicator = the_indicator; - while (probe > 0 && indicator === the_indicator) { - probe--; - probe_line = c.line_elt(probe); - if (!probe_line) { - break; - } - indicator = c.chunk_indicator(probe_line); - } - var begin = probe + 1; - - // Search forward for the last line. - probe = lineno; - indicator = the_indicator; - while (indicator === the_indicator) { - probe++; - probe_line = c.line_elt(probe); - indicator = c.chunk_indicator(probe_line); - } - - coverage.set_sel(begin, probe); - } - else { - coverage.set_sel(lineno); - } -}; - -coverage.show_selection = function () { - // Highlight the lines in the chunk - document.querySelectorAll("#source .highlight").forEach(e => e.classList.remove("highlight")); - for (let probe = coverage.sel_begin; probe < coverage.sel_end; probe++) { - coverage.line_elt(probe).querySelector(".n").classList.add("highlight"); - } - - coverage.scroll_to_selection(); -}; - -coverage.scroll_to_selection = function () { - // Scroll the page if the chunk isn't fully visible. - if (coverage.selection_ends_on_screen() < 2) { - const element = coverage.line_elt(coverage.sel_begin); - coverage.scroll_window(element.offsetTop - 60); - } -}; - -coverage.scroll_window = function (to_pos) { - window.scroll({top: to_pos, behavior: "smooth"}); -}; - -coverage.init_scroll_markers = function () { - // Init some variables - coverage.lines_len = document.querySelectorAll('#source > p').length; - - // Build html - coverage.build_scroll_markers(); -}; - -coverage.build_scroll_markers = function () { - const temp_scroll_marker = document.getElementById('scroll_marker') - if (temp_scroll_marker) temp_scroll_marker.remove(); - // Don't build markers if the window has no scroll bar. - if (document.body.scrollHeight <= window.innerHeight) { - return; - } - - const marker_scale = window.innerHeight / document.body.scrollHeight; - const line_height = Math.min(Math.max(3, window.innerHeight / coverage.lines_len), 10); - - let previous_line = -99, last_mark, last_top; - - const scroll_marker = document.createElement("div"); - scroll_marker.id = "scroll_marker"; - document.getElementById('source').querySelectorAll( - 'p.show_run, p.show_mis, p.show_exc, p.show_exc, p.show_par' - ).forEach(element => { - const line_top = Math.floor(element.offsetTop * marker_scale); - const line_number = parseInt(element.id.substr(1)); - - if (line_number === previous_line + 1) { - // If this solid missed block just make previous mark higher. - last_mark.style.height = `${line_top + line_height - last_top}px`; - } else { - // Add colored line in scroll_marker block. - last_mark = document.createElement("div"); - last_mark.id = `m${line_number}`; - last_mark.classList.add("marker"); - last_mark.style.height = `${line_height}px`; - last_mark.style.top = `${line_top}px`; - scroll_marker.append(last_mark); - last_top = line_top; - } - - previous_line = line_number; - }); - - // Append last to prevent layout calculation - document.body.append(scroll_marker); -}; - -coverage.wire_up_sticky_header = function () { - const header = document.querySelector('header'); - const header_bottom = ( - header.querySelector('.content h2').getBoundingClientRect().top - - header.getBoundingClientRect().top - ); - - function updateHeader() { - if (window.scrollY > header_bottom) { - header.classList.add('sticky'); - } else { - header.classList.remove('sticky'); - } - } - - window.addEventListener('scroll', updateHeader); - updateHeader(); -}; - -document.addEventListener("DOMContentLoaded", () => { - if (document.body.classList.contains("indexfile")) { - coverage.index_ready(); - } else { - coverage.pyfile_ready(); - } -}); diff --git a/coverage/d_8e5d7c2a8ace5693_context_py.html b/coverage/d_8e5d7c2a8ace5693_context_py.html deleted file mode 100644 index 220a602..0000000 --- a/coverage/d_8e5d7c2a8ace5693_context_py.html +++ /dev/null @@ -1,173 +0,0 @@ - - - - - - Coverage for api_validator/models/context.py: 94% - - - - - -
-
-

- Coverage for api_validator/models/context.py: - 94% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 42 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""context.py 

-

3 

-

4Context manages all variables used during validation, 

-

5like system environment variables and responses of previous steps. 

-

6""" 

-

7 

-

8import os 

-

9import re 

-

10from types import SimpleNamespace 

-

11 

-

12from .singleton import Singleton 

-

13from .errors import BaseContextError, EvaluationError, EnvironmentVariableError 

-

14 

-

15 

-

16class Context(metaclass=Singleton): 

-

17 """ 

-

18 The global context object will be used as a singleton across the entire system. 

-

19 """ 

-

20 

-

21 _steps = SimpleNamespace() 

-

22 

-

23 @property 

-

24 def steps(self): 

-

25 return self._steps 

-

26 

-

27 def add_steps(self, step): 

-

28 """ 

-

29 Adds a Step object as an attribute of `self.steps` 

-

30 

-

31 Args: 

-

32 step (Step): the Step object to add 

-

33 """ 

-

34 

-

35 setattr(self.steps, step.name, step) 

-

36 

-

37 def clear_steps(self): 

-

38 """ 

-

39 Clears all Steps objects from attributes of `self.steps` 

-

40 """ 

-

41 

-

42 self._steps = SimpleNamespace() 

-

43 

-

44 # noinspection PyMethodMayBeStatic 

-

45 def evaluate(self, expression: any) -> any: 

-

46 """ 

-

47 Recursively evaluate nested expressions using depth-first search. 

-

48 Eventually the evaluation result as a string is returned. 

-

49 

-

50 The only allowed base contexts are "env" and "steps". 

-

51 

-

52 Args: 

-

53 expression (str): Object of any type that may contain expression(s) 

-

54 

-

55 Raises: 

-

56 EnvironmentVariableError: 

-

57 if the expression represents an environment variable but it cannot be found 

-

58 

-

59 Returns: 

-

60 The evaluated result as a string if there is any expression, original value otherwise. 

-

61 """ 

-

62 

-

63 if expression is None: 63 ↛ 64line 63 didn't jump to line 64, because the condition on line 63 was never true

-

64 return 

-

65 

-

66 # Evaluate each value in a dictionary 

-

67 if isinstance(expression, dict): 

-

68 return dict(map(lambda x: (x[0], self.evaluate(x[1])), expression.items())) 

-

69 

-

70 # Evaluate each element in a list 

-

71 if isinstance(expression, list): 

-

72 return list(map(lambda x: self.evaluate(x), expression)) 

-

73 

-

74 if not isinstance(expression, str): 

-

75 return expression 

-

76 

-

77 matches: list[str] = re.findall(r"(\${{[^/}]*}})", expression) 

-

78 if not matches: 

-

79 return expression 

-

80 

-

81 for match in matches: 

-

82 value = match.removeprefix("${{").removesuffix("}}").strip() 

-

83 base = value.split(".").pop(0) 

-

84 

-

85 if base == "env": 

-

86 # Only split at the first dot 

-

87 result = os.environ.get(value.split(".", 1)[1]) 

-

88 if result is None: 

-

89 raise EnvironmentVariableError(value) 

-

90 elif base == "steps": 

-

91 try: 

-

92 result = eval("self." + value) 

-

93 except AttributeError as e: 

-

94 raise EvaluationError(e) 

-

95 else: 

-

96 raise BaseContextError(base) 

-

97 

-

98 # Only replace the first occurrence 

-

99 expression = expression.replace(match, str(result), 1) 

-

100 

-

101 return expression 

-
- - - diff --git a/coverage/d_8e5d7c2a8ace5693_errors_py.html b/coverage/d_8e5d7c2a8ace5693_errors_py.html deleted file mode 100644 index 1a72031..0000000 --- a/coverage/d_8e5d7c2a8ace5693_errors_py.html +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - Coverage for api_validator/models/errors.py: 85% - - - - - -
-
-

- Coverage for api_validator/models/errors.py: - 85% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 13 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""errors.py: 

-

3 

-

4This module contains custom errors related to the models subpackage. 

-

5These errors mostly include exceptions related to resolving previous step and environment variable references in step files. 

-

6""" 

-

7 

-

8 

-

9class BaseContextError(Exception): 

-

10 """ 

-

11 Raises when the base context of an expression is not either "env" or "steps". 

-

12 """ 

-

13 def __init__(self, value): 

-

14 super().__init__( 

-

15 f"the base context must be either 'env' or 'steps', but found '{value}'" 

-

16 ) 

-

17 

-

18 

-

19class EvaluationError(Exception): 

-

20 """ 

-

21 Raises when the errors occur during expression evaluation. 

-

22 """ 

-

23 def __init__(self, message): 

-

24 super().__init__(message) 

-

25 

-

26 

-

27class EnvironmentVariableError(Exception): 

-

28 """ 

-

29 Raises when an environment variable is not found. 

-

30 """ 

-

31 def __init__(self, value): 

-

32 super().__init__(f"environment variable {value} not found") 

-

33 

-

34 

-

35class InvalidExpressionError(Exception): 

-

36 """ 

-

37 Raises when an expression is invalid. 

-

38 """ 

-

39 def __init__(self, value): 

-

40 super().__init__(f"invalid expression: {value}") 

-
- - - diff --git a/coverage/d_8e5d7c2a8ace5693_request_py.html b/coverage/d_8e5d7c2a8ace5693_request_py.html deleted file mode 100644 index ff472b6..0000000 --- a/coverage/d_8e5d7c2a8ace5693_request_py.html +++ /dev/null @@ -1,153 +0,0 @@ - - - - - - Coverage for api_validator/models/request.py: 100% - - - - - -
-
-

- Coverage for api_validator/models/request.py: - 100% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 41 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""request.py 

-

3 

-

4Request will be sent off to a server to request or query some resource. 

-

5""" 

-

6 

-

7from addict import Dict 

-

8 

-

9from api_validator.models import Context 

-

10 

-

11context = Context() 

-

12 

-

13 

-

14class Request: 

-

15 """ 

-

16 Request object includes method, url, params, body, headers, and auth data. 

-

17 """ 

-

18 def __init__( 

-

19 self, 

-

20 method: str, 

-

21 url: str, 

-

22 params: dict = None, 

-

23 body: dict = None, 

-

24 headers: dict = None, 

-

25 auth: dict = None, 

-

26 ): 

-

27 self.method = method 

-

28 self.url = url 

-

29 self.params = params 

-

30 self.body = body 

-

31 self.headers = headers 

-

32 self.auth = auth 

-

33 

-

34 @property 

-

35 def params(self): 

-

36 return self._params 

-

37 

-

38 @params.setter 

-

39 def params(self, value): 

-

40 self._params = Dict(value) 

-

41 

-

42 @property 

-

43 def body(self): 

-

44 return self._body 

-

45 

-

46 @body.setter 

-

47 def body(self, value): 

-

48 self._body = Dict(value) 

-

49 

-

50 @property 

-

51 def headers(self): 

-

52 return self._headers 

-

53 

-

54 @headers.setter 

-

55 def headers(self, value): 

-

56 self._headers = Dict(value) 

-

57 

-

58 @property 

-

59 def auth(self): 

-

60 return self._auth 

-

61 

-

62 @auth.setter 

-

63 def auth(self, value): 

-

64 self._auth = Dict(value if value else {"username": "", "password": ""}) 

-

65 

-

66 def evaluate_all(self) -> None: 

-

67 """ 

-

68 Evaluates url, params, body, and header in-place. 

-

69 

-

70 Expressions can be either simple or nested: 

-

71 

-

72 /users/${{ steps.createUser.response.body.id }} 

-

73 /users/${{ steps.createUser.response.body.id }}/orders/${{ steps.getOrders.response.body[0].id }} 

-

74 """ 

-

75 

-

76 self.url = context.evaluate(self.url) 

-

77 

-

78 # Evaluate all Dict object 

-

79 for name in ("params", "body", "headers", "auth"): 

-

80 attr = self.__getattribute__(name) 

-

81 self.__setattr__(name, context.evaluate(attr)) 

-
- - - diff --git a/coverage/d_8e5d7c2a8ace5693_response_py.html b/coverage/d_8e5d7c2a8ace5693_response_py.html deleted file mode 100644 index 54faaf4..0000000 --- a/coverage/d_8e5d7c2a8ace5693_response_py.html +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - Coverage for api_validator/models/response.py: 100% - - - - - -
-
-

- Coverage for api_validator/models/response.py: - 100% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 12 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""response.py 

-

3 

-

4Response is generated once a request gets a response back from the server. 

-

5""" 

-

6 

-

7from dataclasses import dataclass 

-

8 

-

9import requests 

-

10from addict import Dict 

-

11 

-

12 

-

13@dataclass 

-

14class Response: 

-

15 """ 

-

16 Stores the response sent back to the server. 

-

17 """ 

-

18 

-

19 __response: requests.Response 

-

20 

-

21 def __getattr__(self, item): 

-

22 if item == "body": 

-

23 body = self.__response.json() 

-

24 return Dict(body) if isinstance(body, dict) else body 

-

25 

-

26 return getattr(self.__response, item) 

-
- - - diff --git a/coverage/d_8e5d7c2a8ace5693_schema_py.html b/coverage/d_8e5d7c2a8ace5693_schema_py.html deleted file mode 100644 index 5918a4c..0000000 --- a/coverage/d_8e5d7c2a8ace5693_schema_py.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - Coverage for api_validator/models/schema.py: 100% - - - - - -
-
-

- Coverage for api_validator/models/schema.py: - 100% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 10 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""schema.py 

-

3 

-

4JSON Schema is a vocabulary that allows you to annotate and validate JSON documents. 

-

5""" 

-

6 

-

7from jschon import JSONSchema, JSON 

-

8from jschon.jsonschema import Scope 

-

9 

-

10META_SCHEMA = "https://json-schema.org/draft/2020-12/schema" 

-

11 

-

12 

-

13class Schema: 

-

14 """ 

-

15 Predefines meta schema and stores JSON schema document model. 

-

16 """ 

-

17 

-

18 def __init__(self, schema: dict, meta_schema: str = META_SCHEMA): 

-

19 schema["$schema"] = meta_schema 

-

20 self.schema = JSONSchema(schema) 

-

21 

-

22 def evaluate(self, json: dict) -> Scope: 

-

23 """ 

-

24 Verify the JSON against the schema. 

-

25 

-

26 Args: 

-

27 json (dict): the JSON document to evaluate 

-

28 

-

29 Returns: 

-

30 Evaluate a JSON document and return the complete evaluation result tree. 

-

31 """ 

-

32 

-

33 return self.schema.evaluate(JSON(json)) 

-
- - - diff --git a/coverage/d_8e5d7c2a8ace5693_singleton_py.html b/coverage/d_8e5d7c2a8ace5693_singleton_py.html deleted file mode 100644 index 0353604..0000000 --- a/coverage/d_8e5d7c2a8ace5693_singleton_py.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - Coverage for api_validator/models/singleton.py: 100% - - - - - -
-
-

- Coverage for api_validator/models/singleton.py: - 100% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 7 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""singleton.py 

-

3 

-

4This module is only used by Context to ensure the uniqueness of the global context state. 

-

5""" 

-

6 

-

7class Singleton(type): 

-

8 """ 

-

9 Restricts the instantiation of a class to one single instance. 

-

10 """ 

-

11 _instances = {} 

-

12 

-

13 def __call__(cls, *args, **kwargs): 

-

14 if cls not in cls._instances: 

-

15 cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 

-

16 return cls._instances[cls] 

-
- - - diff --git a/coverage/d_8e5d7c2a8ace5693_step_py.html b/coverage/d_8e5d7c2a8ace5693_step_py.html deleted file mode 100644 index 3e731af..0000000 --- a/coverage/d_8e5d7c2a8ace5693_step_py.html +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - Coverage for api_validator/models/step.py: 100% - - - - - -
-
-

- Coverage for api_validator/models/step.py: - 100% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 14 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""step.py: 

-

3 

-

4Encapsulates step data from the step file, including name, request, response, and schema for easy access. 

-

5""" 

-

6 

-

7from dataclasses import dataclass 

-

8 

-

9from jschon.jsonschema import Scope 

-

10 

-

11from .request import Request 

-

12from .response import Response 

-

13from .schema import Schema 

-

14 

-

15 

-

16@dataclass 

-

17class Step: 

-

18 """ 

-

19 Steps object manages request, response, and schema. 

-

20 """ 

-

21 

-

22 name: str 

-

23 request: Request = None 

-

24 response: Response = None 

-

25 schema: Schema = None 

-

26 

-

27 def verify(self) -> Scope: 

-

28 """ 

-

29 Verify the response against the schema. 

-

30 

-

31 Returns: 

-

32 Evaluate the response body and return the complete evaluation result tree. 

-

33 """ 

-

34 

-

35 return self.schema.evaluate(self.response.json()) 

-
- - - diff --git a/coverage/d_b4251882f9eb8989_action_py.html b/coverage/d_b4251882f9eb8989_action_py.html deleted file mode 100644 index cb68f07..0000000 --- a/coverage/d_b4251882f9eb8989_action_py.html +++ /dev/null @@ -1,315 +0,0 @@ - - - - - - Coverage for api_validator/action.py: 100% - - - - - -
-
-

- Coverage for api_validator/action.py: - 100% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 84 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""action.py 

-

3 

-

4This module contains the primary code for the GitHub Action. 

-

5Using the parsed OpenAPI specs and step files, it will create API requests and 

-

6validate the responses. 

-

7""" 

-

8import os 

-

9import traceback 

-

10 

-

11import requests 

-

12from actions_toolkit import core 

-

13from dotenv import load_dotenv 

-

14from jschon import create_catalog 

-

15from jschon.jsonschema import OutputFormat 

-

16from openapi_schema_to_json_schema import to_json_schema 

-

17from requests.auth import HTTPBasicAuth 

-

18 

-

19from .errors import ( 

-

20 InsufficientCoverageError, 

-

21 ResponseMatchError, 

-

22 ResponseValidationError, 

-

23 UndocumentedEndpointError, 

-

24) 

-

25from .models import Context, Request, Response, Schema, Step 

-

26from .parsing import parse_spec, parse_steps 

-

27from .preprocessing import get_endpoint_coverage 

-

28 

-

29# Global variable storing all runtime contexts (initialize once) 

-

30context = Context() 

-

31 

-

32# Load dotenv 

-

33load_dotenv() 

-

34 

-

35 

-

36def parse_spec_steps(spec_file_path: str, step_file_path: str) -> tuple[dict, dict]: 

-

37 """ 

-

38 Parses the OpenAPI spec and step files and return each of them 

-

39 

-

40 Args: 

-

41 spec_file_path (str): The path to the OpenAPI spec file 

-

42 step_file_path (str): The path to the step file 

-

43 Returns: 

-

44 A 2-tuple of dictionaries, containing the parsed OpenAPI spec and step files 

-

45 Raises: 

-

46 FileNotFoundError: If the spec or step file does not exist 

-

47 ValidationError: If the spec file is not valid according to the OpenAPI standard 

-

48 ScannerError: If the step file is not valid YAML 

-

49 JSONValidatorError: If the step file instance is not valid according to the JSON schema 

-

50 """ 

-

51 

-

52 # Parse OpenAPI specification file 

-

53 core.start_group("Parsing spec file") 

-

54 spec_data = parse_spec(spec_file_path) 

-

55 core.end_group() 

-

56 

-

57 # Parse step file 

-

58 core.start_group("Parsing step file") 

-

59 steps_data = parse_steps(step_file_path) 

-

60 core.end_group() 

-

61 

-

62 return spec_data, steps_data 

-

63 

-

64 

-

65def check_endpoint_coverage(spec_data: dict, steps_data: dict): 

-

66 """ 

-

67 Checks the endpoint coverage of the step file against the OpenAPI spec. 

-

68 

-

69 Args: 

-

70 spec_data (dict): The parsed OpenAPI spec 

-

71 steps_data (dict): The parsed step file 

-

72 Raises: 

-

73 UndocumentedEndpointError: 

-

74 """ 

-

75 

-

76 endpoint_coverage = get_endpoint_coverage(spec_data, steps_data) 

-

77 

-

78 # If any undocumented endpoints, immediately halt 

-

79 if endpoint_coverage.has_undocumented_endpoints(): 

-

80 raise UndocumentedEndpointError(endpoint_coverage.undocumented) 

-

81 

-

82 # Check if endpoint coverage meets threshold 

-

83 if "coverage_threshold" in steps_data: 

-

84 target_coverage: float = steps_data["coverage_threshold"] 

-

85 achieved_coverage = endpoint_coverage.proportion_covered() 

-

86 

-

87 if achieved_coverage < target_coverage: 

-

88 raise InsufficientCoverageError( 

-

89 achieved_coverage, target_coverage, endpoint_coverage.uncovered 

-

90 ) 

-

91 

-

92 

-

93def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: bool): 

-

94 """ 

-

95 Given a parsed OpenAPI spec and step file, make requests and validate responses. 

-

96 

-

97 Args: 

-

98 spec_data (dict): The parsed OpenAPI spec 

-

99 steps_data (dict): The parsed step file 

-

100 fail_fast (bool): Whether to exit immediately if exception raised 

-

101 verbose (bool): Whether to output stacktrace 

-

102 Raises: 

-

103 ResponseMatchError: If the response does not match the expected response according to status code 

-

104 ResponseValidationError: If the response does not match the expected response according to schema 

-

105 """ 

-

106 

-

107 core.info('Validating APIs') 

-

108 

-

109 # Create requests 

-

110 base_url: str = steps_data["base_url"] 

-

111 paths: dict = steps_data["paths"] 

-

112 

-

113 # Go through each path in the steps 

-

114 path_value: dict 

-

115 for path_key, path_value in paths.items(): 

-

116 core.start_group(path_key) 

-

117 

-

118 # Go through each method in each path 

-

119 method_value: dict 

-

120 for method_name, method_value in path_value.items(): 

-

121 # Store steps of current method 

-

122 context.clear_steps() 

-

123 

-

124 # Get steps YAML from file 

-

125 method_steps: list = method_value["steps"] 

-

126 

-

127 # Go through each step in each method 

-

128 step_data: dict 

-

129 for step_data in method_steps: 

-

130 try: 

-

131 # Get step name 

-

132 step_name = step_data.pop("name") 

-

133 core.info(step_name) 

-

134 

-

135 # Create Request object 

-

136 path_url = step_data.pop("url") 

-

137 request = Request(url=(base_url + path_url), **step_data) 

-

138 

-

139 # Evaluate expressions 

-

140 request.evaluate_all() 

-

141 

-

142 core.info(' Request:') 

-

143 core.info(f' {request.method} {request.url}') 

-

144 core.info(f' Authentication: {request.auth}') 

-

145 core.info(f' Body: {request.body}') 

-

146 core.info(f' Headers: {request.headers}') 

-

147 core.info(f' Parameters: {request.params}') 

-

148 

-

149 # Create Step object 

-

150 step = Step(step_name, request) 

-

151 

-

152 # Send the request 

-

153 step.response = Response( 

-

154 requests.request( 

-

155 method=request.method, 

-

156 url=request.url, 

-

157 params=request.params.to_dict(), 

-

158 headers=request.headers.to_dict(), 

-

159 json=request.body.to_dict(), 

-

160 auth=HTTPBasicAuth(**request.auth), 

-

161 ) 

-

162 ) 

-

163 

-

164 response = step.response 

-

165 

-

166 core.info(' Response:') 

-

167 core.info(f' HTTP {response.status_code} {response.reason}') 

-

168 core.info(f' Body: {response.body}') 

-

169 core.info('') 

-

170 

-

171 status_code = step.response.status_code 

-

172 

-

173 # Fetch schema 

-

174 try: 

-

175 schema = to_json_schema( 

-

176 spec_data.get("paths") 

-

177 .get(path_key) 

-

178 .get(method_name) 

-

179 .get("responses") 

-

180 .get(str(status_code)) 

-

181 .get("content") 

-

182 .get("application/json") 

-

183 .get("schema") 

-

184 ) 

-

185 step.schema = Schema(schema) 

-

186 except AttributeError: 

-

187 raise ResponseMatchError( 

-

188 spec_data.get("paths") 

-

189 .get(path_key) 

-

190 .get(method_name) 

-

191 .get("responses") 

-

192 .keys(), 

-

193 step.response, 

-

194 ) 

-

195 

-

196 # Save the step to further use 

-

197 context.add_steps(step) 

-

198 

-

199 # Verify the response 

-

200 verification_result = step.verify() 

-

201 if not verification_result.valid: 

-

202 raise ResponseValidationError( 

-

203 errors=verification_result.output(OutputFormat.BASIC)["errors"], 

-

204 url=path_url, 

-

205 method=method_name, 

-

206 status_code=status_code, 

-

207 ) 

-

208 

-

209 except BaseException as e: 

-

210 if fail_fast: 

-

211 raise e 

-

212 

-

213 if verbose: 

-

214 core.warning(traceback.format_exc(), title=e.__class__.__name__) 

-

215 else: 

-

216 core.warning(str(e), title=e.__class__.__name__) 

-

217 

-

218 core.end_group() 

-

219 

-

220 

-

221def verify_api(spec_file_path: str, step_file_path: str, fail_fast: bool = False, verbose: bool = False): 

-

222 """ 

-

223 This is the main function of the API verifier. 

-

224 It parses the OpenAPI spec and step files, measures coverage, makes requests, and validates responses. 

-

225 If this method completes without raising any exceptions, the API is considered valid. 

-

226 

-

227 Args: 

-

228 spec_file_path (str): The path to the OpenAPI spec file 

-

229 step_file_path (str): The path to the step file 

-

230 fail_fast (bool): Whether to exit immediately if exception raised 

-

231 verbose (bool): Whether to output stacktrace 

-

232 """ 

-

233 

-

234 create_catalog("2020-12", default=True) 

-

235 

-

236 # Parse spec and step files 

-

237 spec_data, steps_data = parse_spec_steps(spec_file_path, step_file_path) 

-

238 

-

239 # Check endpoint coverage 

-

240 check_endpoint_coverage(spec_data, steps_data) 

-

241 

-

242 # Make requests 

-

243 make_requests(spec_data, steps_data, fail_fast, verbose) 

-
- - - diff --git a/coverage/d_b4251882f9eb8989_errors_py.html b/coverage/d_b4251882f9eb8989_errors_py.html deleted file mode 100644 index d84654c..0000000 --- a/coverage/d_b4251882f9eb8989_errors_py.html +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - Coverage for api_validator/errors.py: 100% - - - - - -
-
-

- Coverage for api_validator/errors.py: - 100% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 18 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""errors.py 

-

3 

-

4This module contains custom errors for the API validator. 

-

5Often, these errors are raised when a library gives an error with a difficult to read message. 

-

6These errors will nicely format these error messages for logging and stack traces. 

-

7""" 

-

8 

-

9# Import errors from models subpackage 

-

10from .models.errors import * 

-

11 

-

12# For type hints 

-

13from .models import Response 

-

14 

-

15 

-

16class UndocumentedEndpointError(BaseException): 

-

17 """ 

-

18 Raised when the step file contains endpoints that are not documented in the OpenAPI spec. 

-

19 This error will halt further execution of the action. 

-

20 """ 

-

21 

-

22 def __init__(self, undocumented: set[str]): 

-

23 super().__init__( 

-

24 f"""The following endpoints are undocumented: 

-

25 {undocumented}""" 

-

26 ) 

-

27 

-

28 

-

29class InsufficientCoverageError(BaseException): 

-

30 """ 

-

31 Raised the step file does not sufficiently cover the OpenAPI spec according the the coverage threshold. 

-

32 If a coverage threshold given in the step file, this error will halt further execution of the action. 

-

33 """ 

-

34 

-

35 def __init__( 

-

36 self, achieved_coverage: float, target_coverage: float, uncovered: set[str] 

-

37 ): 

-

38 super().__init__( 

-

39 f"""The endpoint coverage is {achieved_coverage} but the target is {target_coverage}. 

-

40 The following endpoints are uncovered: {uncovered}""" 

-

41 ) 

-

42 

-

43 

-

44class ResponseMatchError(BaseException): 

-

45 """ 

-

46 Raised when a response does not match any of the expected responses in the OpenAPI spec according to status code. 

-

47 This error will halt further execution of the action. 

-

48 """ 

-

49 

-

50 def __init__(self, statuses: list, step: Response): 

-

51 super().__init__( 

-

52 f"""Cannot find a matching response in the specification. 

-

53 Expected possible responses: 

-

54 {', '.join(list(statuses))} 

-

55 Actual response: 

-

56 {step.status_code} {step.reason}: {step.text}""" 

-

57 ) 

-

58 

-

59 

-

60class ResponseValidationError(BaseException): 

-

61 """ 

-

62 Raised when the structure of a response does not match the expected schema in the OpenAPI spec. 

-

63 This error will halt further execution of the action. 

-

64 """ 

-

65 

-

66 def __init__(self, errors: list, url: str, method: str, status_code: int): 

-

67 super().__init__( 

-

68 f"""Response is not valid against the schema at: 

-

69 {url}: 

-

70 {method}: 

-

71 {status_code}: 

-

72 {list(map(lambda x: {x['keywordLocation']: x['error']}, errors))}""" 

-

73 ) 

-

74 

-

75 

-

76class JSONValidatorError(BaseException): 

-

77 """ 

-

78 Raised when the structure of a JSON does not match the expected JSON schema. 

-

79 This error will halt further execution of the action. 

-

80 """ 

-

81 

-

82 def __init__(self, errors: list[dict[str, str]]): 

-

83 super().__init__( 

-

84 "\n".join( 

-

85 [ 

-

86 f"""{error['instanceLocation']}\n {error['error']}""" 

-

87 for error in errors 

-

88 ] 

-

89 ) 

-

90 ) 

-
- - - diff --git a/coverage/d_b4251882f9eb8989_parsing_py.html b/coverage/d_b4251882f9eb8989_parsing_py.html deleted file mode 100644 index 93425ca..0000000 --- a/coverage/d_b4251882f9eb8989_parsing_py.html +++ /dev/null @@ -1,177 +0,0 @@ - - - - - - Coverage for api_validator/parsing.py: 96% - - - - - -
-
-

- Coverage for api_validator/parsing.py: - 96% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 47 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""parsing.py 

-

3 

-

4This module contains functions for parsing the input file. 

-

5It will parse input files, returning a nested dictionary object. 

-

6If the input file is not valid, it will raise an exception. 

-

7""" 

-

8 

-

9import importlib.resources 

-

10import json 

-

11import os 

-

12 

-

13import yaml 

-

14from jschon import JSON, JSONSchema 

-

15from jschon.jsonschema import OutputFormat 

-

16from openapi_spec_validator import validate_spec 

-

17from prance import ResolvingParser, ValidationError 

-

18from prance.util.url import ResolutionError 

-

19from ruamel.yaml.scanner import ScannerError as RuamelScannerError 

-

20from yaml.constructor import SafeConstructor 

-

21from yaml.scanner import ScannerError as PyYAMLScannerError 

-

22 

-

23from .errors import JSONValidatorError 

-

24 

-

25 

-

26def parse_spec(spec_file_path: str) -> dict: 

-

27 """Parse OpenAPI spec file to a dictionary. 

-

28 

-

29 Args: 

-

30 spec_file_path (str): Path to spec file. 

-

31 

-

32 Returns: 

-

33 dict: Spec file parsed into a dictionary. 

-

34 

-

35 Raises: 

-

36 ValidationError: If the spec file is not valid according to the OpenAPI spec. 

-

37 ResolutionError: If the spec file has refs cannot be resolved 

-

38 ruamel.yaml.scanner.ScannerError: 

-

39 """ 

-

40 

-

41 if not os.path.exists(spec_file_path): 

-

42 raise FileNotFoundError(f"Spec file '{spec_file_path}' not found") 

-

43 

-

44 try: 

-

45 parser = ResolvingParser(spec_file_path) 

-

46 spec_dict = parser.specification 

-

47 validate_spec(spec_dict) 

-

48 return spec_dict 

-

49 except ValidationError as e: 

-

50 raise ValidationError( 

-

51 f"Spec file {spec_file_path} is not valid according to the OpenAPI specification" 

-

52 ) from e 

-

53 except ResolutionError as e: 

-

54 raise ResolutionError( 

-

55 f"Spec file {spec_file_path} has refs that cannot be resolved" 

-

56 ) 

-

57 except RuamelScannerError as e: 

-

58 raise RuamelScannerError( 

-

59 f"Could not parse {spec_file_path} as a YAML file" 

-

60 ) from e 

-

61 

-

62 

-

63def parse_steps(step_file_path: str) -> dict: 

-

64 """Parse steps file to a dictionary. 

-

65 

-

66 Args: 

-

67 step_file_path (str): Path to step file. 

-

68 

-

69 Returns: 

-

70 dict: Step file parsed into a dictionary. 

-

71 

-

72 Raises: 

-

73 FileNotFoundError: If the step file does not exist. 

-

74 yaml.scanner.ScannerError: If the step file is not valid YAML. 

-

75 JSONValidatorError: If the step file is not valid against the step file schema. 

-

76 """ 

-

77 

-

78 try: 

-

79 with importlib.resources.path( 

-

80 __package__, "steps_schema.json" 

-

81 ) as step_schema_file: 

-

82 steps_schema = JSONSchema(json.loads(step_schema_file.read_text())) 

-

83 except FileNotFoundError as e: 

-

84 raise FileNotFoundError(f"Steps schema file not found") from e 

-

85 

-

86 try: 

-

87 with open(step_file_path, "r") as step_file: 

-

88 # Drop support for datetime 

-

89 SafeConstructor.yaml_constructors[ 

-

90 "tag:yaml.org,2002:timestamp" 

-

91 ] = SafeConstructor.yaml_constructors["tag:yaml.org,2002:str"] 

-

92 

-

93 # Load the step file and validate it against the step file schema 

-

94 yaml_dict = yaml.safe_load(step_file) 

-

95 result = steps_schema.evaluate(JSON(yaml_dict)) 

-

96 result_output = result.output(OutputFormat.BASIC) 

-

97 

-

98 if not result_output["valid"]: 

-

99 raise JSONValidatorError(result_output["errors"]) 

-

100 except FileNotFoundError as e: 

-

101 raise FileNotFoundError(f"Steps file {step_file_path} not found") from e 

-

102 except PyYAMLScannerError as e: 

-

103 raise PyYAMLScannerError("Steps file is not valid YAML") from e 

-

104 

-

105 return yaml_dict 

-
- - - diff --git a/coverage/d_b4251882f9eb8989_preprocessing_py.html b/coverage/d_b4251882f9eb8989_preprocessing_py.html deleted file mode 100644 index 3b02a97..0000000 --- a/coverage/d_b4251882f9eb8989_preprocessing_py.html +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - Coverage for api_validator/preprocessing.py: 100% - - - - - -
-
-

- Coverage for api_validator/preprocessing.py: - 100% -

-
- - -
-

Shortcuts on this page

-
-

- r - m - x - p -   toggle line displays -

-

- j - k   next/prev highlighted chunk -

-

- 0   (zero) top of page -

-

- 1   (one) first highlighted chunk -

-
-
-
-

- 15 statements   - - - - -

-
- - - - -
-
-
-
-

1# -*- coding: utf-8 -*- 

-

2"""preprocessing.py 

-

3 

-

4This module contains functions to ensure compatibility for a step file and corresponding spec file. 

-

5It will check that every method referenced in the step file exists in the spec file. 

-

6""" 

-

7 

-

8from dataclasses import dataclass 

-

9 

-

10 

-

11@dataclass 

-

12class EndpointCoverage: 

-

13 """ 

-

14 Dictionary classifying endpoints into three categories: 

-

15 - covered: endpoints that are called in the step file and documented in the spec file 

-

16 - uncovered: endpoints that are in the spec file but not called in the step file 

-

17 - undocumented: endpoints that are called in the step file but not documented in the spec file 

-

18 """ 

-

19 

-

20 covered: set[str] 

-

21 uncovered: set[str] 

-

22 undocumented: set[str] 

-

23 

-

24 def has_undocumented_endpoints(self) -> bool: 

-

25 return len(self.undocumented) > 0 

-

26 

-

27 def proportion_covered(self) -> float: 

-

28 return len(self.covered) / ( 

-

29 len(self.covered) + len(self.uncovered) + len(self.undocumented) 

-

30 ) 

-

31 

-

32 

-

33def get_endpoint_coverage(spec: dict, step: dict) -> EndpointCoverage: 

-

34 """ 

-

35 Given a parsed spec and step file, determine the endpoints in the spec that are achieved by the step file. 

-

36 

-

37 Args: 

-

38 spec (dict): specification file parsed as dict 

-

39 step (dict): step file parsed as dict 

-

40 

-

41 Returns: 

-

42 EndpointCoverage: A dataclass containing the endpoints covered, uncovered, and undocumented 

-

43 """ 

-

44 

-

45 # Get all endpoints in the spec file 

-

46 spec_endpoints = set(spec["paths"].keys()) 

-

47 

-

48 # Get all endpoints in the step file 

-

49 step_endpoints = set(step["paths"].keys()) 

-

50 

-

51 return EndpointCoverage( 

-

52 covered=spec_endpoints & step_endpoints, 

-

53 uncovered=spec_endpoints - step_endpoints, 

-

54 undocumented=step_endpoints - spec_endpoints, 

-

55 ) 

-
- - - diff --git a/coverage/favicon_32.png b/coverage/favicon_32.png deleted file mode 100644 index 8649f04..0000000 Binary files a/coverage/favicon_32.png and /dev/null differ diff --git a/coverage/index.html b/coverage/index.html deleted file mode 100644 index 832f218..0000000 --- a/coverage/index.html +++ /dev/null @@ -1,180 +0,0 @@ - - - - - API Validator Test Coverage - - - - - -
-
-

API Validator Test Coverage: - 98% -

-
- - -
-

Shortcuts on this page

-
-

- n - s - m - x - b - p - c   change column sorting -

-
-
-
-
- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Modulestatementsmissingexcludedbranchespartialcoverage
api_validator/action.py8400180100%
api_validator/errors.py180040100%
api_validator/models/context.py423022194%
api_validator/models/errors.py13200085%
api_validator/models/request.py410020100%
api_validator/models/response.py120040100%
api_validator/models/schema.py100000100%
api_validator/models/singleton.py70020100%
api_validator/models/step.py140020100%
api_validator/parsing.py472010096%
api_validator/preprocessing.py150020100%
Total3037066198%
-

- No items found using the specified filter. -

-
- - - diff --git a/coverage/index.js b/coverage/index.js deleted file mode 100644 index e057505..0000000 --- a/coverage/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import React from 'react'; - - diff --git a/coverage/keybd_closed.png b/coverage/keybd_closed.png deleted file mode 100644 index ba119c4..0000000 Binary files a/coverage/keybd_closed.png and /dev/null differ diff --git a/coverage/keybd_open.png b/coverage/keybd_open.png deleted file mode 100644 index a8bac6c..0000000 Binary files a/coverage/keybd_open.png and /dev/null differ diff --git a/coverage/status.json b/coverage/status.json deleted file mode 100644 index 54bfe78..0000000 --- a/coverage/status.json +++ /dev/null @@ -1 +0,0 @@ -{"format":2,"version":"6.1.1","globals":"17b24595ac4974bc95d8570644d1e9fd","files":{"d_b4251882f9eb8989_action_py":{"hash":"e6512aa9e22b369a03837f18b2e0476d","index":{"nums":[0,1,84,0,0,18,0,0],"html_filename":"d_b4251882f9eb8989_action_py.html","relative_filename":"api_validator/action.py"}},"d_b4251882f9eb8989_errors_py":{"hash":"9c6522f1d26f4c7869aa35b7cdb355aa","index":{"nums":[0,1,18,0,0,4,0,0],"html_filename":"d_b4251882f9eb8989_errors_py.html","relative_filename":"api_validator/errors.py"}},"d_8e5d7c2a8ace5693_context_py":{"hash":"560a88d00f4364714e5bbca960490478","index":{"nums":[0,1,42,0,3,22,1,1],"html_filename":"d_8e5d7c2a8ace5693_context_py.html","relative_filename":"api_validator/models/context.py"}},"d_8e5d7c2a8ace5693_errors_py":{"hash":"2f6f826eb788afdd8fbe50c8ad953679","index":{"nums":[0,1,13,0,2,0,0,0],"html_filename":"d_8e5d7c2a8ace5693_errors_py.html","relative_filename":"api_validator/models/errors.py"}},"d_8e5d7c2a8ace5693_request_py":{"hash":"6a9bab1b43528141d458197ab206bce3","index":{"nums":[0,1,41,0,0,2,0,0],"html_filename":"d_8e5d7c2a8ace5693_request_py.html","relative_filename":"api_validator/models/request.py"}},"d_8e5d7c2a8ace5693_response_py":{"hash":"a03b666ae4bb9c0c0617b4caa50ab697","index":{"nums":[0,1,12,0,0,4,0,0],"html_filename":"d_8e5d7c2a8ace5693_response_py.html","relative_filename":"api_validator/models/response.py"}},"d_8e5d7c2a8ace5693_schema_py":{"hash":"ba30e1f6f6743c8514c5d0ece4905ae1","index":{"nums":[0,1,10,0,0,0,0,0],"html_filename":"d_8e5d7c2a8ace5693_schema_py.html","relative_filename":"api_validator/models/schema.py"}},"d_8e5d7c2a8ace5693_singleton_py":{"hash":"cb28363fc47ab8aaea9f183966c0faae","index":{"nums":[0,1,7,0,0,2,0,0],"html_filename":"d_8e5d7c2a8ace5693_singleton_py.html","relative_filename":"api_validator/models/singleton.py"}},"d_8e5d7c2a8ace5693_step_py":{"hash":"40d88d69c0b8c4756c3acbc5209134c4","index":{"nums":[0,1,14,0,0,2,0,0],"html_filename":"d_8e5d7c2a8ace5693_step_py.html","relative_filename":"api_validator/models/step.py"}},"d_b4251882f9eb8989_parsing_py":{"hash":"c6dbfd4219bdc0bf1c478330fb83fa5b","index":{"nums":[0,1,47,0,2,10,0,0],"html_filename":"d_b4251882f9eb8989_parsing_py.html","relative_filename":"api_validator/parsing.py"}},"d_b4251882f9eb8989_preprocessing_py":{"hash":"2f3a11d15bc1266941e441b4a5180838","index":{"nums":[0,1,15,0,0,2,0,0],"html_filename":"d_b4251882f9eb8989_preprocessing_py.html","relative_filename":"api_validator/preprocessing.py"}}}} \ No newline at end of file diff --git a/coverage/style.css b/coverage/style.css deleted file mode 100644 index cca6f11..0000000 --- a/coverage/style.css +++ /dev/null @@ -1,307 +0,0 @@ -@charset "UTF-8"; -/* Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 */ -/* For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt */ -/* Don't edit this .css file. Edit the .scss file instead! */ -html, body, h1, h2, h3, p, table, td, th { margin: 0; padding: 0; border: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit; vertical-align: baseline; } - -body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-size: 1em; background: #fff; color: #000; } - -@media (prefers-color-scheme: dark) { body { background: #1e1e1e; } } - -@media (prefers-color-scheme: dark) { body { color: #eee; } } - -html > body { font-size: 16px; } - -a:active, a:focus { outline: 2px dashed #007acc; } - -p { font-size: .875em; line-height: 1.4em; } - -table { border-collapse: collapse; } - -td { vertical-align: top; } - -table tr.hidden { display: none !important; } - -p#no_rows { display: none; font-size: 1.2em; } - -a.nav { text-decoration: none; color: inherit; } - -a.nav:hover { text-decoration: underline; color: inherit; } - -header { background: #f8f8f8; width: 100%; z-index: 2; border-bottom: 1px solid #ccc; } - -@media (prefers-color-scheme: dark) { header { background: black; } } - -@media (prefers-color-scheme: dark) { header { border-color: #333; } } - -header .content { padding: 1rem 3.5rem; } - -header h2 { margin-top: .5em; font-size: 1em; } - -header.sticky { position: fixed; left: 0; right: 0; height: 2.5em; } - -header.sticky .text { display: none; } - -header.sticky h1, header.sticky h2 { font-size: 1em; margin-top: 0; display: inline-block; } - -header.sticky .content { padding: 0.5rem 3.5rem; } - -header.sticky .content p { font-size: 1em; } - -header.sticky ~ #source { padding-top: 6.5em; } - -main { position: relative; z-index: 1; } - -.indexfile footer { margin: 1rem 3.5rem; } - -.pyfile footer { margin: 1rem 1rem; } - -footer .content { padding: 0; color: #666; font-style: italic; } - -@media (prefers-color-scheme: dark) { footer .content { color: #aaa; } } - -#index { margin: 1rem 0 0 3.5rem; } - -h1 { font-size: 1.25em; display: inline-block; } - -#filter_container { float: right; margin: 0 2em 0 0; } - -#filter_container input { width: 10em; padding: 0.2em 0.5em; border: 2px solid #ccc; background: #fff; color: #000; } - -@media (prefers-color-scheme: dark) { #filter_container input { border-color: #444; } } - -@media (prefers-color-scheme: dark) { #filter_container input { background: #1e1e1e; } } - -@media (prefers-color-scheme: dark) { #filter_container input { color: #eee; } } - -#filter_container input:focus { border-color: #007acc; } - -header button { font-family: inherit; font-size: inherit; border: 1px solid; border-radius: .2em; color: inherit; padding: .1em .5em; margin: 1px calc(.1em + 1px); cursor: pointer; border-color: #ccc; } - -@media (prefers-color-scheme: dark) { header button { border-color: #444; } } - -header button:active, header button:focus { outline: 2px dashed #007acc; } - -header button.run { background: #eeffee; } - -@media (prefers-color-scheme: dark) { header button.run { background: #373d29; } } - -header button.run.show_run { background: #dfd; border: 2px solid #00dd00; margin: 0 .1em; } - -@media (prefers-color-scheme: dark) { header button.run.show_run { background: #373d29; } } - -header button.mis { background: #ffeeee; } - -@media (prefers-color-scheme: dark) { header button.mis { background: #4b1818; } } - -header button.mis.show_mis { background: #fdd; border: 2px solid #ff0000; margin: 0 .1em; } - -@media (prefers-color-scheme: dark) { header button.mis.show_mis { background: #4b1818; } } - -header button.exc { background: #f7f7f7; } - -@media (prefers-color-scheme: dark) { header button.exc { background: #333; } } - -header button.exc.show_exc { background: #eee; border: 2px solid #808080; margin: 0 .1em; } - -@media (prefers-color-scheme: dark) { header button.exc.show_exc { background: #333; } } - -header button.par { background: #ffffd5; } - -@media (prefers-color-scheme: dark) { header button.par { background: #650; } } - -header button.par.show_par { background: #ffa; border: 2px solid #bbbb00; margin: 0 .1em; } - -@media (prefers-color-scheme: dark) { header button.par.show_par { background: #650; } } - -#help_panel, #source p .annotate.long { display: none; position: absolute; z-index: 999; background: #ffffcc; border: 1px solid #888; border-radius: .2em; color: #333; padding: .25em .5em; } - -#source p .annotate.long { white-space: normal; float: right; top: 1.75em; right: 1em; height: auto; } - -#help_panel_wrapper { float: right; position: relative; } - -#keyboard_icon { margin: 5px; } - -#help_panel_state { display: none; } - -#help_panel { top: 25px; right: 0; padding: .75em; border: 1px solid #883; } - -#help_panel .legend { font-style: italic; margin-bottom: 1em; } - -.indexfile #help_panel { width: 25em; } - -.pyfile #help_panel { width: 18em; } - -#help_panel_state:checked ~ #help_panel { display: block; } - -.keyhelp { margin-top: .75em; } - -kbd { border: 1px solid black; border-color: #888 #333 #333 #888; padding: .1em .35em; font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: bold; background: #eee; border-radius: 3px; } - -#source { padding: 1em 0 1em 3.5rem; font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; } - -#source p { position: relative; white-space: pre; } - -#source p * { box-sizing: border-box; } - -#source p .n { float: left; text-align: right; width: 3.5rem; box-sizing: border-box; margin-left: -3.5rem; padding-right: 1em; color: #999; } - -@media (prefers-color-scheme: dark) { #source p .n { color: #777; } } - -#source p .n.highlight { background: #ffdd00; } - -#source p .n a { margin-top: -4em; padding-top: 4em; text-decoration: none; color: #999; } - -@media (prefers-color-scheme: dark) { #source p .n a { color: #777; } } - -#source p .n a:hover { text-decoration: underline; color: #999; } - -@media (prefers-color-scheme: dark) { #source p .n a:hover { color: #777; } } - -#source p .t { display: inline-block; width: 100%; box-sizing: border-box; margin-left: -.5em; padding-left: 0.3em; border-left: 0.2em solid #fff; } - -@media (prefers-color-scheme: dark) { #source p .t { border-color: #1e1e1e; } } - -#source p .t:hover { background: #f2f2f2; } - -@media (prefers-color-scheme: dark) { #source p .t:hover { background: #282828; } } - -#source p .t:hover ~ .r .annotate.long { display: block; } - -#source p .t .com { color: #008000; font-style: italic; line-height: 1px; } - -@media (prefers-color-scheme: dark) { #source p .t .com { color: #6a9955; } } - -#source p .t .key { font-weight: bold; line-height: 1px; } - -#source p .t .str { color: #0451a5; } - -@media (prefers-color-scheme: dark) { #source p .t .str { color: #9cdcfe; } } - -#source p.mis .t { border-left: 0.2em solid #ff0000; } - -#source p.mis.show_mis .t { background: #fdd; } - -@media (prefers-color-scheme: dark) { #source p.mis.show_mis .t { background: #4b1818; } } - -#source p.mis.show_mis .t:hover { background: #f2d2d2; } - -@media (prefers-color-scheme: dark) { #source p.mis.show_mis .t:hover { background: #532323; } } - -#source p.run .t { border-left: 0.2em solid #00dd00; } - -#source p.run.show_run .t { background: #dfd; } - -@media (prefers-color-scheme: dark) { #source p.run.show_run .t { background: #373d29; } } - -#source p.run.show_run .t:hover { background: #d2f2d2; } - -@media (prefers-color-scheme: dark) { #source p.run.show_run .t:hover { background: #404633; } } - -#source p.exc .t { border-left: 0.2em solid #808080; } - -#source p.exc.show_exc .t { background: #eee; } - -@media (prefers-color-scheme: dark) { #source p.exc.show_exc .t { background: #333; } } - -#source p.exc.show_exc .t:hover { background: #e2e2e2; } - -@media (prefers-color-scheme: dark) { #source p.exc.show_exc .t:hover { background: #3c3c3c; } } - -#source p.par .t { border-left: 0.2em solid #bbbb00; } - -#source p.par.show_par .t { background: #ffa; } - -@media (prefers-color-scheme: dark) { #source p.par.show_par .t { background: #650; } } - -#source p.par.show_par .t:hover { background: #f2f2a2; } - -@media (prefers-color-scheme: dark) { #source p.par.show_par .t:hover { background: #6d5d0c; } } - -#source p .r { position: absolute; top: 0; right: 2.5em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; } - -#source p .annotate { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; color: #666; padding-right: .5em; } - -@media (prefers-color-scheme: dark) { #source p .annotate { color: #ddd; } } - -#source p .annotate.short:hover ~ .long { display: block; } - -#source p .annotate.long { width: 30em; right: 2.5em; } - -#source p input { display: none; } - -#source p input ~ .r label.ctx { cursor: pointer; border-radius: .25em; } - -#source p input ~ .r label.ctx::before { content: "▶ "; } - -#source p input ~ .r label.ctx:hover { background: #e8f4ff; color: #666; } - -@media (prefers-color-scheme: dark) { #source p input ~ .r label.ctx:hover { background: #0f3a42; } } - -@media (prefers-color-scheme: dark) { #source p input ~ .r label.ctx:hover { color: #aaa; } } - -#source p input:checked ~ .r label.ctx { background: #d0e8ff; color: #666; border-radius: .75em .75em 0 0; padding: 0 .5em; margin: -.25em 0; } - -@media (prefers-color-scheme: dark) { #source p input:checked ~ .r label.ctx { background: #056; } } - -@media (prefers-color-scheme: dark) { #source p input:checked ~ .r label.ctx { color: #aaa; } } - -#source p input:checked ~ .r label.ctx::before { content: "▼ "; } - -#source p input:checked ~ .ctxs { padding: .25em .5em; overflow-y: scroll; max-height: 10.5em; } - -#source p label.ctx { color: #999; display: inline-block; padding: 0 .5em; font-size: .8333em; } - -@media (prefers-color-scheme: dark) { #source p label.ctx { color: #777; } } - -#source p .ctxs { display: block; max-height: 0; overflow-y: hidden; transition: all .2s; padding: 0 .5em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; white-space: nowrap; background: #d0e8ff; border-radius: .25em; margin-right: 1.75em; } - -@media (prefers-color-scheme: dark) { #source p .ctxs { background: #056; } } - -#source p .ctxs span { display: block; text-align: right; } - -#index { font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.875em; } - -#index table.index { margin-left: -.5em; } - -#index td, #index th { text-align: right; width: 5em; padding: .25em .5em; border-bottom: 1px solid #eee; } - -@media (prefers-color-scheme: dark) { #index td, #index th { border-color: #333; } } - -#index td.name, #index th.name { text-align: left; width: auto; } - -#index th { font-style: italic; color: #333; cursor: pointer; } - -@media (prefers-color-scheme: dark) { #index th { color: #ddd; } } - -#index th:hover { background: #eee; } - -@media (prefers-color-scheme: dark) { #index th:hover { background: #333; } } - -#index th[aria-sort="ascending"], #index th[aria-sort="descending"] { white-space: nowrap; background: #eee; padding-left: .5em; } - -@media (prefers-color-scheme: dark) { #index th[aria-sort="ascending"], #index th[aria-sort="descending"] { background: #333; } } - -#index th[aria-sort="ascending"]::after { font-family: sans-serif; content: " ↑"; } - -#index th[aria-sort="descending"]::after { font-family: sans-serif; content: " ↓"; } - -#index td.name a { text-decoration: none; color: inherit; } - -#index tr.total td, #index tr.total_dynamic td { font-weight: bold; border-top: 1px solid #ccc; border-bottom: none; } - -#index tr.file:hover { background: #eee; } - -@media (prefers-color-scheme: dark) { #index tr.file:hover { background: #333; } } - -#index tr.file:hover td.name { text-decoration: underline; color: inherit; } - -#scroll_marker { position: fixed; z-index: 3; right: 0; top: 0; width: 16px; height: 100%; background: #fff; border-left: 1px solid #eee; will-change: transform; } - -@media (prefers-color-scheme: dark) { #scroll_marker { background: #1e1e1e; } } - -@media (prefers-color-scheme: dark) { #scroll_marker { border-color: #333; } } - -#scroll_marker .marker { background: #ccc; position: absolute; min-height: 3px; width: 100%; } - -@media (prefers-color-scheme: dark) { #scroll_marker .marker { background: #444; } } diff --git a/pypony.py b/pypony.py index d027f8e..3ae4105 100644 --- a/pypony.py +++ b/pypony.py @@ -4,13 +4,14 @@ import click from actions_toolkit import core -from src.action import verify_api +from src.validate import verify_api @click.group() def cli(): pass + @cli.command() @click.option( "--spec_file", required=True, type=click.STRING, envvar="INPUT_SPEC_FILE" @@ -19,10 +20,10 @@ def cli(): "--step_file", required=True, type=click.STRING, envvar="INPUT_STEP_FILE" ) @click.option( - "-ff", "--fail-fast", default=False, type=click.BOOL, envvar="INPUT_FAIL_FAST" + "-ff", "--fail-fast", is_flag=True ) @click.option( - "-v", "--verbose", default=False, type=click.BOOL, envvar="INPUT_VERBOSE" + "-v", "--verbose", is_flag=True ) def main(spec_file, step_file, fail_fast, verbose): try: @@ -35,4 +36,5 @@ def main(spec_file, step_file, fail_fast, verbose): sys.exit(1) + main() diff --git a/requirements.dev.txt b/requirements.dev.txt index 4582efc..e282d2d 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1,9 +1,6 @@ -coverage==6.1.1 Django==3.2.9 django_extensions==3.1.3 djangorestframework==3.12.4 Faker==9.8.0 genbadge[coverage]==1.0.6 pytest==7.0.1 -Sphinx==4.2.0 -sphinx-rtd-theme==1.0.0 diff --git a/requirements.txt b/requirements.txt index 6c3e144..7ce0a7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,4 @@ py-openapi-schema-to-json-schema==0.0.3 python-dotenv==0.19.1 PyYAML==6.0 requests==2.26.0 -actions-toolkit==0.0.9 +rich==11.2.0 diff --git a/setup.py b/setup.py index 2d62322..85d6350 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,8 @@ from setuptools import setup, find_packages +from pathlib import Path + +this_directory = Path(__file__).parent +long_description = (this_directory / "README.md").read_text() with open("requirements.txt", "r") as fp: requirements = fp.readlines() @@ -6,14 +10,16 @@ setup( name='PyPony', description='A python utility for contract testing APIs', + long_description=long_description, + long_description_content_type='text/markdown', author='Bandwidth', author_email='letstalk@bandwidth.com', url='https://github.com/Bandwidth/pypony/', version='0.1.0', py_modules=['pypony', 'src'], install_requires=requirements, - packages = find_packages(exclude=["website", "test"]), - entry_points = ''' + packages=find_packages(exclude=["website", "test"]), + entry_points=''' [console_scripts] pypony=pypony:cli ''' diff --git a/src/__main__.py b/src/__main__.py deleted file mode 100644 index e88f090..0000000 --- a/src/__main__.py +++ /dev/null @@ -1,34 +0,0 @@ -import sys -import traceback - -import click -from actions_toolkit import core - -from .action import verify_api - - -@click.command() -@click.option( - "--spec_file", required=True, type=click.STRING, envvar="INPUT_SPEC_FILE" -) -@click.option( - "--step_file", required=True, type=click.STRING, envvar="INPUT_STEP_FILE" -) -@click.option( - "-ff", "--fail-fast", default=False, type=click.BOOL, envvar="INPUT_FAIL_FAST" -) -@click.option( - "-v", "--verbose", default=False, type=click.BOOL, envvar="INPUT_VERBOSE" -) -def main(spec_file, step_file, fail_fast, verbose): - try: - verify_api(spec_file, step_file, fail_fast, verbose) - except BaseException as e: - if verbose: - core.error(traceback.format_exc(), title=e.__class__.__name__) - else: - core.error(str(e), title=e.__class__.__name__) - - sys.exit(1) - -main() diff --git a/src/action.py b/src/validate.py similarity index 87% rename from src/action.py rename to src/validate.py index d349a0b..da38233 100644 --- a/src/action.py +++ b/src/validate.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""action.py +"""validate.py This module contains the primary code for the GitHub Action. Using the parsed OpenAPI specs and step files, it will create API requests and @@ -9,7 +9,7 @@ import traceback import requests -from actions_toolkit import core +from rich import print, print_json from dotenv import load_dotenv from jschon import create_catalog from jschon.jsonschema import OutputFormat @@ -50,14 +50,12 @@ def parse_spec_steps(spec_file_path: str, step_file_path: str) -> tuple[dict, di """ # Parse OpenAPI specification file - core.start_group("Parsing spec file") + print("---Parsing spec file---") spec_data = parse_spec(spec_file_path) - core.end_group() # Parse step file - core.start_group("Parsing step file") + print("---Parsing step file---") steps_data = parse_steps(step_file_path) - core.end_group() return spec_data, steps_data @@ -104,7 +102,7 @@ def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: b ResponseValidationError: If the response does not match the expected response according to schema """ - core.info('Validating APIs') + print('---Validating APIs---') # Create requests base_url: str = steps_data["base_url"] @@ -113,8 +111,6 @@ def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: b # Go through each path in the steps path_value: dict for path_key, path_value in paths.items(): - core.start_group(path_key) - # Go through each method in each path method_value: dict for method_name, method_value in path_value.items(): @@ -130,7 +126,7 @@ def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: b try: # Get step name step_name = step_data.pop("name") - core.info(step_name) + print(step_name) # Create Request object path_url = step_data.pop("url") @@ -139,12 +135,13 @@ def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: b # Evaluate expressions request.evaluate_all() - core.info(' Request:') - core.info(f' {request.method} {request.url}') - core.info(f' Authentication: {request.auth}') - core.info(f' Body: {request.body}') - core.info(f' Headers: {request.headers}') - core.info(f' Parameters: {request.params}') + print('Request:') + print(f'{request.method} {request.url}') + print(f'Authentication: {request.auth}') + print(f'Body:') + print_json(data=request.body, indent=4) + print(f'Headers: {request.headers}') + print(f'Parameters: {request.params}') # Create Step object step = Step(step_name, request) @@ -163,10 +160,10 @@ def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: b response = step.response - core.info(' Response:') - core.info(f' HTTP {response.status_code} {response.reason}') - core.info(f' Body: {response.body}') - core.info('') + print('Response:') + print(f'HTTP {response.status_code} {response.reason}') + print_json(data=response.body, indent=4) + print('') status_code = step.response.status_code @@ -211,11 +208,9 @@ def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: b raise e if verbose: - core.warning(traceback.format_exc(), title=e.__class__.__name__) + print(f'[red]{traceback.format_exc()}[/red]') else: - core.warning(str(e), title=e.__class__.__name__) - - core.end_group() + print(f'[bold red]{str(e)}[/bold red]') def verify_api(spec_file_path: str, step_file_path: str, fail_fast: bool = False, verbose: bool = False): diff --git a/test/README.md b/test/README.md index 96aab01..ea85c86 100644 --- a/test/README.md +++ b/test/README.md @@ -5,7 +5,7 @@ ## Folder Structure - `/test` - - `action_test.py` + - `validate_test.py` - `...` - `/fixtures` (mock data) - `/specs` diff --git a/test/action_test.py b/test/validate_test.py similarity index 99% rename from test/action_test.py rename to test/validate_test.py index 5902b7e..d8a5d72 100644 --- a/test/action_test.py +++ b/test/validate_test.py @@ -5,7 +5,7 @@ import prance import pytest -from src.action import verify_api +from src.validate import verify_api from src.errors import ( UndocumentedEndpointError, InsufficientCoverageError,