From 775f86f75321ad82b2bd903d19a52fa1430bd9ec Mon Sep 17 00:00:00 2001 From: Marcus Pereira Date: Thu, 8 Oct 2020 14:17:00 -0300 Subject: [PATCH 01/21] Fix publish test pypi (#307) --- .github/workflows/publish-to-test-pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index 61c7871c..e4a4debe 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -22,4 +22,4 @@ jobs: poetry_version: ">=0.12" pypi_token: ${{ secrets.TEST_PYPI_TOKEN }} repository_name: "testpypi" - repository_url: "https://test.pypi.org/legacy/" + repository_url: "https://test.pypi.org/legacy/" \ No newline at end of file From da0178a0600a6d609361e0ae1001a48f809ec753 Mon Sep 17 00:00:00 2001 From: Jelena Dokic Date: Thu, 15 Oct 2020 00:39:10 +0200 Subject: [PATCH 02/21] Updated poetry-publish version (#311) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ea9de4b..f59af424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- Updated poetry-publish version to v1.3 [#311](https://github.com/scanapi/scanapi/pull/311) ## [2.5.0] - 2021-07-23 ### Added From c5605dcad04fcfa891b0bce12273a14c3f9d643c Mon Sep 17 00:00:00 2001 From: Jelena Dokic Date: Thu, 15 Oct 2020 01:14:50 +0200 Subject: [PATCH 03/21] --no-report flag (#299) --- CHANGELOG.md | 3 +++ scanapi/__main__.py | 7 +++++++ scanapi/reporter.py | 14 ++++++++++++++ tests/unit/test_settings.py | 1 + 4 files changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f59af424..b8b462ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Add `--no-report` flag. [#299](https://github.com/scanapi/scanapi/pull/299) + ### Changed - Updated poetry-publish version to v1.3 [#311](https://github.com/scanapi/scanapi/pull/311) diff --git a/scanapi/__main__.py b/scanapi/__main__.py index 5f48e48a..66ffec86 100644 --- a/scanapi/__main__.py +++ b/scanapi/__main__.py @@ -36,6 +36,13 @@ def main(): is_flag=True, help="Run ScanAPI without generating report.", ) +@click.option( + "-n", + "--no-report", + "no_report", + is_flag=True, + help="Run ScanAPI without generating report.", +) @click.option( "-c", "--config-path", diff --git a/scanapi/reporter.py b/scanapi/reporter.py index 8b843574..2404dec5 100644 --- a/scanapi/reporter.py +++ b/scanapi/reporter.py @@ -44,6 +44,20 @@ def write(self, results): logger.info("\nThe documentation was generated successfully.") logger.info(f"It is available at {abspath(self.output_path)}") + def write_without_generating_report(self, results): + """ Part of the Reporter instance that is responsible for writing the results without + generating the scanapi-report.html. + """ + logger.info("Writing results without generating report") + for r in results: + if logger.root.level == logging.DEBUG: + continue + else: + for test in r["tests_results"]: + logger.info(f" [{test['status'].upper()}] {test['name']}") + if test["status"] == "failed": + logger.info(f"\t {test['failure']} is false") + @staticmethod def write_without_generating_report(results): """Part of the Reporter instance that is responsible for writing the diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index daa529d3..e86967ab 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -143,6 +143,7 @@ def test_should_clean_and_save_preferences(self): "reporter": None, "output_path": "path/output-path", "template": None, + "no_report": False, "config_path": "path/config-path", } ) From e8c8fecc3dee98322818b8e8d4799df7efd50434 Mon Sep 17 00:00:00 2001 From: vjern Date: Thu, 15 Oct 2020 23:46:03 +0200 Subject: [PATCH 04/21] Add remote method call implementation with inplace import path resolution --- scanapi/evaluators/rmc.py | 216 +++++++++++++++++++++++++++++++++++++ scanapi/evaluators/test.py | 20 ++++ 2 files changed, 236 insertions(+) create mode 100644 scanapi/evaluators/rmc.py create mode 100644 scanapi/evaluators/test.py diff --git a/scanapi/evaluators/rmc.py b/scanapi/evaluators/rmc.py new file mode 100644 index 00000000..6a0179d5 --- /dev/null +++ b/scanapi/evaluators/rmc.py @@ -0,0 +1,216 @@ +from typing import Dict, Any, List, Tuple, Optional, Callable +import os +import sys +import ast +import re +import operator +from functools import partial +import importlib +import inspect +from unittest.mock import Mock + + +_cwd = os.getcwd() +_sentinel = object() + + +def rpartial(f, *a, **kw): + if not a and not kw: + return f + def rpartialer(*_a, **_kw): + __a = (*_a, *a) + __kw = dict(kw) + __kw.update(_kw) + return f(*__a, **__kw) + return rpartialer + + +def get_module(name: str): + global _cwd + if _cwd is None: + _cwd = os.getcwd() + sys.path.append(_cwd) + module = sys.modules.get(name) or importlib.import_module(name) + print(f'Loaded {module}') + return module + + +def fetch(location: str) -> Callable: + # Search for a module named after it in sys ? + # Look at the bottom of the stack then import wildly ? or _cwd + # Your choice + module_name, *trail = location.split('.') + if not trail: + raise Exception + module = get_module(module_name) + node = module + i = 0 + for i, name in enumerate(trail): + next_node = getattr(node, name, _sentinel) + if next_node is _sentinel: + if type(node) is type(module): + print('No attribute found. Trying to import as submodule ') + try: + next_node = importlib.import_module(name, package=node.__name__) + except ModuleNotFoundError: + pass + if next_node is _sentinel: + raise AttributeError(f'No such location: {module_name}.{".".join(trail[:i + 1])}') + node = next_node + return node + + +def as_kw(token: str) -> Optional[re.Match]: + return re.match(r'^([\w.-]+)\s*=+\s*(.*)$', token) + + +def safe_eval(expr: str) -> Any: + try: + return ast.literal_eval(expr) + except SyntaxError as e: + e.args = (*e.args, expr) + raise + + +def parse(args: str, allow_position_marker: bool = False) -> Tuple[Tuple[Any, ...], Dict[str, Any]]: + """Parse a comma-separated list of simple Python expressions.""" + right_bind = False + tokens = re.split(r',(?!\)|\]|\})', args) + tokens = list(map(str.strip, tokens)) + if tokens[0] == '/': + right_bind = True + tokens = tokens[1:] + a = list(token for token in tokens if as_kw(token) is None) + kw = dict(i.groups() for i in map(as_kw, tokens) if i is not None) + for i, item in enumerate(a): a[i] = safe_eval(item) + for name, kwarg in kw.items(): kw[name] = safe_eval(kwarg) + if allow_position_marker: + return tuple(a), kw, right_bind + return tuple(a), kw + + +def remote_method_call( + expr: str, + vars: Dict[str, Any], + is_a_test_case: bool = False +): + expr = str(expr) + print(f'expr = {expr}') + print(f'vars = {vars}') + + # + + # Parse expr + regex = r'^!([\w.]+)(?:\((.*)\))?$' + m = re.match(regex, expr) + if m is None: + raise ValueError(f'Could not parse ! expr {expr!r}') + name, argexpr = m.groups() + print('name =', name) + print('argexpr =', argexpr) + + # Build f + f = fetch(name) + spec = inspect.getfullargspec(f) + + if argexpr: + a, kw, right_bind = parse(argexpr, allow_position_marker=True) + print('a =', a) + print('kw =', kw) + if not right_bind: + f = partial(f, *a, **kw) + else: + f = rpartial(f, *a, **kw) + # + + args = [] + # for key in spec.args: + # if key not in vars: + # break + # args.append(vars[key]) + kwargs = {k: vars[k] for k in vars.keys() & {*spec.kwonlyargs, *spec.args}} + # ^ this collide with right_bind + result = f(*args, **kwargs) + #or f(**vars) but need to filter suitable args from spec first + + if is_a_test_case: + return (True, None) if operator.truth(result) else (False, expr) + + return result + + +def test_rmc(): + + rmc = remote_method_call + + assert rmc('!test.it', {'a': 3}) == 3 + assert rmc('!test.it.it', {'a': 3}) == 3 + assert rmc('!test.it(b=4)', {'a': 3}) == 7 + + assert rmc('!test.response.ok', {'response': Mock(status=400)}) == False + + assert rmc('!test.response.ok', {'response': Mock(status=200)}) == True + + assert rmc('!test.response.status_is(code=200)', {'response': Mock(status=200)}) == True + assert rmc('!test.response.status_is(code=200)', {'response': Mock(status=400)}) == False + + assert rmc('!test.response.status_is(200)', {'response': Mock(status=200)}) == True + assert rmc('!test.response.status_is(200)', {'response': Mock(status=400)}) == False + + +def test_get_module(): + assert hasattr(get_module('test'), 'it') + assert hasattr(get_module('operator'), '__add__') + assert hasattr(get_module('pathlib'), 'Path') + + +def test_parse(): + + assert parse('3') == ((3,), {}) + assert parse('"jon"') == (('jon',), {}) + assert parse('("jon")') == (('jon',), {}) + assert parse('("jon",)') == ((('jon',),), {}) + + assert parse('3, 4') == ((3, 4), {}) + + assert parse('3, b=5') == ((3,), {'b': 5}) + assert parse('3, b=5, c=4') == ((3,), {'b': 5, 'c': 4}) + assert parse('3, b=5, 66, c.x=4') == ((3, 66), {'b': 5, 'c.x': 4}) + + assert parse('3, b=5, c.x=4') == ((3,), {'b': 5, 'c.x': 4}) + assert parse('3, b==5') == ((3,), {'b': 5}) + + try: + parse('/, 3') + except SyntaxError as e: + assert e.args[:-1] == '/' + + assert parse('3', allow_position_marker=True) == ((3,), {}, False) + assert parse('/, 3', allow_position_marker=True) == ((3,), {}, True) + + +def test_as_kw(): + assert as_kw('b=5') and as_kw('b=5').groups() == ('b', '5') + assert not as_kw('3') + + +def test_safe_eval(): + + assert safe_eval('3') == 3 + + try: + safe_eval('3+') == 3 + except SyntaxError as e: + *_, a = e.args + assert '3+' == a + + +def test_rpartial(): + + def f(a, b, c, d): + return a, b, c, d + + assert rpartial(f) == f + assert rpartial(f, 5)(2, 3, 4) == (2, 3, 4, 5) + assert rpartial(f, 4, 5)(2, 3) == (2, 3, 4, 5) + assert rpartial(f, 3, 4, 5)(2) == (2, 3, 4, 5) \ No newline at end of file diff --git a/scanapi/evaluators/test.py b/scanapi/evaluators/test.py new file mode 100644 index 00000000..0452b6ef --- /dev/null +++ b/scanapi/evaluators/test.py @@ -0,0 +1,20 @@ +def it(a: int, b: int = 0) -> int: + return a + b + +it.it = it + + +def off(a: int, b: int, status: str = 'x') -> int: + return str(a) + '+' + str(b) + '-' + status + + +class response: + + def ok(response): + return response.status < 400 + + # def status_is(response, code: int): + # return response.status == code + + def status_is(code: int, response): + return response.status == code From 8a1766bed5b2c190d294212231e5cdf7f6811b4a Mon Sep 17 00:00:00 2001 From: vjern Date: Fri, 16 Oct 2020 00:31:25 +0200 Subject: [PATCH 05/21] Fix relative imports --- scanapi/evaluators/code_evaluator.py | 5 ++ scanapi/evaluators/rmc.py | 104 +++++++++++++++++++-------- 2 files changed, 79 insertions(+), 30 deletions(-) diff --git a/scanapi/evaluators/code_evaluator.py b/scanapi/evaluators/code_evaluator.py index 03c4cad2..d7921046 100644 --- a/scanapi/evaluators/code_evaluator.py +++ b/scanapi/evaluators/code_evaluator.py @@ -8,6 +8,7 @@ import uuid # noqa: F401 from scanapi.errors import InvalidPythonCodeError +from . import rmc logger = logging.getLogger(__name__) @@ -27,6 +28,10 @@ def evaluate(cls, sequence, vars, is_a_test_case=False): code = match.group("python_code") response = vars.get("response") + code = code.strip() + if code.startswith('!'): + return rmc.remote_method_call(code, vars, is_a_test_case) + try: if is_a_test_case: return cls._assert_code(code, response) diff --git a/scanapi/evaluators/rmc.py b/scanapi/evaluators/rmc.py index 6a0179d5..bd61bb2e 100644 --- a/scanapi/evaluators/rmc.py +++ b/scanapi/evaluators/rmc.py @@ -1,44 +1,41 @@ -from typing import Dict, Any, List, Tuple, Optional, Callable import os import sys import ast import re import operator -from functools import partial -import importlib import inspect +import importlib +from functools import partial from unittest.mock import Mock +from typing import Dict, Any, List, Tuple, Optional, Callable -_cwd = os.getcwd() +_cwd = None _sentinel = object() -def rpartial(f, *a, **kw): +def rpartial(f, *a, **kw) -> Callable: + """Partially bind a function from the right side of positional arguments.""" if not a and not kw: return f def rpartialer(*_a, **_kw): - __a = (*_a, *a) - __kw = dict(kw) - __kw.update(_kw) - return f(*__a, **__kw) + return f(*_a, *a, **kw, **_kw) return rpartialer def get_module(name: str): - global _cwd - if _cwd is None: - _cwd = os.getcwd() - sys.path.append(_cwd) + """Attempt to load a module looking from the current working directory.""" + # global _cwd + # if _cwd is None: + # _cwd = os.getcwd() + # sys.path.insert(1, _cwd) module = sys.modules.get(name) or importlib.import_module(name) print(f'Loaded {module}') return module def fetch(location: str) -> Callable: - # Search for a module named after it in sys ? - # Look at the bottom of the stack then import wildly ? or _cwd - # Your choice + """Fetch a callable from a given location, wich can span across submodules/attributes.""" module_name, *trail = location.split('.') if not trail: raise Exception @@ -46,14 +43,15 @@ def fetch(location: str) -> Callable: node = module i = 0 for i, name in enumerate(trail): + print('node =', node) next_node = getattr(node, name, _sentinel) if next_node is _sentinel: if type(node) is type(module): - print('No attribute found. Trying to import as submodule ') + print(f'No {name!r} attribute found. Trying to import as submodule from {node.__name__!r}') try: - next_node = importlib.import_module(name, package=node.__name__) - except ModuleNotFoundError: - pass + next_node = importlib.import_module('.' + name, package=node.__name__) + except ModuleNotFoundError as e: + print(type(e), e) if next_node is _sentinel: raise AttributeError(f'No such location: {module_name}.{".".join(trail[:i + 1])}') node = next_node @@ -65,6 +63,7 @@ def as_kw(token: str) -> Optional[re.Match]: def safe_eval(expr: str) -> Any: + """Literal Eval an expression, and hydrate the raised exception with the input expr.""" try: return ast.literal_eval(expr) except SyntaxError as e: @@ -73,20 +72,20 @@ def safe_eval(expr: str) -> Any: def parse(args: str, allow_position_marker: bool = False) -> Tuple[Tuple[Any, ...], Dict[str, Any]]: - """Parse a comma-separated list of simple Python expressions.""" + """Parse a comma-separated list of ast.literal_eval eligible Python expressions.""" right_bind = False tokens = re.split(r',(?!\)|\]|\})', args) tokens = list(map(str.strip, tokens)) if tokens[0] == '/': right_bind = True tokens = tokens[1:] - a = list(token for token in tokens if as_kw(token) is None) - kw = dict(i.groups() for i in map(as_kw, tokens) if i is not None) - for i, item in enumerate(a): a[i] = safe_eval(item) - for name, kwarg in kw.items(): kw[name] = safe_eval(kwarg) + args = (token for token in tokens if as_kw(token) is None) + args = list(map(safe_eval, args)) + kwargs = (i.groups() for i in map(as_kw, tokens) if i is not None) + kwargs = {k: safe_eval(v) for k, v in kwargs} if allow_position_marker: - return tuple(a), kw, right_bind - return tuple(a), kw + return tuple(args), kwargs, right_bind + return tuple(args), kwargs def remote_method_call( @@ -94,12 +93,50 @@ def remote_method_call( vars: Dict[str, Any], is_a_test_case: bool = False ): + """ + Parse a remote method call (rmc) expression, then run it against input `vars`. + + A rmc expression starts with a ! followed by an ident, and optionally a set + of simple arguments to bind to the function: + + def ok(response): + return response.status_code == 200 + + def status_is(code, response): + return code == r esponse.status_code + + {{ !mymodule.response.ok }} + # with positional arguments + {{ !mymodule.response.status_is(200) }} + # or keyword arguments + {{ !mymodule.response.status_is(code=200) }} + + Beware that partial binding binds from the left on positional arguments, so + you should expect your positional arguments to be fed through the expression + rather than from `vars`, ie don't write this but the above: + + def status_is(response, code): # response would be 200 here and a collision would happen + ... + + --- + + `vars` are fed to the function as keyword arguments; only `vars` keys found in + the function spec are fed to the function, so you can write stuff like this: + + def analyze_response(response): + ... + + with vars = {'response': ... , 'book_id': 333} + ${{ !mymodule.analyze_response }} + + to just get the vars you're interested in. + + """ + expr = str(expr) print(f'expr = {expr}') print(f'vars = {vars}') - # - # Parse expr regex = r'^!([\w.]+)(?:\((.*)\))?$' m = re.match(regex, expr) @@ -157,6 +194,13 @@ def test_rmc(): assert rmc('!test.response.status_is(200)', {'response': Mock(status=200)}) == True assert rmc('!test.response.status_is(200)', {'response': Mock(status=400)}) == False + # Test kw selection + assert rmc('!test.it', {'a': 3, 'c': 3}) == 3 + assert rmc('!test.it', {'a': 3, 'b': 4, 'arbre': 'kglf'}) == 7 + + # Self import actually works + assert rmc('!rmc.safe_eval("[3]")', {}) == [3] + def test_get_module(): assert hasattr(get_module('test'), 'it') @@ -213,4 +257,4 @@ def f(a, b, c, d): assert rpartial(f) == f assert rpartial(f, 5)(2, 3, 4) == (2, 3, 4, 5) assert rpartial(f, 4, 5)(2, 3) == (2, 3, 4, 5) - assert rpartial(f, 3, 4, 5)(2) == (2, 3, 4, 5) \ No newline at end of file + assert rpartial(f, 3, 4, 5)(2) == (2, 3, 4, 5) From af8dde2ca2355b42c4c4ae3aa4e9adb8883e2735 Mon Sep 17 00:00:00 2001 From: vjern Date: Fri, 23 Oct 2020 01:15:11 +0200 Subject: [PATCH 06/21] Simplify code with ast.parse + add std module --- scanapi/evaluators/code_evaluator.py | 9 +- scanapi/evaluators/rmc.py | 247 ++++++++------------------- scanapi/evaluators/test.py | 6 +- scanapi/std.py | 19 +++ tests/unit/evaluators/test_rmc.py | 35 ++++ 5 files changed, 132 insertions(+), 184 deletions(-) create mode 100644 scanapi/std.py create mode 100644 tests/unit/evaluators/test_rmc.py diff --git a/scanapi/evaluators/code_evaluator.py b/scanapi/evaluators/code_evaluator.py index d7921046..d7d9ceed 100644 --- a/scanapi/evaluators/code_evaluator.py +++ b/scanapi/evaluators/code_evaluator.py @@ -28,9 +28,12 @@ def evaluate(cls, sequence, vars, is_a_test_case=False): code = match.group("python_code") response = vars.get("response") - code = code.strip() - if code.startswith('!'): - return rmc.remote_method_call(code, vars, is_a_test_case) + rmc_match = rmc.pattern.match(code.strip()) + if rmc_match: + try: + return rmc.remote_method_call(code, vars, is_a_test_case=is_a_test_case) + except Exception as e: + raise InvalidPythonCodeError(str(e), code) try: if is_a_test_case: diff --git a/scanapi/evaluators/rmc.py b/scanapi/evaluators/rmc.py index bd61bb2e..02d4ae77 100644 --- a/scanapi/evaluators/rmc.py +++ b/scanapi/evaluators/rmc.py @@ -7,89 +7,55 @@ import importlib from functools import partial from unittest.mock import Mock -from typing import Dict, Any, List, Tuple, Optional, Callable +from typing import Dict, Any, List, Tuple, Optional, Callable, Union +from scanapi import std -_cwd = None _sentinel = object() - -def rpartial(f, *a, **kw) -> Callable: - """Partially bind a function from the right side of positional arguments.""" - if not a and not kw: - return f - def rpartialer(*_a, **_kw): - return f(*_a, *a, **kw, **_kw) - return rpartialer +pattern = re.compile( + r'^(?P[\w.]*):(?P.*)$' +) def get_module(name: str): - """Attempt to load a module looking from the current working directory.""" - # global _cwd - # if _cwd is None: - # _cwd = os.getcwd() - # sys.path.insert(1, _cwd) - module = sys.modules.get(name) or importlib.import_module(name) + if name.lower() == 'std': + return std + module = importlib.import_module(name) print(f'Loaded {module}') return module -def fetch(location: str) -> Callable: +def fetch(location: str, module = None) -> Callable: """Fetch a callable from a given location, wich can span across submodules/attributes.""" - module_name, *trail = location.split('.') - if not trail: - raise Exception - module = get_module(module_name) + + if module is None: + module_name, *trail = location.split('.') + if not trail: + raise Exception + module = get_module(module_name) + else: + trail = location.split('.') + node = module - i = 0 for i, name in enumerate(trail): - print('node =', node) - next_node = getattr(node, name, _sentinel) - if next_node is _sentinel: - if type(node) is type(module): - print(f'No {name!r} attribute found. Trying to import as submodule from {node.__name__!r}') - try: - next_node = importlib.import_module('.' + name, package=node.__name__) - except ModuleNotFoundError as e: - print(type(e), e) - if next_node is _sentinel: - raise AttributeError(f'No such location: {module_name}.{".".join(trail[:i + 1])}') - node = next_node + try: + node = getattr(node, name) + except AttributeError: + raise AttributeError(f'No such location: {module.__name__}.{".".join(trail[:i + 1])}') return node -def as_kw(token: str) -> Optional[re.Match]: - return re.match(r'^([\w.-]+)\s*=+\s*(.*)$', token) - - -def safe_eval(expr: str) -> Any: - """Literal Eval an expression, and hydrate the raised exception with the input expr.""" - try: - return ast.literal_eval(expr) - except SyntaxError as e: - e.args = (*e.args, expr) - raise - - -def parse(args: str, allow_position_marker: bool = False) -> Tuple[Tuple[Any, ...], Dict[str, Any]]: - """Parse a comma-separated list of ast.literal_eval eligible Python expressions.""" - right_bind = False - tokens = re.split(r',(?!\)|\]|\})', args) - tokens = list(map(str.strip, tokens)) - if tokens[0] == '/': - right_bind = True - tokens = tokens[1:] - args = (token for token in tokens if as_kw(token) is None) - args = list(map(safe_eval, args)) - kwargs = (i.groups() for i in map(as_kw, tokens) if i is not None) - kwargs = {k: safe_eval(v) for k, v in kwargs} - if allow_position_marker: - return tuple(args), kwargs, right_bind - return tuple(args), kwargs +def unroll_name(name: Union[str, ast.Attribute]) -> str: + if isinstance(name, str): + return name + if isinstance(name, ast.Name): + return name.id + return unroll_name(name.value) + '.' + name.attr def remote_method_call( - expr: str, + code: str, vars: Dict[str, Any], is_a_test_case: bool = False ): @@ -105,11 +71,11 @@ def ok(response): def status_is(code, response): return code == r esponse.status_code - {{ !mymodule.response.ok }} + {{ mymodule:response.ok }} # with positional arguments - {{ !mymodule.response.status_is(200) }} + {{ mymodule:response.status_is(200) }} # or keyword arguments - {{ !mymodule.response.status_is(code=200) }} + {{ mymodule:response.status_is(code=200) }} Beware that partial binding binds from the left on positional arguments, so you should expect your positional arguments to be fed through the expression @@ -133,128 +99,53 @@ def analyze_response(response): """ - expr = str(expr) - print(f'expr = {expr}') - print(f'vars = {vars}') + code = str(code) # Parse expr - regex = r'^!([\w.]+)(?:\((.*)\))?$' - m = re.match(regex, expr) + m = pattern.match(code) if m is None: - raise ValueError(f'Could not parse ! expr {expr!r}') - name, argexpr = m.groups() - print('name =', name) - print('argexpr =', argexpr) + raise ValueError( + "Failed to parse expr: %r" % code + ) + + modulename, callcode = m.groups() + modulename = modulename or 'std' + + expr = ast.parse(callcode, mode='eval').body + + name = None + args = None + kwargs = None + + if isinstance(expr, ast.Call): + name = unroll_name(expr.func) + args = [ast.literal_eval(arg) for arg in expr.args] + kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in expr.keywords} + elif isinstance(expr, ast.Name): + name = expr.id + elif isinstance(expr, ast.Attribute): + name = unroll_name(expr) + else: + raise ValueError( + "Failed to parse %r as an attribute name or function call." % callcode + ) + # - # Build f - f = fetch(name) + # Build function + module = get_module(modulename) + f = fetch(name, module) spec = inspect.getfullargspec(f) - if argexpr: - a, kw, right_bind = parse(argexpr, allow_position_marker=True) - print('a =', a) - print('kw =', kw) - if not right_bind: - f = partial(f, *a, **kw) - else: - f = rpartial(f, *a, **kw) + if args or kwargs: + f = partial(f, *args or (), **kwargs or {}) # - args = [] - # for key in spec.args: - # if key not in vars: - # break - # args.append(vars[key]) - kwargs = {k: vars[k] for k in vars.keys() & {*spec.kwonlyargs, *spec.args}} - # ^ this collide with right_bind - result = f(*args, **kwargs) - #or f(**vars) but need to filter suitable args from spec first + result = f(**{ + key: vars[key] + for key in vars.keys() & {*spec.kwonlyargs, *spec.args} + }) if is_a_test_case: return (True, None) if operator.truth(result) else (False, expr) return result - - -def test_rmc(): - - rmc = remote_method_call - - assert rmc('!test.it', {'a': 3}) == 3 - assert rmc('!test.it.it', {'a': 3}) == 3 - assert rmc('!test.it(b=4)', {'a': 3}) == 7 - - assert rmc('!test.response.ok', {'response': Mock(status=400)}) == False - - assert rmc('!test.response.ok', {'response': Mock(status=200)}) == True - - assert rmc('!test.response.status_is(code=200)', {'response': Mock(status=200)}) == True - assert rmc('!test.response.status_is(code=200)', {'response': Mock(status=400)}) == False - - assert rmc('!test.response.status_is(200)', {'response': Mock(status=200)}) == True - assert rmc('!test.response.status_is(200)', {'response': Mock(status=400)}) == False - - # Test kw selection - assert rmc('!test.it', {'a': 3, 'c': 3}) == 3 - assert rmc('!test.it', {'a': 3, 'b': 4, 'arbre': 'kglf'}) == 7 - - # Self import actually works - assert rmc('!rmc.safe_eval("[3]")', {}) == [3] - - -def test_get_module(): - assert hasattr(get_module('test'), 'it') - assert hasattr(get_module('operator'), '__add__') - assert hasattr(get_module('pathlib'), 'Path') - - -def test_parse(): - - assert parse('3') == ((3,), {}) - assert parse('"jon"') == (('jon',), {}) - assert parse('("jon")') == (('jon',), {}) - assert parse('("jon",)') == ((('jon',),), {}) - - assert parse('3, 4') == ((3, 4), {}) - - assert parse('3, b=5') == ((3,), {'b': 5}) - assert parse('3, b=5, c=4') == ((3,), {'b': 5, 'c': 4}) - assert parse('3, b=5, 66, c.x=4') == ((3, 66), {'b': 5, 'c.x': 4}) - - assert parse('3, b=5, c.x=4') == ((3,), {'b': 5, 'c.x': 4}) - assert parse('3, b==5') == ((3,), {'b': 5}) - - try: - parse('/, 3') - except SyntaxError as e: - assert e.args[:-1] == '/' - - assert parse('3', allow_position_marker=True) == ((3,), {}, False) - assert parse('/, 3', allow_position_marker=True) == ((3,), {}, True) - - -def test_as_kw(): - assert as_kw('b=5') and as_kw('b=5').groups() == ('b', '5') - assert not as_kw('3') - - -def test_safe_eval(): - - assert safe_eval('3') == 3 - - try: - safe_eval('3+') == 3 - except SyntaxError as e: - *_, a = e.args - assert '3+' == a - - -def test_rpartial(): - - def f(a, b, c, d): - return a, b, c, d - - assert rpartial(f) == f - assert rpartial(f, 5)(2, 3, 4) == (2, 3, 4, 5) - assert rpartial(f, 4, 5)(2, 3) == (2, 3, 4, 5) - assert rpartial(f, 3, 4, 5)(2) == (2, 3, 4, 5) diff --git a/scanapi/evaluators/test.py b/scanapi/evaluators/test.py index 0452b6ef..208c957c 100644 --- a/scanapi/evaluators/test.py +++ b/scanapi/evaluators/test.py @@ -11,10 +11,10 @@ def off(a: int, b: int, status: str = 'x') -> int: class response: def ok(response): - return response.status < 400 + return response.status_code < 400 # def status_is(response, code: int): - # return response.status == code + # return response.status_code == code def status_is(code: int, response): - return response.status == code + return response.status_code == code diff --git a/scanapi/std.py b/scanapi/std.py new file mode 100644 index 00000000..b57eac1c --- /dev/null +++ b/scanapi/std.py @@ -0,0 +1,19 @@ + + +class response: + + # ${{ std:response.status_is(200) }} + def status_is(code: int, *, response) -> bool: + return response.status_code == code + + # ${{ std:response.status_in_range(200, 204) }} + def status_in(*codes: int, response) -> bool: + return response.status_code in codes + + # ${{ std:response.status_in_range(200, 299) }} + def status_in_range(start: int, end: int, *, response) -> bool: + return response.status_code in range(start, end) + + # ${{ std:response.ok }} + def ok(response) -> bool: + return response.status_code in range(200, 300) diff --git a/tests/unit/evaluators/test_rmc.py b/tests/unit/evaluators/test_rmc.py new file mode 100644 index 00000000..8a087e46 --- /dev/null +++ b/tests/unit/evaluators/test_rmc.py @@ -0,0 +1,35 @@ + +from scanapi.evaluators.rmc import remote_method_call, get_module + +from unittest.mock import Mock + + +def test_rmc(): + + rmc = remote_method_call + + # bare name + assert rmc('scanapi.std:response.ok', {'response': Mock(status_code=400)}) == False + assert rmc('scanapi.std:response.ok', {'response': Mock(status_code=200)}) == True + + # with args + assert rmc('scanapi.std:response.status_is(200)', {'response': Mock(status_code=200)}) == True + assert rmc('scanapi.std:response.status_is(200)', {'response': Mock(status_code=400)}) == False + + # with kwargs + assert rmc('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=200)}) == True + assert rmc('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=400)}) == False + + +def test_std(): + + rmc = remote_method_call + + assert rmc('std:response.ok', {'response': Mock(status_code=400)}) == False + assert rmc(':response.ok', {'response': Mock(status_code=400)}) == False + + +def test_get_module(): + assert hasattr(get_module('scanapi.std'), 'response') + assert hasattr(get_module('operator'), '__add__') + assert hasattr(get_module('pathlib'), 'Path') From c1b087d2d89d4ec0def954ed76dff1a6c630032b Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 18:52:02 +0200 Subject: [PATCH 07/21] Rename rmc.py to rmc_evaluator.py --- scanapi/evaluators/{rmc.py => rmc_evaluator.py} | 0 tests/unit/evaluators/{test_rmc.py => test_rmc_evaluator.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename scanapi/evaluators/{rmc.py => rmc_evaluator.py} (100%) rename tests/unit/evaluators/{test_rmc.py => test_rmc_evaluator.py} (100%) diff --git a/scanapi/evaluators/rmc.py b/scanapi/evaluators/rmc_evaluator.py similarity index 100% rename from scanapi/evaluators/rmc.py rename to scanapi/evaluators/rmc_evaluator.py diff --git a/tests/unit/evaluators/test_rmc.py b/tests/unit/evaluators/test_rmc_evaluator.py similarity index 100% rename from tests/unit/evaluators/test_rmc.py rename to tests/unit/evaluators/test_rmc_evaluator.py From 62dd67759146d64e313c0a1d9a3d70141e0fefae Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 18:53:17 +0200 Subject: [PATCH 08/21] Refactor rmc_evaluator after code_evaluator --- scanapi/evaluators/code_evaluator.py | 14 +- scanapi/evaluators/rmc_evaluator.py | 158 ++++++++++---------- tests/unit/evaluators/test_rmc_evaluator.py | 14 +- 3 files changed, 100 insertions(+), 86 deletions(-) diff --git a/scanapi/evaluators/code_evaluator.py b/scanapi/evaluators/code_evaluator.py index d7d9ceed..645904fe 100644 --- a/scanapi/evaluators/code_evaluator.py +++ b/scanapi/evaluators/code_evaluator.py @@ -8,7 +8,7 @@ import uuid # noqa: F401 from scanapi.errors import InvalidPythonCodeError -from . import rmc +from scanapi.evaluators.rmc_evaluator import RemoteMethodCallEvaluator logger = logging.getLogger(__name__) @@ -28,14 +28,14 @@ def evaluate(cls, sequence, vars, is_a_test_case=False): code = match.group("python_code") response = vars.get("response") - rmc_match = rmc.pattern.match(code.strip()) - if rmc_match: - try: - return rmc.remote_method_call(code, vars, is_a_test_case=is_a_test_case) - except Exception as e: - raise InvalidPythonCodeError(str(e), code) + rmc_match = RemoteMethodCallEvaluator.pattern.match(code.strip()) try: + if rmc_match: + return RemoteMethodCallEvaluator.evaluate( + code, vars, is_a_test_case + ) + if is_a_test_case: return cls._assert_code(code, response) diff --git a/scanapi/evaluators/rmc_evaluator.py b/scanapi/evaluators/rmc_evaluator.py index 02d4ae77..e5421b3e 100644 --- a/scanapi/evaluators/rmc_evaluator.py +++ b/scanapi/evaluators/rmc_evaluator.py @@ -13,10 +13,6 @@ _sentinel = object() -pattern = re.compile( - r'^(?P[\w.]*):(?P.*)$' -) - def get_module(name: str): if name.lower() == 'std': @@ -54,98 +50,108 @@ def unroll_name(name: Union[str, ast.Attribute]) -> str: return unroll_name(name.value) + '.' + name.attr -def remote_method_call( - code: str, - vars: Dict[str, Any], - is_a_test_case: bool = False -): - """ - Parse a remote method call (rmc) expression, then run it against input `vars`. +class RemoteMethodCallEvaluator: - A rmc expression starts with a ! followed by an ident, and optionally a set - of simple arguments to bind to the function: + pattern = re.compile( + r'^(?P[\w.]*):(?P.*)$' + ) - def ok(response): - return response.status_code == 200 + @classmethod + def evaluate( + cls, + code: str, + vars: Dict[str, Any], + is_a_test_case: bool = False + ): + """ + Parse a remote method call (rmc) expression, then run it against input `vars`. - def status_is(code, response): - return code == r esponse.status_code + A rmc expression starts with a ! followed by an ident, and optionally a set + of simple arguments to bind to the function: - {{ mymodule:response.ok }} - # with positional arguments - {{ mymodule:response.status_is(200) }} - # or keyword arguments - {{ mymodule:response.status_is(code=200) }} + def ok(response): + return response.status_code == 200 - Beware that partial binding binds from the left on positional arguments, so - you should expect your positional arguments to be fed through the expression - rather than from `vars`, ie don't write this but the above: + def status_is(code, response): + return code == r esponse.status_code - def status_is(response, code): # response would be 200 here and a collision would happen - ... + {{ mymodule:response.ok }} + # with positional arguments + {{ mymodule:response.status_is(200) }} + # or keyword arguments + {{ mymodule:response.status_is(code=200) }} - --- + Beware that partial binding binds from the left on positional arguments, so + you should expect your positional arguments to be fed through the expression + rather than from `vars`, ie don't write this but the above: - `vars` are fed to the function as keyword arguments; only `vars` keys found in - the function spec are fed to the function, so you can write stuff like this: + def status_is(response, code): # response would be 200 here and a collision would happen + ... - def analyze_response(response): - ... + --- - with vars = {'response': ... , 'book_id': 333} - ${{ !mymodule.analyze_response }} + `vars` are fed to the function as keyword arguments; only `vars` keys found in + the function spec are fed to the function, so you can write stuff like this: - to just get the vars you're interested in. + def analyze_response(response): + ... - """ + with vars = {'response': ... , 'book_id': 333} + ${{ !mymodule.analyze_response }} - code = str(code) + to just get the vars you're interested in. - # Parse expr - m = pattern.match(code) - if m is None: - raise ValueError( - "Failed to parse expr: %r" % code - ) + """ - modulename, callcode = m.groups() - modulename = modulename or 'std' + code = str(code) - expr = ast.parse(callcode, mode='eval').body + # Parse expr + m = cls.pattern.match(code) + if m is None: + raise ValueError( + "Failed to parse expr: %r" % code + ) - name = None - args = None - kwargs = None + modulename, callcode = m.groups() + modulename = modulename or 'std' - if isinstance(expr, ast.Call): - name = unroll_name(expr.func) - args = [ast.literal_eval(arg) for arg in expr.args] - kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in expr.keywords} - elif isinstance(expr, ast.Name): - name = expr.id - elif isinstance(expr, ast.Attribute): - name = unroll_name(expr) - else: - raise ValueError( - "Failed to parse %r as an attribute name or function call." % callcode - ) - # + expr = ast.parse(callcode, mode='eval').body + + name = None + args = None + kwargs = None - # Build function - module = get_module(modulename) - f = fetch(name, module) - spec = inspect.getfullargspec(f) + if isinstance(expr, ast.Call): + name = unroll_name(expr.func) + args = [ast.literal_eval(arg) for arg in expr.args] + kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in expr.keywords} + elif isinstance(expr, ast.Name): + name = expr.id + elif isinstance(expr, ast.Attribute): + name = unroll_name(expr) + else: + raise ValueError( + "Failed to parse %r as an attribute name or function call." % callcode + ) + # - if args or kwargs: - f = partial(f, *args or (), **kwargs or {}) - # + # Build function + module = get_module(modulename) + f = fetch(name, module) + spec = inspect.getfullargspec(f) - result = f(**{ - key: vars[key] - for key in vars.keys() & {*spec.kwonlyargs, *spec.args} - }) + if args or kwargs: + f = partial(f, *args or (), **kwargs or {}) + # - if is_a_test_case: - return (True, None) if operator.truth(result) else (False, expr) + result = f(**{ + key: vars[key] + for key in vars.keys() & {*spec.kwonlyargs, *spec.args} + }) - return result + if is_a_test_case: + if operator.truth(result): + return (True, None) + return (False, expr) + + return result diff --git a/tests/unit/evaluators/test_rmc_evaluator.py b/tests/unit/evaluators/test_rmc_evaluator.py index 8a087e46..a41dd88e 100644 --- a/tests/unit/evaluators/test_rmc_evaluator.py +++ b/tests/unit/evaluators/test_rmc_evaluator.py @@ -1,12 +1,20 @@ +import ast -from scanapi.evaluators.rmc import remote_method_call, get_module +from scanapi.evaluators.rmc_evaluator import RemoteMethodCallEvaluator, get_module, unroll_name from unittest.mock import Mock +def test_unroll_name(): + + assert unroll_name(ast.parse('a', mode='eval').body) == 'a' + assert unroll_name(ast.parse('a.c', mode='eval').body) == 'a.c' + assert unroll_name(ast.parse('a.b.c', mode='eval').body) == 'a.b.c' + + def test_rmc(): - rmc = remote_method_call + rmc = RemoteMethodCallEvaluator.evaluate # bare name assert rmc('scanapi.std:response.ok', {'response': Mock(status_code=400)}) == False @@ -23,7 +31,7 @@ def test_rmc(): def test_std(): - rmc = remote_method_call + rmc = RemoteMethodCallEvaluator.evaluate assert rmc('std:response.ok', {'response': Mock(status_code=400)}) == False assert rmc(':response.ok', {'response': Mock(status_code=400)}) == False From 5898b38e2b1a871dd2acb40e8af94f50a0fe62f6 Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 19:08:13 +0200 Subject: [PATCH 09/21] Refactor rmc + Add comments + Allow spaces in expression --- scanapi/evaluators/code_evaluator.py | 2 +- scanapi/evaluators/rmc_evaluator.py | 46 ++++++++++++---------------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/scanapi/evaluators/code_evaluator.py b/scanapi/evaluators/code_evaluator.py index 645904fe..81a5c684 100644 --- a/scanapi/evaluators/code_evaluator.py +++ b/scanapi/evaluators/code_evaluator.py @@ -33,7 +33,7 @@ def evaluate(cls, sequence, vars, is_a_test_case=False): try: if rmc_match: return RemoteMethodCallEvaluator.evaluate( - code, vars, is_a_test_case + code, vars, is_a_test_case, rmc_match ) if is_a_test_case: diff --git a/scanapi/evaluators/rmc_evaluator.py b/scanapi/evaluators/rmc_evaluator.py index e5421b3e..cdd05a92 100644 --- a/scanapi/evaluators/rmc_evaluator.py +++ b/scanapi/evaluators/rmc_evaluator.py @@ -1,13 +1,10 @@ -import os -import sys import ast import re import operator import inspect import importlib from functools import partial -from unittest.mock import Mock -from typing import Dict, Any, List, Tuple, Optional, Callable, Union +from typing import Dict, Any, Optional, Union from scanapi import std @@ -15,6 +12,7 @@ def get_module(name: str): + """Import a module dynamically.""" if name.lower() == 'std': return std module = importlib.import_module(name) @@ -22,27 +20,20 @@ def get_module(name: str): return module -def fetch(location: str, module = None) -> Callable: - """Fetch a callable from a given location, wich can span across submodules/attributes.""" - - if module is None: - module_name, *trail = location.split('.') - if not trail: - raise Exception - module = get_module(module_name) - else: - trail = location.split('.') - - node = module +def getname(location: str, root) -> Any: + """Get an ident's value from a given namespace.""" + trail = location.split('.') + node = root for i, name in enumerate(trail): try: node = getattr(node, name) except AttributeError: - raise AttributeError(f'No such location: {module.__name__}.{".".join(trail[:i + 1])}') + raise AttributeError(f'No such location: {root}.{".".join(trail[:i + 1])}') return node -def unroll_name(name: Union[str, ast.Attribute]) -> str: +def unroll_name(name: Union[str, ast.Attribute, ast.Name]) -> str: + """Unroll a ast.Name / ast.Attribute / str object.""" if isinstance(name, str): return name if isinstance(name, ast.Name): @@ -53,7 +44,7 @@ def unroll_name(name: Union[str, ast.Attribute]) -> str: class RemoteMethodCallEvaluator: pattern = re.compile( - r'^(?P[\w.]*):(?P.*)$' + r'^(?P[\w.]*)\s*:\s*(?P.*)$' ) @classmethod @@ -61,7 +52,8 @@ def evaluate( cls, code: str, vars: Dict[str, Any], - is_a_test_case: bool = False + is_a_test_case: bool = False, + match: Optional[re.Match] = None ): """ Parse a remote method call (rmc) expression, then run it against input `vars`. @@ -106,13 +98,13 @@ def analyze_response(response): code = str(code) # Parse expr - m = cls.pattern.match(code) - if m is None: + match = match or cls.pattern.match(code) + if match is None: raise ValueError( "Failed to parse expr: %r" % code ) - modulename, callcode = m.groups() + modulename, callcode = match.groups() modulename = modulename or 'std' expr = ast.parse(callcode, mode='eval').body @@ -137,14 +129,14 @@ def analyze_response(response): # Build function module = get_module(modulename) - f = fetch(name, module) - spec = inspect.getfullargspec(f) + func = getname(name, module) + spec = inspect.getfullargspec(func) if args or kwargs: - f = partial(f, *args or (), **kwargs or {}) + func = partial(func, *args or (), **kwargs or {}) # - result = f(**{ + result = func(**{ key: vars[key] for key in vars.keys() & {*spec.kwonlyargs, *spec.args} }) From 7b35370b672773b92115b1201fd9494709ba53bf Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 19:19:48 +0200 Subject: [PATCH 10/21] Update rmc docstring --- scanapi/evaluators/rmc_evaluator.py | 30 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/scanapi/evaluators/rmc_evaluator.py b/scanapi/evaluators/rmc_evaluator.py index cdd05a92..a64c623e 100644 --- a/scanapi/evaluators/rmc_evaluator.py +++ b/scanapi/evaluators/rmc_evaluator.py @@ -58,14 +58,21 @@ def evaluate( """ Parse a remote method call (rmc) expression, then run it against input `vars`. - A rmc expression starts with a ! followed by an ident, and optionally a set - of simple arguments to bind to the function: + A rmc expression is made of two fields separated by a colon (:): + * a module to import the function from: (eg 'pandas') + * the name location of the function (eg 'is_nan') - def ok(response): - return response.status_code == 200 + mymodule.py: - def status_is(code, response): - return code == r esponse.status_code + def ok(response): + return response.status_code == 200 + + def status_is(code, response): + return code == r esponse.status_code + + We can call this module's functions with rmc expressions. + You can omit any and all arguments, but if you do provide some, + they will be bound to the function before it gets to run. {{ mymodule:response.ok }} # with positional arguments @@ -77,13 +84,13 @@ def status_is(code, response): you should expect your positional arguments to be fed through the expression rather than from `vars`, ie don't write this but the above: + ${{ mymodule:response.status_is(200) }} + def status_is(response, code): # response would be 200 here and a collision would happen ... - --- - - `vars` are fed to the function as keyword arguments; only `vars` keys found in - the function spec are fed to the function, so you can write stuff like this: + `vars` is fed to the function as keyword arguments; only `vars` keys found in + the function's spec are fed to the function, so you can write stuff like this: def analyze_response(response): ... @@ -91,8 +98,7 @@ def analyze_response(response): with vars = {'response': ... , 'book_id': 333} ${{ !mymodule.analyze_response }} - to just get the vars you're interested in. - + to just be able to process the vars you're interested in. """ code = str(code) From ce43763aaba4df407aa99c64faa2b8099c55a661 Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 20:20:50 +0200 Subject: [PATCH 11/21] Fix rmc for builtins + Add missing SpecEvaluator.keys() --- scanapi/evaluators/rmc_evaluator.py | 19 +++++++++++-------- scanapi/evaluators/spec_evaluator.py | 3 +++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/scanapi/evaluators/rmc_evaluator.py b/scanapi/evaluators/rmc_evaluator.py index a64c623e..d6398084 100644 --- a/scanapi/evaluators/rmc_evaluator.py +++ b/scanapi/evaluators/rmc_evaluator.py @@ -136,20 +136,23 @@ def analyze_response(response): # Build function module = get_module(modulename) func = getname(name, module) - spec = inspect.getfullargspec(func) + bound_func = func if args or kwargs: - func = partial(func, *args or (), **kwargs or {}) - # + bound_func = partial(bound_func, *args or (), **kwargs or {}) - result = func(**{ - key: vars[key] - for key in vars.keys() & {*spec.kwonlyargs, *spec.args} - }) + if getattr(func, '__module__', None) == 'builtins': + result = bound_func(vars) + else: + spec = inspect.getfullargspec(func) + result = bound_func(**{ + key: vars[key] + for key in vars.keys() & {*spec.kwonlyargs, *spec.args} + }) if is_a_test_case: if operator.truth(result): return (True, None) - return (False, expr) + return (False, code) return result diff --git a/scanapi/evaluators/spec_evaluator.py b/scanapi/evaluators/spec_evaluator.py index 22a0f9b6..e84b0d3b 100644 --- a/scanapi/evaluators/spec_evaluator.py +++ b/scanapi/evaluators/spec_evaluator.py @@ -39,6 +39,9 @@ def get(self, key, default=None): def __repr__(self): return self.registry.__repr__() + def keys(self): + return self.registry.keys() + def __getitem__(self, key): if key in self: return self.registry[key] From 6e7167a9b0e52e4cc2d49f2fee03e8a183257b0a Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 21:20:43 +0200 Subject: [PATCH 12/21] Add BDD tests for rmc_evaluator --- scanapi/evaluators/rmc_evaluator.py | 62 +++++++-- tests/unit/evaluators/test_rmc_evaluator.py | 135 ++++++++++++++++---- 2 files changed, 158 insertions(+), 39 deletions(-) diff --git a/scanapi/evaluators/rmc_evaluator.py b/scanapi/evaluators/rmc_evaluator.py index d6398084..24da3dc7 100644 --- a/scanapi/evaluators/rmc_evaluator.py +++ b/scanapi/evaluators/rmc_evaluator.py @@ -4,7 +4,7 @@ import inspect import importlib from functools import partial -from typing import Dict, Any, Optional, Union +from typing import Dict, Any, Optional, Union, Callable, Dict, Tuple, Mapping from scanapi import std @@ -41,9 +41,54 @@ def unroll_name(name: Union[str, ast.Attribute, ast.Name]) -> str: return unroll_name(name.value) + '.' + name.attr +def call_against_vars(func: Callable, args: Tuple, kwargs: Dict, vars: Mapping): + """ + Call a function against the spec vars + --- + Bind func to args/kwargs; + If function has no spec, feed vars as a single positional argument. + Feed `vars` as keyword arguments to func if func's spec share some of the same + names, including a reference to `vars` itself. + If `vars` and spec share no keys whatsoever, raise exception. + """ + + bound_func = func + + if args or kwargs: + bound_func = partial(bound_func, *args or (), **kwargs or {}) + + if getattr(func, '__module__', None) == 'builtins': + return bound_func(vars) + + try: + spec = inspect.getfullargspec(func) + except TypeError: + return bound_func(vars) + + try: + vars = dict(vars) + except TypeError: + raise TypeError(f'vars={vars} is not dict-like') + + if 'vars' not in vars: + vars['vars'] = vars + + feed_keys = {*spec.kwonlyargs, *spec.args} + + if not feed_keys & vars.keys(): + raise RuntimeError( + f'vars={vars.keys()} contain no key that function {func} expects as parameter' + ) + + return bound_func(**{ + key: vars[key] + for key in vars.keys() & feed_keys + }) + + class RemoteMethodCallEvaluator: - pattern = re.compile( + pattern: re.Pattern = re.compile( r'^(?P[\w.]*)\s*:\s*(?P.*)$' ) @@ -136,19 +181,8 @@ def analyze_response(response): # Build function module = get_module(modulename) func = getname(name, module) - bound_func = func - - if args or kwargs: - bound_func = partial(bound_func, *args or (), **kwargs or {}) - if getattr(func, '__module__', None) == 'builtins': - result = bound_func(vars) - else: - spec = inspect.getfullargspec(func) - result = bound_func(**{ - key: vars[key] - for key in vars.keys() & {*spec.kwonlyargs, *spec.args} - }) + result = call_against_vars(func, args, kwargs, vars) if is_a_test_case: if operator.truth(result): diff --git a/tests/unit/evaluators/test_rmc_evaluator.py b/tests/unit/evaluators/test_rmc_evaluator.py index a41dd88e..0c1a2d4d 100644 --- a/tests/unit/evaluators/test_rmc_evaluator.py +++ b/tests/unit/evaluators/test_rmc_evaluator.py @@ -1,43 +1,128 @@ import ast +from unittest.mock import Mock -from scanapi.evaluators.rmc_evaluator import RemoteMethodCallEvaluator, get_module, unroll_name +import pytest +import scanapi +from scanapi.evaluators import rmc_evaluator as rmc + + +rmc_eval = rmc.RemoteMethodCallEvaluator.evaluate + + +class TestRemoteMethodCallEvaluator: + class TestGetName: + def test_expected_behavior(self): + assert rmc.getname('evaluators.rmc_evaluator', scanapi) == rmc + import functools + assert rmc.getname('partial.__module__', functools) == functools.partial.__module__ + + def test_invalid_name(self): + with pytest.raises(AttributeError) as excinfo: + rmc.getname('evaluators.rmc_evaltor', scanapi) == rmc + assert str(excinfo.value) == ( + f'No such location: {scanapi}.evaluators.rmc_evaltor' + ) -from unittest.mock import Mock + class TestUnrollName: + def test_expected_behavior(self): + assert rmc.unroll_name(ast.parse('a', mode='eval').body) == 'a' + assert rmc.unroll_name(ast.parse('a.c', mode='eval').body) == 'a.c' + assert rmc.unroll_name(ast.parse('a.b.c', mode='eval').body) == 'a.b.c' + + class TestEvaluate: + class TestInvalidCode: + def test_expected_behavior(self): + with pytest.raises(ValueError) as excinfo: + rmc_eval('a:b + c', {}) + assert str(excinfo.value) == ( + 'Failed to parse \'b + c\' as an attribute name or function call.' + ) + + class TestBareName: + def test_expected_behavior(self): + # local path module + assert rmc_eval('scanapi.std:response.ok', {'response': Mock(status_code=400)}) == False + assert rmc_eval('scanapi.std:response.ok', {'response': Mock(status_code=200)}) == True + # no spec + assert rmc_eval('builtins:str', {'object': {4}}) == "{'object': {4}}" -def test_unroll_name(): + # spec but doesn't take keyword arguments + assert rmc_eval('builtins:list', {'iterable': (4, 5)}) == ['iterable'] - assert unroll_name(ast.parse('a', mode='eval').body) == 'a' - assert unroll_name(ast.parse('a.c', mode='eval').body) == 'a.c' - assert unroll_name(ast.parse('a.b.c', mode='eval').body) == 'a.b.c' + class TestPositionalArgs: + def test_expected_behavior(self): + assert rmc_eval('scanapi.std:response.status_is(200)', {'response': Mock(status_code=200)}) == True + assert rmc_eval('scanapi.std:response.status_is(200)', {'response': Mock(status_code=400)}) == False + class TestKeywordArgs: + def test_expected_behavior(self): + assert rmc_eval('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=200)}) == True + assert rmc_eval('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=400)}) == False -def test_rmc(): + class TestStdConst: + def test_expected_behavior(self): + assert rmc_eval('std:response.ok', {'response': Mock(status_code=400)}) == False + assert rmc_eval(':response.ok', {'response': Mock(status_code=400)}) == False - rmc = RemoteMethodCallEvaluator.evaluate + class TestGetModule: + def test_expected_behavior(self): + assert hasattr(rmc.get_module('scanapi.std'), 'response') + assert hasattr(rmc.get_module('operator'), '__add__') + assert hasattr(rmc.get_module('pathlib'), 'Path') - # bare name - assert rmc('scanapi.std:response.ok', {'response': Mock(status_code=400)}) == False - assert rmc('scanapi.std:response.ok', {'response': Mock(status_code=200)}) == True + class TestCallAgainstVars: - # with args - assert rmc('scanapi.std:response.status_is(200)', {'response': Mock(status_code=200)}) == True - assert rmc('scanapi.std:response.status_is(200)', {'response': Mock(status_code=400)}) == False + def test_expected_behavior(self): - # with kwargs - assert rmc('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=200)}) == True - assert rmc('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=400)}) == False + def f(a: int, b: int) -> int: + return a + b + assert rmc.call_against_vars(f, (), {}, {'a': 3, 'b': 4}) == 7 + assert rmc.call_against_vars(f, (), {'b': 4}, {'a': 3}) == 7 + assert rmc.call_against_vars(f, (3,), {}, {'b': 4}) == 7 -def test_std(): + def test_empty_vars(self): + def f(a: int, b: int) -> int: + return a + b + # vars should never be empty, so this fails + vars = {} + vars['vars'] = vars + with pytest.raises(RuntimeError) as excinfo: + assert rmc.call_against_vars(f, (3,), {'b': 4}, vars) == 7 - rmc = RemoteMethodCallEvaluator.evaluate + assert str(excinfo.value) == ( + f'vars={vars.keys()} contain no key that function {f} expects as parameter' + ) - assert rmc('std:response.ok', {'response': Mock(status_code=400)}) == False - assert rmc(':response.ok', {'response': Mock(status_code=400)}) == False + def test_not_dict_like(self): + with pytest.raises(TypeError) as excinfo: + assert rmc.call_against_vars(lambda m:m, (), {}, 44) + assert str(excinfo.value) == 'vars=44 is not dict-like' + def test_builtins(self): + vars = {'a': 3} + assert rmc.call_against_vars(str, (), {}, vars) == str(vars) + assert rmc.call_against_vars(list, (), {}, vars) == list(vars) -def test_get_module(): - assert hasattr(get_module('scanapi.std'), 'response') - assert hasattr(get_module('operator'), '__add__') - assert hasattr(get_module('pathlib'), 'Path') + def test_only_vars(self): + + def f(vars) -> int: + return vars['a'] + vars['b'] + + assert rmc.call_against_vars(f, (), {}, {'a': 3, 'b': 4}) == 7 + + def test_no_common_names(self): + + def f(args) -> int: + return args['a'] + args['b'] + + vars = {'a': 3, 'b': 4} + vars['vars'] = vars + + with pytest.raises(RuntimeError) as excinfo: + rmc.call_against_vars(f, (), {}, vars) == 7 + + assert str(excinfo.value) == ( + f'vars={vars.keys()} contain no key that function {f} expects as parameter' + ) From 81e8f051c34224d5cabefae0289036d5bbe018b4 Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 21:26:59 +0200 Subject: [PATCH 13/21] Refactor test_rmc_evaluator more closely to BDD style --- tests/unit/evaluators/test_rmc_evaluator.py | 99 +++++++++++---------- 1 file changed, 50 insertions(+), 49 deletions(-) diff --git a/tests/unit/evaluators/test_rmc_evaluator.py b/tests/unit/evaluators/test_rmc_evaluator.py index 0c1a2d4d..488c307f 100644 --- a/tests/unit/evaluators/test_rmc_evaluator.py +++ b/tests/unit/evaluators/test_rmc_evaluator.py @@ -16,12 +16,13 @@ def test_expected_behavior(self): import functools assert rmc.getname('partial.__module__', functools) == functools.partial.__module__ - def test_invalid_name(self): - with pytest.raises(AttributeError) as excinfo: - rmc.getname('evaluators.rmc_evaltor', scanapi) == rmc - assert str(excinfo.value) == ( - f'No such location: {scanapi}.evaluators.rmc_evaltor' - ) + class TestWhenNameDoesntExist: + def test_should_raise_attribute_error(self): + with pytest.raises(AttributeError) as excinfo: + rmc.getname('evaluators.rmc_evaltor', scanapi) == rmc + assert str(excinfo.value) == ( + f'No such location: {scanapi}.evaluators.rmc_evaltor' + ) class TestUnrollName: def test_expected_behavior(self): @@ -30,7 +31,7 @@ def test_expected_behavior(self): assert rmc.unroll_name(ast.parse('a.b.c', mode='eval').body) == 'a.b.c' class TestEvaluate: - class TestInvalidCode: + class TestWhenCodeRightMemberIsInvalid: def test_expected_behavior(self): with pytest.raises(ValueError) as excinfo: rmc_eval('a:b + c', {}) @@ -38,7 +39,7 @@ def test_expected_behavior(self): 'Failed to parse \'b + c\' as an attribute name or function call.' ) - class TestBareName: + class TestOnlyBareName: def test_expected_behavior(self): # local path module assert rmc_eval('scanapi.std:response.ok', {'response': Mock(status_code=400)}) == False @@ -50,17 +51,17 @@ def test_expected_behavior(self): # spec but doesn't take keyword arguments assert rmc_eval('builtins:list', {'iterable': (4, 5)}) == ['iterable'] - class TestPositionalArgs: + class TestExprWithPositionalArgs: def test_expected_behavior(self): assert rmc_eval('scanapi.std:response.status_is(200)', {'response': Mock(status_code=200)}) == True assert rmc_eval('scanapi.std:response.status_is(200)', {'response': Mock(status_code=400)}) == False - class TestKeywordArgs: + class TestExprWithKeywordArgs: def test_expected_behavior(self): assert rmc_eval('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=200)}) == True assert rmc_eval('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=400)}) == False - class TestStdConst: + class TestExprWithStdConst: def test_expected_behavior(self): assert rmc_eval('std:response.ok', {'response': Mock(status_code=400)}) == False assert rmc_eval(':response.ok', {'response': Mock(status_code=400)}) == False @@ -72,57 +73,57 @@ def test_expected_behavior(self): assert hasattr(rmc.get_module('pathlib'), 'Path') class TestCallAgainstVars: - def test_expected_behavior(self): - def f(a: int, b: int) -> int: return a + b - assert rmc.call_against_vars(f, (), {}, {'a': 3, 'b': 4}) == 7 assert rmc.call_against_vars(f, (), {'b': 4}, {'a': 3}) == 7 assert rmc.call_against_vars(f, (3,), {}, {'b': 4}) == 7 - def test_empty_vars(self): - def f(a: int, b: int) -> int: - return a + b - # vars should never be empty, so this fails - vars = {} - vars['vars'] = vars - with pytest.raises(RuntimeError) as excinfo: - assert rmc.call_against_vars(f, (3,), {'b': 4}, vars) == 7 - - assert str(excinfo.value) == ( - f'vars={vars.keys()} contain no key that function {f} expects as parameter' - ) + class TestWhenVarsIsEmpty: + def test_should_raise_runtime_error(self): + def f(a: int, b: int) -> int: + return a + b + # vars should never be empty, so this fails + vars = {} + vars['vars'] = vars + with pytest.raises(RuntimeError) as excinfo: + assert rmc.call_against_vars(f, (3,), {'b': 4}, vars) == 7 - def test_not_dict_like(self): - with pytest.raises(TypeError) as excinfo: - assert rmc.call_against_vars(lambda m:m, (), {}, 44) - assert str(excinfo.value) == 'vars=44 is not dict-like' + assert str(excinfo.value) == ( + f'vars={vars.keys()} contain no key that function {f} expects as parameter' + ) - def test_builtins(self): - vars = {'a': 3} - assert rmc.call_against_vars(str, (), {}, vars) == str(vars) - assert rmc.call_against_vars(list, (), {}, vars) == list(vars) + class TestWhenVarsIsNotDictLike: + def test_should_raise_type_error(self): + with pytest.raises(TypeError) as excinfo: + assert rmc.call_against_vars(lambda m:m, (), {}, 44) + assert str(excinfo.value) == 'vars=44 is not dict-like' - def test_only_vars(self): + class TestWhenFuncIsBuiltin: + def test_should_use_vars_as_pos_arg(self): + vars = {'a': 3} + assert rmc.call_against_vars(str, (), {}, vars) == str(vars) + assert rmc.call_against_vars(list, (), {}, vars) == list(vars) - def f(vars) -> int: - return vars['a'] + vars['b'] + class TestWhenVarsIsPartOfSpec: + def test_should_feed_vars_as_keyword_arg(self): + def f(vars) -> int: + return vars['a'] + vars['b'] + assert rmc.call_against_vars(f, (), {}, {'a': 3, 'b': 4}) == 7 - assert rmc.call_against_vars(f, (), {}, {'a': 3, 'b': 4}) == 7 + class TestWhenSpecAndVarsShareNoCommonKeys: + def test_should_raise_runtime_error(self): - def test_no_common_names(self): + def f(args) -> int: + return args['a'] + args['b'] - def f(args) -> int: - return args['a'] + args['b'] + vars = {'a': 3, 'b': 4} + vars['vars'] = vars - vars = {'a': 3, 'b': 4} - vars['vars'] = vars + with pytest.raises(RuntimeError) as excinfo: + rmc.call_against_vars(f, (), {}, vars) == 7 - with pytest.raises(RuntimeError) as excinfo: - rmc.call_against_vars(f, (), {}, vars) == 7 - - assert str(excinfo.value) == ( - f'vars={vars.keys()} contain no key that function {f} expects as parameter' - ) + assert str(excinfo.value) == ( + f'vars={vars.keys()} contain no key that function {f} expects as parameter' + ) From 7f29df2276b7ce28c6cc545fc420f6d81c15385c Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 21:28:54 +0200 Subject: [PATCH 14/21] Delete old file --- scanapi/evaluators/test.py | 20 -------------------- scanapi/std.py | 5 +++++ 2 files changed, 5 insertions(+), 20 deletions(-) delete mode 100644 scanapi/evaluators/test.py diff --git a/scanapi/evaluators/test.py b/scanapi/evaluators/test.py deleted file mode 100644 index 208c957c..00000000 --- a/scanapi/evaluators/test.py +++ /dev/null @@ -1,20 +0,0 @@ -def it(a: int, b: int = 0) -> int: - return a + b - -it.it = it - - -def off(a: int, b: int, status: str = 'x') -> int: - return str(a) + '+' + str(b) + '-' + status - - -class response: - - def ok(response): - return response.status_code < 400 - - # def status_is(response, code: int): - # return response.status_code == code - - def status_is(code: int, response): - return response.status_code == code diff --git a/scanapi/std.py b/scanapi/std.py index b57eac1c..c80757fd 100644 --- a/scanapi/std.py +++ b/scanapi/std.py @@ -1,5 +1,10 @@ +""" +A compendium of off-the-shelf operations for rmc expressions. +""" + + class response: # ${{ std:response.status_is(200) }} From aaf240339ef392aa8e2a3367bf698bd846635712 Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 21:37:17 +0200 Subject: [PATCH 15/21] Update CHANGELOG --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8b462ac..f8031c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Add `--no-report` flag. [#299](https://github.com/scanapi/scanapi/pull/299) +- Added flake8 check workflow on pull_request event [#321](https://github.com/scanapi/scanapi/pull/321) +- Allow ${{}} exprs to call functions from external python modules [PR]() ### Changed - Updated poetry-publish version to v1.3 [#311](https://github.com/scanapi/scanapi/pull/311) From 81f30820f1a0af86f58ac0ed61336bea7afdae4e Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 21:39:58 +0200 Subject: [PATCH 16/21] Update CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8031c9e..9f883073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Added flake8 check workflow on pull_request event [#321](https://github.com/scanapi/scanapi/pull/321) -- Allow ${{}} exprs to call functions from external python modules [PR]() +- Allow ${{}} exprs to call methods from external python modules [PR]() ### Changed - Updated poetry-publish version to v1.3 [#311](https://github.com/scanapi/scanapi/pull/311) From feeb4eab3ba325ed050d9f0a29c74f79c98c4ab1 Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 22:46:26 +0200 Subject: [PATCH 17/21] Fix flake8 errors --- scanapi/evaluators/rmc_evaluator.py | 4 ++-- tests/unit/evaluators/test_rmc_evaluator.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/scanapi/evaluators/rmc_evaluator.py b/scanapi/evaluators/rmc_evaluator.py index 24da3dc7..b5a83b52 100644 --- a/scanapi/evaluators/rmc_evaluator.py +++ b/scanapi/evaluators/rmc_evaluator.py @@ -4,7 +4,7 @@ import inspect import importlib from functools import partial -from typing import Dict, Any, Optional, Union, Callable, Dict, Tuple, Mapping +from typing import Dict, Any, Optional, Union, Callable, Tuple, Mapping from scanapi import std @@ -88,7 +88,7 @@ def call_against_vars(func: Callable, args: Tuple, kwargs: Dict, vars: Mapping): class RemoteMethodCallEvaluator: - pattern: re.Pattern = re.compile( + pattern = re.compile( r'^(?P[\w.]*)\s*:\s*(?P.*)$' ) diff --git a/tests/unit/evaluators/test_rmc_evaluator.py b/tests/unit/evaluators/test_rmc_evaluator.py index 488c307f..e55900a0 100644 --- a/tests/unit/evaluators/test_rmc_evaluator.py +++ b/tests/unit/evaluators/test_rmc_evaluator.py @@ -42,8 +42,8 @@ def test_expected_behavior(self): class TestOnlyBareName: def test_expected_behavior(self): # local path module - assert rmc_eval('scanapi.std:response.ok', {'response': Mock(status_code=400)}) == False - assert rmc_eval('scanapi.std:response.ok', {'response': Mock(status_code=200)}) == True + assert not rmc_eval('scanapi.std:response.ok', {'response': Mock(status_code=400)}) + assert rmc_eval('scanapi.std:response.ok', {'response': Mock(status_code=200)}) # no spec assert rmc_eval('builtins:str', {'object': {4}}) == "{'object': {4}}" @@ -53,18 +53,18 @@ def test_expected_behavior(self): class TestExprWithPositionalArgs: def test_expected_behavior(self): - assert rmc_eval('scanapi.std:response.status_is(200)', {'response': Mock(status_code=200)}) == True - assert rmc_eval('scanapi.std:response.status_is(200)', {'response': Mock(status_code=400)}) == False + assert rmc_eval('scanapi.std:response.status_is(200)', {'response': Mock(status_code=200)}) + assert not rmc_eval('scanapi.std:response.status_is(200)', {'response': Mock(status_code=400)}) class TestExprWithKeywordArgs: def test_expected_behavior(self): - assert rmc_eval('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=200)}) == True - assert rmc_eval('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=400)}) == False + assert rmc_eval('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=200)}) + assert not rmc_eval('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=400)}) class TestExprWithStdConst: def test_expected_behavior(self): - assert rmc_eval('std:response.ok', {'response': Mock(status_code=400)}) == False - assert rmc_eval(':response.ok', {'response': Mock(status_code=400)}) == False + assert not rmc_eval('std:response.ok', {'response': Mock(status_code=400)}) + assert not rmc_eval(':response.ok', {'response': Mock(status_code=400)}) class TestGetModule: def test_expected_behavior(self): @@ -97,7 +97,7 @@ def f(a: int, b: int) -> int: class TestWhenVarsIsNotDictLike: def test_should_raise_type_error(self): with pytest.raises(TypeError) as excinfo: - assert rmc.call_against_vars(lambda m:m, (), {}, 44) + assert rmc.call_against_vars(lambda m: m, (), {}, 44) assert str(excinfo.value) == 'vars=44 is not dict-like' class TestWhenFuncIsBuiltin: From 3975d064eb6b1361951cd4883776677810c8989b Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 22:52:01 +0200 Subject: [PATCH 18/21] Lint with black --- scanapi/evaluators/rmc_evaluator.py | 37 ++++---- scanapi/std.py | 2 - tests/unit/evaluators/test_rmc_evaluator.py | 98 +++++++++++++-------- 3 files changed, 76 insertions(+), 61 deletions(-) diff --git a/scanapi/evaluators/rmc_evaluator.py b/scanapi/evaluators/rmc_evaluator.py index b5a83b52..a98d59c7 100644 --- a/scanapi/evaluators/rmc_evaluator.py +++ b/scanapi/evaluators/rmc_evaluator.py @@ -13,16 +13,16 @@ def get_module(name: str): """Import a module dynamically.""" - if name.lower() == 'std': + if name.lower() == "std": return std module = importlib.import_module(name) - print(f'Loaded {module}') + print(f"Loaded {module}") return module def getname(location: str, root) -> Any: """Get an ident's value from a given namespace.""" - trail = location.split('.') + trail = location.split(".") node = root for i, name in enumerate(trail): try: @@ -38,7 +38,7 @@ def unroll_name(name: Union[str, ast.Attribute, ast.Name]) -> str: return name if isinstance(name, ast.Name): return name.id - return unroll_name(name.value) + '.' + name.attr + return unroll_name(name.value) + "." + name.attr def call_against_vars(func: Callable, args: Tuple, kwargs: Dict, vars: Mapping): @@ -57,7 +57,7 @@ def call_against_vars(func: Callable, args: Tuple, kwargs: Dict, vars: Mapping): if args or kwargs: bound_func = partial(bound_func, *args or (), **kwargs or {}) - if getattr(func, '__module__', None) == 'builtins': + if getattr(func, "__module__", None) == "builtins": return bound_func(vars) try: @@ -68,29 +68,24 @@ def call_against_vars(func: Callable, args: Tuple, kwargs: Dict, vars: Mapping): try: vars = dict(vars) except TypeError: - raise TypeError(f'vars={vars} is not dict-like') + raise TypeError(f"vars={vars} is not dict-like") - if 'vars' not in vars: - vars['vars'] = vars + if "vars" not in vars: + vars["vars"] = vars feed_keys = {*spec.kwonlyargs, *spec.args} if not feed_keys & vars.keys(): raise RuntimeError( - f'vars={vars.keys()} contain no key that function {func} expects as parameter' + f"vars={vars.keys()} contain no key that function {func} expects as parameter" ) - return bound_func(**{ - key: vars[key] - for key in vars.keys() & feed_keys - }) + return bound_func(**{key: vars[key] for key in vars.keys() & feed_keys}) class RemoteMethodCallEvaluator: - pattern = re.compile( - r'^(?P[\w.]*)\s*:\s*(?P.*)$' - ) + pattern = re.compile(r"^(?P[\w.]*)\s*:\s*(?P.*)$") @classmethod def evaluate( @@ -98,7 +93,7 @@ def evaluate( code: str, vars: Dict[str, Any], is_a_test_case: bool = False, - match: Optional[re.Match] = None + match: Optional[re.Match] = None, ): """ Parse a remote method call (rmc) expression, then run it against input `vars`. @@ -151,14 +146,12 @@ def analyze_response(response): # Parse expr match = match or cls.pattern.match(code) if match is None: - raise ValueError( - "Failed to parse expr: %r" % code - ) + raise ValueError("Failed to parse expr: %r" % code) modulename, callcode = match.groups() - modulename = modulename or 'std' + modulename = modulename or "std" - expr = ast.parse(callcode, mode='eval').body + expr = ast.parse(callcode, mode="eval").body name = None args = None diff --git a/scanapi/std.py b/scanapi/std.py index c80757fd..df327c0e 100644 --- a/scanapi/std.py +++ b/scanapi/std.py @@ -1,5 +1,3 @@ - - """ A compendium of off-the-shelf operations for rmc expressions. """ diff --git a/tests/unit/evaluators/test_rmc_evaluator.py b/tests/unit/evaluators/test_rmc_evaluator.py index e55900a0..f41d0912 100644 --- a/tests/unit/evaluators/test_rmc_evaluator.py +++ b/tests/unit/evaluators/test_rmc_evaluator.py @@ -12,118 +12,142 @@ class TestRemoteMethodCallEvaluator: class TestGetName: def test_expected_behavior(self): - assert rmc.getname('evaluators.rmc_evaluator', scanapi) == rmc + assert rmc.getname("evaluators.rmc_evaluator", scanapi) == rmc import functools - assert rmc.getname('partial.__module__', functools) == functools.partial.__module__ + + assert ( + rmc.getname("partial.__module__", functools) + == functools.partial.__module__ + ) class TestWhenNameDoesntExist: def test_should_raise_attribute_error(self): with pytest.raises(AttributeError) as excinfo: - rmc.getname('evaluators.rmc_evaltor', scanapi) == rmc + rmc.getname("evaluators.rmc_evaltor", scanapi) == rmc assert str(excinfo.value) == ( - f'No such location: {scanapi}.evaluators.rmc_evaltor' + f"No such location: {scanapi}.evaluators.rmc_evaltor" ) class TestUnrollName: def test_expected_behavior(self): - assert rmc.unroll_name(ast.parse('a', mode='eval').body) == 'a' - assert rmc.unroll_name(ast.parse('a.c', mode='eval').body) == 'a.c' - assert rmc.unroll_name(ast.parse('a.b.c', mode='eval').body) == 'a.b.c' + assert rmc.unroll_name(ast.parse("a", mode="eval").body) == "a" + assert rmc.unroll_name(ast.parse("a.c", mode="eval").body) == "a.c" + assert rmc.unroll_name(ast.parse("a.b.c", mode="eval").body) == "a.b.c" class TestEvaluate: class TestWhenCodeRightMemberIsInvalid: def test_expected_behavior(self): with pytest.raises(ValueError) as excinfo: - rmc_eval('a:b + c', {}) + rmc_eval("a:b + c", {}) assert str(excinfo.value) == ( - 'Failed to parse \'b + c\' as an attribute name or function call.' + "Failed to parse 'b + c' as an attribute name or function call." ) class TestOnlyBareName: def test_expected_behavior(self): # local path module - assert not rmc_eval('scanapi.std:response.ok', {'response': Mock(status_code=400)}) - assert rmc_eval('scanapi.std:response.ok', {'response': Mock(status_code=200)}) + assert not rmc_eval( + "scanapi.std:response.ok", {"response": Mock(status_code=400)} + ) + assert rmc_eval( + "scanapi.std:response.ok", {"response": Mock(status_code=200)} + ) # no spec - assert rmc_eval('builtins:str', {'object': {4}}) == "{'object': {4}}" + assert rmc_eval("builtins:str", {"object": {4}}) == "{'object': {4}}" # spec but doesn't take keyword arguments - assert rmc_eval('builtins:list', {'iterable': (4, 5)}) == ['iterable'] + assert rmc_eval("builtins:list", {"iterable": (4, 5)}) == ["iterable"] class TestExprWithPositionalArgs: def test_expected_behavior(self): - assert rmc_eval('scanapi.std:response.status_is(200)', {'response': Mock(status_code=200)}) - assert not rmc_eval('scanapi.std:response.status_is(200)', {'response': Mock(status_code=400)}) + assert rmc_eval( + "scanapi.std:response.status_is(200)", + {"response": Mock(status_code=200)}, + ) + assert not rmc_eval( + "scanapi.std:response.status_is(200)", + {"response": Mock(status_code=400)}, + ) class TestExprWithKeywordArgs: def test_expected_behavior(self): - assert rmc_eval('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=200)}) - assert not rmc_eval('scanapi.std:response.status_is(code=200)', {'response': Mock(status_code=400)}) + assert rmc_eval( + "scanapi.std:response.status_is(code=200)", + {"response": Mock(status_code=200)}, + ) + assert not rmc_eval( + "scanapi.std:response.status_is(code=200)", + {"response": Mock(status_code=400)}, + ) class TestExprWithStdConst: def test_expected_behavior(self): - assert not rmc_eval('std:response.ok', {'response': Mock(status_code=400)}) - assert not rmc_eval(':response.ok', {'response': Mock(status_code=400)}) + assert not rmc_eval( + "std:response.ok", {"response": Mock(status_code=400)} + ) + assert not rmc_eval(":response.ok", {"response": Mock(status_code=400)}) class TestGetModule: def test_expected_behavior(self): - assert hasattr(rmc.get_module('scanapi.std'), 'response') - assert hasattr(rmc.get_module('operator'), '__add__') - assert hasattr(rmc.get_module('pathlib'), 'Path') + assert hasattr(rmc.get_module("scanapi.std"), "response") + assert hasattr(rmc.get_module("operator"), "__add__") + assert hasattr(rmc.get_module("pathlib"), "Path") class TestCallAgainstVars: def test_expected_behavior(self): def f(a: int, b: int) -> int: return a + b - assert rmc.call_against_vars(f, (), {}, {'a': 3, 'b': 4}) == 7 - assert rmc.call_against_vars(f, (), {'b': 4}, {'a': 3}) == 7 - assert rmc.call_against_vars(f, (3,), {}, {'b': 4}) == 7 + + assert rmc.call_against_vars(f, (), {}, {"a": 3, "b": 4}) == 7 + assert rmc.call_against_vars(f, (), {"b": 4}, {"a": 3}) == 7 + assert rmc.call_against_vars(f, (3,), {}, {"b": 4}) == 7 class TestWhenVarsIsEmpty: def test_should_raise_runtime_error(self): def f(a: int, b: int) -> int: return a + b + # vars should never be empty, so this fails vars = {} - vars['vars'] = vars + vars["vars"] = vars with pytest.raises(RuntimeError) as excinfo: - assert rmc.call_against_vars(f, (3,), {'b': 4}, vars) == 7 + assert rmc.call_against_vars(f, (3,), {"b": 4}, vars) == 7 assert str(excinfo.value) == ( - f'vars={vars.keys()} contain no key that function {f} expects as parameter' + f"vars={vars.keys()} contain no key that function {f} expects as parameter" ) class TestWhenVarsIsNotDictLike: def test_should_raise_type_error(self): with pytest.raises(TypeError) as excinfo: assert rmc.call_against_vars(lambda m: m, (), {}, 44) - assert str(excinfo.value) == 'vars=44 is not dict-like' + assert str(excinfo.value) == "vars=44 is not dict-like" class TestWhenFuncIsBuiltin: def test_should_use_vars_as_pos_arg(self): - vars = {'a': 3} + vars = {"a": 3} assert rmc.call_against_vars(str, (), {}, vars) == str(vars) assert rmc.call_against_vars(list, (), {}, vars) == list(vars) class TestWhenVarsIsPartOfSpec: def test_should_feed_vars_as_keyword_arg(self): def f(vars) -> int: - return vars['a'] + vars['b'] - assert rmc.call_against_vars(f, (), {}, {'a': 3, 'b': 4}) == 7 + return vars["a"] + vars["b"] + + assert rmc.call_against_vars(f, (), {}, {"a": 3, "b": 4}) == 7 class TestWhenSpecAndVarsShareNoCommonKeys: def test_should_raise_runtime_error(self): - def f(args) -> int: - return args['a'] + args['b'] + return args["a"] + args["b"] - vars = {'a': 3, 'b': 4} - vars['vars'] = vars + vars = {"a": 3, "b": 4} + vars["vars"] = vars with pytest.raises(RuntimeError) as excinfo: rmc.call_against_vars(f, (), {}, vars) == 7 assert str(excinfo.value) == ( - f'vars={vars.keys()} contain no key that function {f} expects as parameter' + f"vars={vars.keys()} contain no key that function {f} expects as parameter" ) From 55b80d9d2aa3f5168f8cfd10ea7417b53bb0a8c1 Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 22:55:13 +0200 Subject: [PATCH 19/21] Remove re.Match annotation --- scanapi/evaluators/rmc_evaluator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanapi/evaluators/rmc_evaluator.py b/scanapi/evaluators/rmc_evaluator.py index a98d59c7..1ea6d310 100644 --- a/scanapi/evaluators/rmc_evaluator.py +++ b/scanapi/evaluators/rmc_evaluator.py @@ -93,7 +93,7 @@ def evaluate( code: str, vars: Dict[str, Any], is_a_test_case: bool = False, - match: Optional[re.Match] = None, + match=None, ): """ Parse a remote method call (rmc) expression, then run it against input `vars`. From 723f6f4a5790d02f0d8b02c5f0f46e18031f5279 Mon Sep 17 00:00:00 2001 From: zebralt Date: Fri, 23 Oct 2020 23:02:46 +0200 Subject: [PATCH 20/21] Remove unused import --- scanapi/evaluators/rmc_evaluator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanapi/evaluators/rmc_evaluator.py b/scanapi/evaluators/rmc_evaluator.py index 1ea6d310..a13f4001 100644 --- a/scanapi/evaluators/rmc_evaluator.py +++ b/scanapi/evaluators/rmc_evaluator.py @@ -4,7 +4,7 @@ import inspect import importlib from functools import partial -from typing import Dict, Any, Optional, Union, Callable, Tuple, Mapping +from typing import Dict, Any, Union, Callable, Tuple, Mapping from scanapi import std From 145d13df7f84b71695d81f45edccaf5fcdd40c86 Mon Sep 17 00:00:00 2001 From: zebralt <15049142+Zebralt@users.noreply.github.com> Date: Sun, 1 Nov 2020 00:04:29 +0100 Subject: [PATCH 21/21] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f883073..0f113e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Added flake8 check workflow on pull_request event [#321](https://github.com/scanapi/scanapi/pull/321) -- Allow ${{}} exprs to call methods from external python modules [PR]() +- Allow ${{}} exprs to call methods from external python modules [#324](https://github.com/scanapi/scanapi/pull/324) ### Changed - Updated poetry-publish version to v1.3 [#311](https://github.com/scanapi/scanapi/pull/311)