pax_global_header00006660000000000000000000000064147310467710014524gustar00rootroot0000000000000052 comment=6c3fd3f5df9d989f2611ec62824bcab338bd6d9d parametrize-0.2.0/000077500000000000000000000000001473104677100140465ustar00rootroot00000000000000parametrize-0.2.0/.github/000077500000000000000000000000001473104677100154065ustar00rootroot00000000000000parametrize-0.2.0/.github/workflows/000077500000000000000000000000001473104677100174435ustar00rootroot00000000000000parametrize-0.2.0/.github/workflows/ci.yml000066400000000000000000000064231473104677100205660ustar00rootroot00000000000000name: CI on: push: branches: - master tags: - '**' pull_request: {} jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.13' - name: Install UV uses: astral-sh/setup-uv@v3 with: enable-cache: true cache-suffix: "${{ matrix.os }}-${{ matrix.python-version }}" cache-dependency-glob: "**/pyproject.toml" - name: Install dependencies run: uv sync --extra lint - name: check run: uv run ruff check parametrize - name: format run: uv run ruff format parametrize type-check: needs: lint strategy: fail-fast: false matrix: os: [" ubuntu-latest", "macos-latest", "windows-latest"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] defaults: run: shell: bash # required for windows runs-on: ${{ matrix.os }} steps: - name: Check out repository uses: actions/checkout@v4 - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install UV uses: astral-sh/setup-uv@v3 with: enable-cache: true cache-suffix: "${{ matrix.os }}-${{ matrix.python-version }}" cache-dependency-glob: "**/pyproject.toml" - name: Install dependencies run: uv sync --extra typing - name: Run MyPy run: uv run mypy parametrize test: needs: lint strategy: fail-fast: false matrix: os: [" ubuntu-latest", "macos-latest", "windows-latest"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] defaults: run: shell: bash # required for windows runs-on: ${{ matrix.os }} steps: - name: Check out repository uses: actions/checkout@v4 - name: Set up python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true cache-suffix: "${{ matrix.os }}-${{ matrix.python-version }}" cache-dependency-glob: "**/pyproject.toml" - name: Install dependencies run: uv sync --extra test - name: Run tests run: uv run pytest tests --cov=parametrize deploy: needs: test if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.13' - name: Build run: uv build - name: Generate Changelog run: python tools/get_changes.py ${{ github.ref }} > changelog.txt - name: Upload to PyPI env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} run: uv publish - name: Release uses: softprops/action-gh-release@v1 with: body_path: changelog.txt files: | dist/parametrize-*-.tar.gz dist/parametrize-*-.whl env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} parametrize-0.2.0/.gitignore000066400000000000000000000041021473104677100160330ustar00rootroot00000000000000# Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: .python-version # pipenv Pipfile Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ .idea # CMake cmake-build-*/ # File-based project format *.iws # IntelliJ out/ # JIRA plugin atlassian-ide-plugin.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties poetry.lock *.py !*/*.pyparametrize-0.2.0/LICENSE000066400000000000000000000020561473104677100150560ustar00rootroot00000000000000MIT License Copyright (c) 2021 Arseny Boykov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. parametrize-0.2.0/Makefile000066400000000000000000000014501473104677100155060ustar00rootroot00000000000000.PHONY: all clean format flake8 mypy test coverage install update all: flake8 mypy test sources = parametrize tools format: isort $(sources) tests black $(sources) tests flake8: flake8 $(sources) tests mypy: mypy $(sources) test: pytest tests --cov=$(sources) coverage: test coverage html open htmlcov/index.html install: # install packages from poetry.lock poetry install update: # install packages from pyproject.toml and generate poetry.lock (use when add new packages, or update existing) poetry update clean: @rm -rf build dist @rm -rf `find . -type d -name __pycache__` @rm -f `find . -type f -name '*.py[co]'` @rm -rf `find . -type d -name .pytest_cache` @rm -rf `find . -type d -name .mypy_cache` @rm -rf *.egg-info @rm -rf .coverage @rm -rf htmlcov @rm -rf poetry.lock parametrize-0.2.0/changelog.md000066400000000000000000000001141473104677100163130ustar00rootroot00000000000000## v0.1.1 * Add the readme to PyPi :memo: ## v0.1.0 * First release :tada: parametrize-0.2.0/parametrize/000077500000000000000000000000001473104677100163715ustar00rootroot00000000000000parametrize-0.2.0/parametrize/__init__.py000066400000000000000000000001371473104677100205030ustar00rootroot00000000000000from .parametrize import parametrize __version__ = "0.0.0" __all__ = [ "parametrize", ] parametrize-0.2.0/parametrize/parametrize.py000066400000000000000000000224341473104677100212730ustar00rootroot00000000000000import inspect import itertools import sys from contextlib import suppress from functools import partial, wraps from types import FrameType, FunctionType, ModuleType from typing import Any, Dict, Iterable, List, Set, Tuple, Union, cast from parametrize.utils import copy_func Parameter = Tuple[str, Any] Parameters = Tuple[Parameter, ...] ParametersList = List[Parameters] class UnparametrizedMethod: __slots__ = ("func",) def __init__(self, func: FunctionType): self.func = func def __getattribute__(self, item): """ Forbid any usage of unparametrized object """ if item in {"func", "__repr__", "__name__"}: return object.__getattribute__(self, item) raise AttributeError("UnparametrizedMethod should not be used anywhere") def __repr__(self): return f"{self.func.__name__}[...]" class ParametrizeContext: __slots__ = ( "func", "parametrizes_left", "all_parameters", "seen_argnames", "signature", "decoration_frame", ) def __init__(self, func: FunctionType, decoration_frame: FrameType): self.func = func self.signature = inspect.signature(func) self.parametrizes_left = _count_parametrize_decorators(func, decoration_frame) self.all_parameters: List[ParametersList] = [] self.seen_argnames: Set[str] = set() self.decoration_frame = decoration_frame def add(self, parameters: ParametersList, argnames_set: Set[str]): reused_names = argnames_set & self.seen_argnames if reused_names: raise TypeError(f"Arguments names reused: {reused_names}") if argnames_set - self.signature.parameters.keys(): raise TypeError( f"Unexpected argument(s) {argnames_set} " f"for function {self.func.__name__}{self.signature}" ) self.all_parameters.append(parameters) self.seen_argnames.update(argnames_set) self.parametrizes_left -= 1 @property def combined_parameters(self): for case in itertools.product(*self.all_parameters): yield {k: v for params in case for k, v in params} def __call__(self, *args, **kwargs): """ We should never end up here. This method is necessary just to ensure in case something goes wrong, it won't be unnoticed """ raise RuntimeError( f"Attempt to execute {self.func.__qualname__} before it was parametrized" ) def parametrize(argnames: Union[str, Iterable[str]], argvalues: Iterable[Any]): """ class TestSomething(unittest.TestCase): @parametrize('a,b', [(1, 2), (3, 4)]) def test_a_and_b(self, a, b): self.assertGreater(b, a) Trick to use pytest.mark.parametrize with unittest.TestCase It generates parametrized test cases and injects them into class namespace """ parameters, argnames_set = _collect_parameters(argnames, argvalues) def decorator( func_or_context: Union[FunctionType, ParametrizeContext], ) -> Union[ParametrizeContext, UnparametrizedMethod]: if isinstance(func_or_context, UnparametrizedMethod): # we should never end up here raise RuntimeError( "Failed to complete parametrization. " "Please make sure all parametrization done with decorators grouped in once place" ) if isinstance(func_or_context, ParametrizeContext): context = func_or_context else: decoration_frame = cast(FrameType, inspect.currentframe().f_back) # type: ignore context = ParametrizeContext(func_or_context, decoration_frame) context.add(parameters, argnames_set) if context.parametrizes_left: return context # pass context to the next parametrize decorator else: # set parametrized functions in place of given one _set_test_cases(context) return UnparametrizedMethod(context.func) decorator.__parametrize_decorator__ = parametrize # type: ignore return decorator def _collect_parameters(argnames, argvalues) -> Tuple[ParametersList, Set[str]]: if isinstance(argnames, str): argnames = list(map(str.strip, argnames.split(","))) argnames_set = set(argnames) if len(argnames) != len(argnames_set): raise TypeError("Arguments must not repeat") parameters = [] for i, values in enumerate(argvalues): if len(argnames) == 1 and isinstance(values, str) or not isinstance(values, Iterable): values = (values,) if len(values) != len(argnames): raise ValueError( f"Wrong number of values at index {i}, expected " f"{len(argnames)}, got {len(values)}: {values}" ) parameters.append(tuple(zip(argnames, values))) return parameters, argnames_set def _find_possible_decorators( namespace: Dict[str, Any], search_in_modules: bool = True ) -> Set[str]: possible_definitions: Set[str] = set() if not search_in_modules and namespace.get("__name__") in sys.builtin_module_names: # don't search in builtin modules return possible_definitions for key, value in namespace.items(): with suppress(ValueError): # inspect.unwrap() may raise ValueError if ( value is parametrize or getattr(value, "__parametrize_decorator__", False) is parametrize or ( hasattr(value, "__dict__") and "__wrapped__" in value.__dict__ and inspect.unwrap(value) is parametrize ) ): possible_definitions.add(key) elif search_in_modules and isinstance(value, ModuleType): # allow usages like @my_module.my_predefined_params possible_definitions.update( _find_possible_decorators(value.__dict__, search_in_modules=False) ) return possible_definitions def _count_parametrize_decorators(function, decoration_frame): possible_definitions = _find_possible_decorators( {**decoration_frame.f_globals, **decoration_frame.f_locals} ) lines, _ = inspect.getsourcelines(function) # maybe it would be safer/better to use ast.parse for that # but for now this method works pretty well parametrized_count = 0 parametrize_decorators_should_end = False decorator_out_of_order = False for line in map(str.strip, lines): if line.startswith("def "): break for definition in possible_definitions: if line.startswith(f"@{definition}"): if parametrize_decorators_should_end: raise TypeError( f"Parametrize decorator {line} must be grouped " f"together with other parametrize decorators" ) parametrized_count += 1 break else: is_another_decorator = line.startswith("@") if is_another_decorator and parametrized_count: parametrize_decorators_should_end = True elif is_another_decorator: decorator_out_of_order = line if not parametrized_count: raise RuntimeError( f"Unable to find any parametrizes in decorators, " f"please rewrite decorator name to match any of detected names @{possible_definitions}" ) if decorator_out_of_order: raise TypeError( f"{decorator_out_of_order} must be defined before any of " f"@{possible_definitions} decorators" ) return parametrized_count def _set_test_cases(context): func = context.func used_names: Set[str] = set() namespace = context.decoration_frame.f_locals for params in context.combined_parameters: parameters_str = "-".join(str(v).replace(".", "-") for v in params.values()) methods_with_same_name = 1 final_parameters_str = parameters_str while final_parameters_str in used_names: final_parameters_str = f"{parameters_str}:{methods_with_same_name}" methods_with_same_name += 1 func_name = func.__name__ used_names.add(final_parameters_str) parametrized_name = f"{func_name}[{final_parameters_str}]" if parametrized_name in namespace: raise NameError( f"{func_name!r} parametrized with [{final_parameters_str}] is already defined above" ) # creating a wrapper function. # functools.partial alone will not bound to class # functools.partialmethod and other descriptors won't be detected as tests def get_parametrized_method(f, name, parameters): parametrized_func = partial(f, **parameters) # copying func with new default parameters and name is necessary for introspection # without it, pytest, for example would think that parametrized values are fixtures @wraps(copy_func(f, name, parameters, context.signature)) def parametrized_method(*args, **kwargs): return parametrized_func(*args, **kwargs) return parametrized_method namespace[parametrized_name] = get_parametrized_method(func, parametrized_name, params) parametrize-0.2.0/parametrize/utils.py000066400000000000000000000041671473104677100201130ustar00rootroot00000000000000import sys from inspect import Signature from types import CodeType, FunctionType from typing import Any, Tuple if sys.version_info >= (3, 8): copy_code = CodeType.replace else: PY_36_37_CODE_ARGS: Tuple[str, ...] = ( "co_argcount", "co_kwonlyargcount", "co_nlocals", "co_stacksize", "co_flags", "co_code", "co_consts", "co_names", "co_varnames", "co_filename", "co_name", "co_firstlineno", "co_lnotab", "co_freevars", "co_cellvars", ) def copy_code(code: CodeType, **update: Any) -> CodeType: """ Create a copy of code object with changed attributes """ new_args = [update.pop(arg, getattr(code, arg)) for arg in PY_36_37_CODE_ARGS] if update: raise TypeError(f"Unexpected code attribute(s): {update}") return CodeType(*new_args) def copy_func(f: FunctionType, name, defaults, signature: Signature): """ Makes exact copy of a function object with given name and defaults """ new_defaults = [] kw_only_defaults = f.__kwdefaults__.copy() if f.__kwdefaults__ else {} for key, param in signature.parameters.items(): if param.kind is param.KEYWORD_ONLY: if key in defaults: kw_only_defaults[key] = defaults.pop(key) elif key in defaults: new_defaults.append(defaults.pop(key)) elif param.default is not param.empty: new_defaults.append(param.default) original_code = f.__code__ code_replacements = {"co_name": name} if (qualname := getattr(original_code, "co_qualname", None)) is not None: *path, _old_name = qualname.rsplit(".", maxsplit=1) path.append(name) code_replacements["co_qualname"] = ".".join(path) new_func = FunctionType( code=copy_code(original_code, **code_replacements), globals=f.__globals__, name=name, argdefs=tuple(new_defaults), closure=f.__closure__, ) new_func.__kwdefaults__ = kw_only_defaults new_func.__dict__.update(f.__dict__) return new_func parametrize-0.2.0/pyproject.toml000066400000000000000000000036211473104677100167640ustar00rootroot00000000000000 [project] name = "parametrize" description = "Drop-in @pytest.mark.parametrize replacement working with unittest.TestCase" authors = [ {name = "Bobronium", email = "write@bobronium.me"} ] dynamic = ["version"] license = {text = "MIT"} requires-python = ">=3.6" readme = "readme.md" keywords = ["pytest", "parametrize", "unittest"] homepage = "https://github.com/Bobronium/parametrize/" classifiers = [ "Development Status :: 3 - Alpha", "Framework :: Pytest", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development :: Testing", "Topic :: Software Development :: Testing :: Unit", "Topic :: Software Development :: Libraries", "Typing :: Typed", ] [tool.ruff] target-version = "py37" # technically it should be py36, but ruff doesn't support it line-length = 100 [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "B", "Q"] [tool.uv-dynamic-versioning] vcs = "git" style = "semver" [project.optional-dependencies] typing = [ "mypy==0.971", ] test = [ "pytest-cov>=2.11.1", "pytest-sugar>=0.9.4", "pytest-mock>=3.6.0" ] lint = [ "ruff", ] dev = [ "parametrize[lint,typing,test]" ] [tool.setuptools.dynamic] version = {vcs = "git", style = "semver"} [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [tool.hatch.version] source = "vcs" vcs = "git" style = "semver" parametrize-0.2.0/readme.md000066400000000000000000000154221473104677100156310ustar00rootroot00000000000000[![CI](https://github.com/MrMrRobat/parametrize/workflows/CI/badge.svg?event=push)](https://github.com/Bobronium/parametrize/actions?query=event%3Apush+branch%3Amaster+workflow%3ACI) [![PyPi](https://img.shields.io/pypi/v/parametrize.svg)](https://pypi.python.org/pypi/parametrize) [![Python Versions](https://img.shields.io/pypi/pyversions/parametrize.svg)](https://github.com/Bobronium/parametrize) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) ## Drop-in `@pytest.mark.parametrize` replacement working with `unittest.TestCase` ### Why? You want to start using `@pytest.mark.parametrize`, but can't simply drop `unittest.TestCase` because you have tons of `self.assert`'s, `setUp`'s `tearDown`'s to rewrite? With `@parametrize` you can start parameterizing your tests now, and get rid of `unittest.TestCase` later if needed. ## Usage ### Simple example from [pytest docs](https://docs.pytest.org/en/6.2.x/parametrize.html) adapted to unittest ```python import unittest from parametrize import parametrize class TestSomething(unittest.TestCase): @parametrize('test_input,expected', [("3+5", 8), ("2+4", 6), ("6*9", 42)]) def test_eval(self, test_input, expected): self.assertEqual(expected, eval(test_input)) ``` ```py $ python -m unittest test.py -v test_eval[2+4-6] (test.TestSomething) ... ok test_eval[3+5-8] (test.TestSomething) ... ok test_eval[6*9-42] (test.TestSomething) ... FAIL ====================================================================== FAIL: test_eval[6*9-42] (test.TestSomething) ---------------------------------------------------------------------- Traceback (most recent call last): File "parametrize/parametrize.py", line 261, in parametrized_method return parametrized_func(*args, **kwargs) File "test.py", line 8, in test_eval self.assertEqual(expected, eval(test_input)) AssertionError: 42 != 54 ---------------------------------------------------------------------- Ran 3 tests in 0.001s FAILED (failures=1) ``` ##### You don't need to use additional decorators, custom base classes or metaclasses. ### Stacking parametrize decorators: ```python import unittest from parametrize import parametrize class TestSomething(unittest.TestCase): @parametrize("x", [0, 1]) @parametrize("y", [2, 3]) def test_foo(self, x, y): pass ``` `test_foo` will be called with: `(x=0, y=2)`, `(x=1, y=2)`, `(x=0, y=3)`, and `(x=1, y=3)`: ```python $ python -m unittest test.py -v test_foo[2-0] (test.TestSomething) ... ok test_foo[2-1] (test.TestSomething) ... ok test_foo[3-0] (test.TestSomething) ... ok test_foo[3-1] (test.TestSomething) ... ok ---------------------------------------------------------------------- Ran 4 tests in 0.000s OK ``` ##### Note: even though the tests are always generated in the same order, the execution order is not guaranteed ## Compatibility Any `@parametrize` decorator can be converted to `@pytest.mark.parametrize` just by changing its name. `@pytest.mark.parametrize` decorator can be converted to `@parametrize` as long as `pytest.param`, `indirect`, `ids` and `scope` are not used. `@parametrize` works with both `unittest` and `pytest`. However, `pytest` is recommended due to [limitations when using unittest in cli](#parametrized-method-can-be-ran-from-command-line-only-via-pytest). Parametrized tests are properly detected and handled by PyCharm. They are displayed as if they were parametrized with `@pytest.mark.parametrize`. ## Limitations Since `@parametrize` does some kind of magic under the hood, there are some limitations you need to consider. It's likely you will never face most of them, but if you will, `@parametrize` will let you know with an error: - ### All parametrization must be done via decorators :white_check_mark: OK ```python @parametrize('a', (1, 2)) def f(a): ... ``` :x: Won't work: ```python def f(a): ... parametrize('a', (1, 2))(f) ``` ```py RuntimeError: Unable to find any parametrizes in decorators, please rewrite decorator name to match any of detected names @{'parametrize'} ``` - ### All other decorators must be defined before parametrize decorators :white_check_mark: OK: ```py @parametrize("a", (1, 2)) @parametrize("b", (2, 3)) @mock.patch(f"{__name__}.bar", "foo") def f(a, b): return a, b ``` :x: Won't work: ```python @mock.patch(f"{__name__}.bar", "foo") @parametrize("a", (1, 2)) @parametrize("b", (2, 3)) def f(a, b): return a, b ``` ```py TypeError: @mock.patch(f"{__name__}.bar", "foo") must be defined before any of @{'parametrize'} decorators ``` - ### If you assign parametrized decorator to variable, it must be accessible from `locals()` or `globals()` namespaces: :white_check_mark: OK: ```py a_parameters = parametrize("a", (4, 5)) # defined in module def func(): class TestSomething: b_parameters = parametrize("b", (1, 2, 3)) @b_parameters # b_parameters found in locals() @a_parameters # a_parameters found in globals() def test_method(self, a, b): ... ``` :x: Won't work: ```py def func(): # defined in function scope a_parameters = parametrize("a", (4, 5)) class TestSomething: print('a_parameters' in {**globals(), **locals()}) # False @a_parameters # accessed in class body scope def test_method(self, a, b): ... ``` ```py RuntimeError: Unable to find any parametrizes in decorators, please rewrite decorator name to match any of detected names @{'parametrize'} ``` - ### Parametrized method can be ran from command line only via pytest: `$ cat test.py` ```py import unittest from parametrize import parametrize class TestSomething(unittest.TestCase): @parametrize('a', (1, 2)) def test_something(self, a): self.assertIsInstance(a, int) ``` :white_check_mark: OK: `$ pytest test.py::TestSomething::test_something -v` ```py ... test.py::TestSomething.test_something[1] ✓ 50% █████ test.py::TestSomething.test_something[2] ✓ 100% ██████████ Results (0.07s): 2 passed ``` :x: Won't work: `$ python -m unittest test.TestSomething.test_something` ```py Traceback (most recent call last): ... TypeError: don't know how to make test from: test_something[...] ``` - ### `@parametrize` cannot be used in interactive environments like REPL (It works in IPython though) - ### `@parametrize` cannot be used in cythonized code parametrize-0.2.0/setup.cfg000066400000000000000000000012131473104677100156640ustar00rootroot00000000000000[tool:pytest] testpaths = tests [flake8] ; ignore redefinition of unused variables in tests per-file-ignores = tests/**.py: F811 max_line_length = 100 extend_ignore = W606,FS003,C408 show_source = True [mypy] ignore_missing_imports = True # TODO: define types for all functions and enable ;disallow_untyped_defs = True ; show errors in code pretty = True warn_redundant_casts = True warn_unreachable = True warn_unused_configs = True [isort] line_length=100 include_trailing_comma=True lines_after_imports = 2 multi_line_output = 3 [coverage:report] exclude_lines = pragma: no cover if TYPE_CHECKING: @overload @abc.abstractmethod parametrize-0.2.0/tests/000077500000000000000000000000001473104677100152105ustar00rootroot00000000000000parametrize-0.2.0/tests/__init__.py000066400000000000000000000000001473104677100173070ustar00rootroot00000000000000parametrize-0.2.0/tests/test_edge_cases.py000066400000000000000000000104431473104677100207050ustar00rootroot00000000000000import re from unittest import mock import pytest from parametrize import parametrize def test_wrong_number_of_values(): with pytest.raises( ValueError, match=re.escape("Wrong number of values at index 0, expected 2, got 1: (1,)") ): @parametrize("a,b", (1, 2)) def f(a, b): ... def test_arguments_name_reused(): with pytest.raises(TypeError, match=re.escape("Arguments names reused: {'b'}")): @parametrize("a,b", [(1, 2), (3, 4)]) @parametrize("b", (1, 2)) def f(a, b): ... def test_unexpected_argument(): with pytest.raises( TypeError, match=re.escape("Unexpected argument(s) {'b'} for function f(a)") ): @parametrize("b", (1, 2)) def f(a): ... def test_using_parametrize_as_a_function(): def f(a): ... with pytest.raises( RuntimeError, match=re.escape( "Unable to find any parametrizes in decorators, " "please rewrite decorator name to match any of detected names @{'parametrize'}" ), ): parametrize("a", (1, 2))(f) def test_parametrized_function_defined_above_and_failing_to_complete_parametrization(): @parametrize("a", (1, 2)) def f(a, c): ... with pytest.raises( NameError, match=re.escape("'f' parametrized with [1] is already defined above") ): @parametrize("a", (1, 2)) def f(a): ... with pytest.raises( RuntimeError, match="Failed to complete parametrization. " "Please make sure all parametrization done with decorators grouped in once place", ): parametrize("c", (1, 2))(f) def test_args_must_not_repeat(): with pytest.raises(TypeError, match="Arguments must not repeat"): parametrize("a,b,c,a", ((1, 2, 3, 4), (5, 6, 7, 8))) def test_decoration_order(): with pytest.raises( TypeError, match=re.escape( '@mock.patch(f"{__name__}.bar", "foo") ' "must be defined before any of @{'parametrize'} decorators" ), ): @mock.patch(f"{__name__}.bar", "foo") @parametrize("a", (1, 2)) @parametrize("b", (2, 3)) def f(a, b): return a, b def test_decoration_order_with_other_decorator_between_parametrizes(): with pytest.raises( TypeError, match=re.escape( 'Parametrize decorator @parametrize("b", (2, 3)) must be grouped ' "together with other parametrize decorators" ), ): @parametrize("a", (1, 2)) @mock.patch(f"{__name__}.bar", "foo") @parametrize("b", (2, 3)) def f(a, b): return a, b def test_calling_functon_during_parametrization(): def bad_bad_decorator(func): func() # oh, no, it called the function on decoration! # and it disguised as a parametrize decorator! bad_bad_decorator.__parametrize_decorator__ = parametrize with pytest.raises( RuntimeError, match=re.escape( "Attempt to execute test_calling_functon_during_parametrization..f " "before it was parametrized" ), ): @parametrize("a", (1, 2)) @bad_bad_decorator @parametrize("b", (2, 3)) def f(a, b): return a, b def test_pre_parametrized_decorators_in_non_global_namespace(): c_values = (1, 2, 3) c_parameters = parametrize("c", c_values) with pytest.raises( RuntimeError, match=re.escape( "Unable to find any parametrizes in decorators, " "please rewrite decorator name to match any of detected names @{'parametrize'}" ), ): class TestSomething: @c_parameters def test_method(self, a, b, c): ... def test_pre_parametrized_decorators_in_non_global_namespace_with_another_decorators(): c_parameters = parametrize("a", (1, 2, 3)) with pytest.raises( RuntimeError, match=re.escape( "Failed to complete parametrization. " "Please make sure all parametrization done with decorators grouped in once place" ), ): class TestSomething: @parametrize("b", (1, 2)) @c_parameters def test_method(self, a, b): ... parametrize-0.2.0/tests/test_utils.py000066400000000000000000000050761473104677100177710ustar00rootroot00000000000000import sys from inspect import signature import pytest from parametrize.utils import copy_code, copy_func @pytest.mark.skipif( sys.version_info >= (3, 8), reason="On PY38 builtin CodeType.replace method is used" ) def test_copy_code(): def f(): return locals()["a"] copied_without_changes = copy_code(f.__code__) assert copied_without_changes == f.__code__ assert copied_without_changes is not f.__code__ old_code = f.__code__ assert old_code.co_varnames == () assert old_code.co_nlocals == 0 assert old_code.co_argcount == 0 f.__code__ = new_code = copy_code(f.__code__, co_varnames=("a",), co_argcount=1, co_nlocals=1) assert new_code.co_varnames == ("a",) assert new_code.co_nlocals == 1 assert new_code.co_argcount == 1 assert old_code.co_varnames == () assert old_code.co_nlocals == 0 assert old_code.co_argcount == 0 new_args = {} old_args = {} from parametrize.utils import PY_36_37_CODE_ARGS for arg in set(PY_36_37_CODE_ARGS) - {"co_varnames", "co_nlocals", "co_argcount"}: new_args[arg] = getattr(new_code, arg) old_args[arg] = getattr(old_code, arg) assert new_args == old_args assert f(1) == 1 def test_copy_code_error(): with pytest.raises(TypeError, match="unknown_code_arg"): copy_code(test_copy_code_error.__code__, unknown_code_arg=1) def test_copy_func(): def f(a, b=1, *, c=2, d=5): return a, b, c, d f.important_attr = object() f2 = copy_func(f, "f", defaults=dict(a=3, d=2), signature=signature(f)) assert f2.__code__ == f.__code__ assert f2.__code__ is not f.__code__ assert f2.__dict__ == f.__dict__ assert f2.__dict__ is not f.__dict__ assert f2.__defaults__ == (3, 1) assert f2.__kwdefaults__ == {"c": 2, "d": 2} assert f2.__kwdefaults__ is not f.__kwdefaults__ assert f2.important_attr is f.important_attr assert f2() == (3, 1, 2, 2) assert f2(1, b=2, c=3) == (1, 2, 3, 2) assert f2(1) == (1, 1, 2, 2) f3 = copy_func(f, "f3", defaults=dict(), signature=signature(f)) assert f3.__code__ != f.__code__ assert f3.__dict__ == f.__dict__ assert f3.__dict__ is not f.__dict__ assert f3.__name__ == "f3" assert f3.__qualname__.split(".")[-1] == "f3" assert f2.__defaults__ == (3, 1) assert f3.__kwdefaults__ == f.__kwdefaults__ assert f3.__kwdefaults__ is not f.__kwdefaults__ assert f3.important_attr is f.important_attr assert f3(1, b=2, c=3, d=4) == (1, 2, 3, 4) assert f3(1) == (1, 1, 2, 5) assert f.__kwdefaults__ == {"c": 2, "d": 5} parametrize-0.2.0/tests/test_with_unittest.py000066400000000000000000000152441473104677100215410ustar00rootroot00000000000000from io import StringIO from itertools import chain, product from typing import List, Tuple, Type from unittest import TestCase, TextTestRunner, defaultTestLoader, mock from parametrize import parametrize from parametrize.parametrize import UnparametrizedMethod def run_unittests(case: Type[TestCase]): suite = defaultTestLoader.loadTestsFromTestCase(case) return TextTestRunner(stream=StringIO()).run(suite) def as_tuples(sequence): return [(v,) for v in sequence] A_B_VALUES = [("1", "2"), ("3", "4"), ("5", "6")] A_B_PARAMETERS = parametrize("a,b", A_B_VALUES) def assert_parametrized(test_case, *argvalues, name="test_method"): all_cases = list(product(*argvalues)) test_methods = [f"{name}[{'-'.join(map(str, chain(*case)))}]" for case in all_cases] assert set(test_methods) & set(test_case.__dict__) assert isinstance(getattr(test_case, name), UnparametrizedMethod) assert str(getattr(test_case, name)) == f"{name}[...]" return all_cases def assert_tests_passed(test_case, tests_run, failures: List[Tuple[str, Tuple[str, ...]]] = None): result = run_unittests(test_case) assert result.testsRun == tests_run assert result.errors == [] assert result.skipped == [] if failures is None: assert not result.failures return assert len(result.failures) == len(failures) for (method, message), (failed_test_case, fail_message) in zip(failures, result.failures): assert failed_test_case._testMethodName == method for message_part in message: assert message_part in fail_message def test_simple_parametrize(mocker): test_mock = mocker.Mock("test_mock") values = [1, 2, 3] class TestSomething(TestCase): @parametrize("a", values) def test_method(self, a): self.assertEqual(test_mock.return_value, test_mock(a)) self.assertIn(a, (1, 2)) assert_parametrized(TestSomething, as_tuples(values)) assert_tests_passed( TestSomething, tests_run=3, failures=[ ( "test_method[3]", ("self.assertIn(a, (1, 2))", "AssertionError: 3 not found in (1, 2)"), ) ], ) assert test_mock.mock_calls == [mocker.call(v) for v in values] def test_multiple_arg_parametrize(mocker): test_mock = mocker.Mock("test_mock") values = [("1", "2"), ("3", "4"), ("5", "6")] class TestSomething(TestCase): @parametrize("a,b", values) def test_method(self, *, a, b): self.assertEqual(test_mock.return_value, test_mock(a, b)) self.assertLess(int(a) + int(b), 11) assert_parametrized(TestSomething, values) assert_tests_passed( TestSomething, tests_run=3, failures=[ ( "test_method[5-6]", ("self.assertLess(int(a) + int(b), 11)", "AssertionError: 11 not less than 11"), ) ], ) assert test_mock.mock_calls == [mocker.call(*v) for v in values] def test_multiple_parametrize(mocker): test_mock = mocker.Mock("test_mock") ab_values = [("1", "2"), ("3", "4"), ("5", "6")] c_values = (1, 2, 3) class TestSomething(TestCase): @parametrize("a,b", ab_values) @parametrize("c", c_values) def test_method(self, a, b, c): self.assertEqual(test_mock.return_value, test_mock(c, a, b)) self.assertLess(int(a) + int(b), 11, msg=f"{c}") all_cases = assert_parametrized(TestSomething, as_tuples(c_values), ab_values) assert_tests_passed( TestSomething, tests_run=9, failures=[ ( f"test_method[{c}-5-6]", ( 'self.assertLess(int(a) + int(b), 11, msg=f"{c}")', "AssertionError: 11 not less than 11 : " + f"{c}", ), ) for c in c_values ], ) assert test_mock.mock_calls == [mocker.call(*chain(*v)) for v in all_cases] bar = "bar" # value to mock def test_usage_with_other_decorators(mocker): test_mock = mocker.Mock("test_mock") ab_values = [("1", "2"), ("3", "4"), ("5", "6")] c_values = (1, 2, 3) class TestSomething(TestCase): @parametrize("a,b", ab_values) @parametrize("c", c_values) @mock.patch(f"{__name__}.bar") def test_method(self, bar_mock, a, b, c): self.assertEqual(test_mock.return_value, test_mock(c, a, b)) self.assertLess(int(a) + int(b), 11, msg=f"{c}") self.assertIsInstance(bar_mock, mock.Mock) self.assertIs(bar_mock, bar) all_cases = assert_parametrized(TestSomething, as_tuples(c_values), ab_values) assert_tests_passed( TestSomething, tests_run=9, failures=[ ( f"test_method[{c}-5-6]", ( 'self.assertLess(int(a) + int(b), 11, msg=f"{c}")', "AssertionError: 11 not less than 11 : " + f"{c}", ), ) for c in c_values ], ) assert test_mock.mock_calls == [mocker.call(*chain(*v)) for v in all_cases] def test_values_overlap(mocker): test_mock = mocker.Mock("test_mock") ab_values = ((1, "1"), ("1", 1), ("1", "1"), (1, 1)) class TestSomething(TestCase): @parametrize("a,b", ab_values) def test_method(self, a, b): self.assertEqual(test_mock.return_value, test_mock(a, b)) assert_parametrized(TestSomething, ab_values) assert_tests_passed(TestSomething, tests_run=4) # FIXME: # tests are generated in expected order, but for some reason executed in different one assert sorted(test_mock.mock_calls, key=repr) == sorted( (mocker.call(*v) for v in ab_values), key=repr ) def test_pre_parametrized_decorators(mocker): test_mock = mocker.Mock("test_mock") c_values = (1, 2, 3) class TestSomething(TestCase): c_parameters = parametrize("c", c_values) @A_B_PARAMETERS @c_parameters def test_method(self, a, b, c): self.assertEqual(test_mock.return_value, test_mock(c, a, b)) self.assertLess(int(a) + int(b), 11, msg=f"{c}") all_cases = assert_parametrized(TestSomething, as_tuples(c_values), A_B_VALUES) assert_tests_passed( TestSomething, tests_run=9, failures=[ ( f"test_method[{c}-5-6]", ( 'self.assertLess(int(a) + int(b), 11, msg=f"{c}")', "AssertionError: 11 not less than 11 : " + f"{c}", ), ) for c in c_values ], ) assert test_mock.mock_calls == [mocker.call(*chain(*v)) for v in all_cases] parametrize-0.2.0/tools/000077500000000000000000000000001473104677100152065ustar00rootroot00000000000000parametrize-0.2.0/tools/get_changes.py000066400000000000000000000015431473104677100200320ustar00rootroot00000000000000import sys from pathlib import Path from typing import Iterable TOOLS_DIR = Path(__file__).parent CHANGELOG = TOOLS_DIR.parent / "changelog.md" def get_changes(changelog: Iterable[str], expected_version: str = None): reading_changes = False for line in changelog: if line.startswith("## v"): if reading_changes: break reading_changes = True if expected_version is None: continue actual_version = line.split()[1] if expected_version != actual_version: print(f"Version mismatch: {expected_version=}, {actual_version=}", file=sys.stderr) sys.exit(0) continue print(line.strip()) if __name__ == "__main__": with open(CHANGELOG) as changelog_file: get_changes(changelog_file, *sys.argv[1:])