pytest-results 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ from ._core.assert_results_match import AssertResultsMatch, AssertResultsMatchType
2
+ from ._core.dumpers.abc import Dumper, JSONDumper
3
+ from ._core.storages.abc import Storage
4
+ from ._core.storages.local import LocalStorage
5
+
6
+ __all__ = (
7
+ "AssertResultsMatch",
8
+ "AssertResultsMatchType",
9
+ "Dumper",
10
+ "JSONDumper",
11
+ "Storage",
12
+ "LocalStorage",
13
+ )
File without changes
@@ -0,0 +1,77 @@
1
+ from abc import abstractmethod
2
+ from dataclasses import dataclass, field
3
+ from functools import singledispatch
4
+ from pathlib import Path
5
+ from typing import Any, Protocol, runtime_checkable
6
+
7
+ import pytest
8
+
9
+ from pytest_results._core.dumpers.abc import Dumper
10
+ from pytest_results._core.dumpers.json import SimpleJSONDumper
11
+ from pytest_results._core.storages.abc import Storage
12
+ from pytest_results.exceptions import ResultsMismatchError
13
+
14
+ type AssertResultsMatchType = _AssertResultsMatch[Any]
15
+
16
+
17
+ @runtime_checkable
18
+ class _AssertResultsMatch[T](Protocol):
19
+ __slots__ = ()
20
+
21
+ @abstractmethod
22
+ def __call__(self, current_result: T, /, file_suffix: str = ...) -> None:
23
+ raise NotImplementedError
24
+
25
+
26
+ @dataclass(repr=False, eq=False, frozen=True, slots=True)
27
+ class AssertResultsMatch[T](_AssertResultsMatch[T]):
28
+ request: pytest.FixtureRequest
29
+ storage: Storage
30
+ dumper: Dumper[T] | None = field(default=None)
31
+
32
+ def __call__(self, current_result: T, /, file_suffix: str = "") -> None:
33
+ dumper = self.dumper or select_dumper_from(current_result)
34
+ storage = self.storage
35
+
36
+ current_bytes = dumper.dump(current_result)
37
+ relative_filepath = self.__get_relative_result_filepath(
38
+ dumper.file_format,
39
+ file_suffix,
40
+ )
41
+ filepath = storage.get_absolute_path(relative_filepath)
42
+ previous_bytes = storage.read(filepath)
43
+
44
+ try:
45
+ assert current_bytes == previous_bytes
46
+
47
+ except AssertionError as exc:
48
+ temporary_filepath = storage.get_temporary_path(relative_filepath)
49
+ storage.write(temporary_filepath, current_bytes)
50
+ raise ResultsMismatchError(temporary_filepath, filepath, storage) from exc
51
+
52
+ def __get_relative_result_filepath(self, file_format: str, suffix: str) -> Path:
53
+ request = self.request
54
+ segments = request.module.__name__.split(".")
55
+
56
+ if cls := request.cls:
57
+ segments.append(cls.__name__)
58
+
59
+ segments.append(f"{request.function.__name__}{suffix}.{file_format}")
60
+ return Path(*segments)
61
+
62
+
63
+ @singledispatch
64
+ def select_dumper_from(value: Any) -> Dumper[Any]:
65
+ return SimpleJSONDumper()
66
+
67
+
68
+ try:
69
+ from pydantic import BaseModel
70
+ except ImportError: # pragma: no cover
71
+ ...
72
+ else:
73
+ from pytest_results._core.dumpers.pydantic import PydanticJSONDumper
74
+
75
+ @select_dumper_from.register
76
+ def _(value: BaseModel) -> Dumper[BaseModel]:
77
+ return PydanticJSONDumper()
File without changes
@@ -0,0 +1,19 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Protocol, runtime_checkable
3
+
4
+
5
+ @runtime_checkable
6
+ class Dumper[T](Protocol):
7
+ __slots__ = ()
8
+
9
+ file_format: str
10
+
11
+ @abstractmethod
12
+ def dump(self, value: T) -> bytes:
13
+ raise NotImplementedError
14
+
15
+
16
+ class JSONDumper[T](Dumper[T], ABC):
17
+ __slots__ = ()
18
+
19
+ file_format: str = "json"
@@ -0,0 +1,12 @@
1
+ from typing import Any
2
+
3
+ import orjson
4
+
5
+ from pytest_results._core.dumpers.abc import JSONDumper
6
+
7
+
8
+ class SimpleJSONDumper(JSONDumper[Any]):
9
+ __slots__ = ()
10
+
11
+ def dump(self, value: Any) -> bytes:
12
+ return orjson.dumps(value, default=str, option=orjson.OPT_INDENT_2)
@@ -0,0 +1,10 @@
1
+ from pydantic import BaseModel
2
+
3
+ from pytest_results._core.dumpers.abc import JSONDumper
4
+
5
+
6
+ class PydanticJSONDumper(JSONDumper[BaseModel]):
7
+ __slots__ = ()
8
+
9
+ def dump(self, value: BaseModel) -> bytes:
10
+ return value.model_dump_json(indent=2).encode()
File without changes
@@ -0,0 +1,32 @@
1
+ from abc import abstractmethod
2
+ from pathlib import Path
3
+ from typing import Protocol, runtime_checkable
4
+
5
+
6
+ @runtime_checkable
7
+ class Storage(Protocol):
8
+ __slots__ = ()
9
+
10
+ @abstractmethod
11
+ def copy(self, filepath: Path, destination: Path) -> None:
12
+ raise NotImplementedError
13
+
14
+ @abstractmethod
15
+ def exists(self, path: Path) -> bool:
16
+ raise NotImplementedError
17
+
18
+ @abstractmethod
19
+ def get_absolute_path(self, relative_path: Path) -> Path:
20
+ raise NotImplementedError
21
+
22
+ @abstractmethod
23
+ def get_temporary_path(self, relative_path: Path) -> Path:
24
+ raise NotImplementedError
25
+
26
+ @abstractmethod
27
+ def read(self, filepath: Path) -> bytes:
28
+ raise NotImplementedError
29
+
30
+ @abstractmethod
31
+ def write(self, filepath: Path, result: bytes = ...) -> None:
32
+ raise NotImplementedError
@@ -0,0 +1,43 @@
1
+ import os
2
+ import shutil
3
+ from contextlib import suppress
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from pytest_results._core.storages.abc import Storage
8
+
9
+
10
+ @dataclass(repr=False, eq=False, frozen=True, slots=True)
11
+ class LocalStorage(Storage):
12
+ dir: Path
13
+ temporary_dir: Path
14
+
15
+ def copy(self, filepath: Path, destination: Path) -> None:
16
+ shutil.copyfile(filepath, destination)
17
+
18
+ def exists(self, path: Path) -> bool:
19
+ return path.exists()
20
+
21
+ def get_absolute_path(self, relative_path: Path) -> Path:
22
+ return self.__get_absolute_path(relative_path, self.dir)
23
+
24
+ def get_temporary_path(self, relative_path: Path) -> Path:
25
+ return self.__get_absolute_path(relative_path, self.temporary_dir)
26
+
27
+ def read(self, filepath: Path) -> bytes:
28
+ with suppress(FileNotFoundError):
29
+ with open(filepath, "rb") as reader:
30
+ return reader.read()
31
+
32
+ return b""
33
+
34
+ def write(self, filepath: Path, result: bytes = b"") -> None:
35
+ os.makedirs(filepath.parent, exist_ok=True)
36
+
37
+ with open(filepath, "wb") as writer:
38
+ writer.write(result)
39
+
40
+ @staticmethod
41
+ def __get_absolute_path(filepath: Path, directory: Path) -> Path:
42
+ assert not filepath.is_absolute()
43
+ return directory / filepath
File without changes
File without changes
@@ -0,0 +1,127 @@
1
+ import warnings
2
+ from collections.abc import Generator, Iterator, Mapping
3
+ from pathlib import Path
4
+ from tempfile import TemporaryDirectory
5
+ from typing import ClassVar
6
+
7
+ from pytest import Config, FixtureRequest, Function, Parser, fixture, hookimpl
8
+
9
+ from pytest_results import AssertResultsMatch, AssertResultsMatchType, LocalStorage
10
+ from pytest_results.exceptions import ResultsMismatchError
11
+
12
+ __all__ = ()
13
+
14
+
15
+ def pytest_addoption(parser: Parser) -> None:
16
+ group = parser.getgroup("pytest-results")
17
+
18
+ group.addoption(
19
+ "--accept-all-diff",
20
+ dest="accept_all_diff",
21
+ action="store_true",
22
+ help="Parameter for accepting new differences between results.",
23
+ default=False,
24
+ )
25
+
26
+ diff_help = "Command line to open an interactive comparison. Example: `code -d -w {current} {previous}`."
27
+ group.addoption(
28
+ "--diff",
29
+ dest="diff",
30
+ metavar="COMMAND_LINE",
31
+ help=diff_help,
32
+ default=None,
33
+ )
34
+ parser.addini(
35
+ "diff",
36
+ type="string",
37
+ help=diff_help,
38
+ default=None,
39
+ )
40
+
41
+ ide_help = "The IDE to open for interactive comparison."
42
+ group.addoption(
43
+ "--ide",
44
+ dest="ide",
45
+ metavar="IDE",
46
+ help=ide_help,
47
+ default=None,
48
+ )
49
+ parser.addini(
50
+ "ide",
51
+ type="string",
52
+ help=ide_help,
53
+ default=None,
54
+ )
55
+
56
+
57
+ @hookimpl(wrapper=True)
58
+ def pytest_pyfunc_call(
59
+ pyfuncitem: Function,
60
+ ) -> Generator[None, object | None, object | None]:
61
+ try:
62
+ result = yield
63
+ except ResultsMismatchError as mismatch:
64
+ config = PytestResultsConfig(pyfuncitem.config)
65
+
66
+ if config.accept_all_diff:
67
+ mismatch.accept_diff()
68
+ return None
69
+
70
+ if command := config.diff_command:
71
+ mismatch.show_diff(command)
72
+
73
+ raise mismatch
74
+
75
+ return result
76
+
77
+
78
+ @fixture(scope="session")
79
+ def _pytest_results_tmpdir() -> Iterator[Path]:
80
+ with TemporaryDirectory(prefix="pytest-temporary-results@") as tmpdir:
81
+ yield Path(tmpdir)
82
+
83
+
84
+ @fixture(scope="function")
85
+ def assert_results_match(
86
+ request: FixtureRequest,
87
+ _pytest_results_tmpdir: Path,
88
+ ) -> AssertResultsMatchType:
89
+ results_dir = request.config.rootpath / "__pytest_results__"
90
+ storage = LocalStorage(results_dir, _pytest_results_tmpdir)
91
+ return AssertResultsMatch(request, storage)
92
+
93
+
94
+ class PytestResultsConfig:
95
+ __slots__ = ("__config",)
96
+
97
+ __diff_commands: ClassVar[Mapping[str, str]] = {
98
+ "cursor": "cursor -d -r -w {current} {previous}",
99
+ "pycharm": "pycharm diff {current} {previous}",
100
+ "vscode": "code -d -r -w {current} {previous}",
101
+ }
102
+
103
+ def __init__(self, pytest_config: Config) -> None:
104
+ self.__config = pytest_config
105
+
106
+ @property
107
+ def accept_all_diff(self) -> bool:
108
+ return self.__config.getoption("accept_all_diff")
109
+
110
+ @property
111
+ def diff_command(self) -> str | None:
112
+ if diff := self.__get_option_or_ini("diff"):
113
+ return diff
114
+
115
+ if ide := self.__get_option_or_ini("ide"):
116
+ lowercase_ide = ide.lower()
117
+
118
+ try:
119
+ return self.__diff_commands[lowercase_ide]
120
+ except KeyError:
121
+ warnings.warn(f"pytest-results doesn't yet support the `{ide}` IDE.")
122
+
123
+ return None
124
+
125
+ def __get_option_or_ini[T](self, key: str) -> T | None:
126
+ config = self.__config
127
+ return config.getoption(key, default=config.getini(key))
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from pytest_results import Storage
11
+
12
+ __all__ = ("PytestResultsError", "ResultsMismatchError")
13
+
14
+
15
+ class PytestResultsError(Exception): ...
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class ResultsMismatchError(AssertionError, PytestResultsError):
20
+ current_filepath: Path
21
+ previous_filepath: Path
22
+ storage: Storage = field(repr=False)
23
+
24
+ def __str__(self) -> str:
25
+ return self.__to_string(self.current_filepath, self.previous_filepath)
26
+
27
+ def accept_diff(self) -> None:
28
+ self.__ensure_file_exists(self.previous_filepath)
29
+ self.storage.copy(self.current_filepath, self.previous_filepath)
30
+
31
+ def show_diff(self, command: str) -> None:
32
+ self.__ensure_file_exists(self.previous_filepath)
33
+ command = command.format(
34
+ current=self.current_filepath,
35
+ previous=self.previous_filepath,
36
+ )
37
+ os.system(command)
38
+ time.sleep(1)
39
+
40
+ def __ensure_file_exists(self, filepath: Path) -> None:
41
+ if self.storage.exists(filepath):
42
+ return
43
+
44
+ self.storage.write(filepath)
45
+
46
+ @staticmethod
47
+ def __to_string(current: object, previous: object) -> str:
48
+ return f"Results mismatch\n・Current: `{current}`\n・Previous: `{previous}`"
File without changes
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-results
3
+ Version: 0.1.0
4
+ Summary: Easily spot regressions in your tests.
5
+ Project-URL: Repository, https://github.com/100nm/pytest-results
6
+ Author: remimd
7
+ License: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Natural Language :: English
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: <4,>=3.12
20
+ Requires-Dist: orjson
21
+ Requires-Dist: pytest
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pytest-results
25
+
26
+ [![CI](https://github.com/100nm/pytest-results/actions/workflows/ci.yml/badge.svg)](https://github.com/100nm/pytest-results)
27
+ [![PyPI](https://img.shields.io/pypi/v/pytest-results.svg?color=blue)](https://pypi.org/project/pytest-results/)
28
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
29
+
30
+ Easily spot regressions in your tests.
31
+
32
+ The package lets you write non-regression tests.
33
+
34
+ ## Installation
35
+
36
+ ⚠️ _Requires Python 3.12 or higher_
37
+
38
+ ```bash
39
+ pip install pytest-results
40
+ ```
41
+
42
+ ## Quick start
43
+
44
+ First, retrieve the Pytest fixture `assert_results_match` in your test:
45
+
46
+ ```python
47
+ def test_function(assert_results_match):
48
+ result = {"hello": "world"}
49
+ assert_results_match(result)
50
+ ```
51
+
52
+ All results are stored in JSON files at the root of your project in the
53
+ `__pytest_results__` folder.
54
+
55
+ In case of regression, to open the interactive comparison, add the `--ide` parameter
56
+ to Pytest with the name of your IDE:
57
+
58
+ ```bash
59
+ pytest --ide vscode
60
+ ```
61
+
62
+ _At present, the IDEs supported are `cursor`, `pycharm` and `vscode`._
63
+
64
+ ## Resources
65
+
66
+ * [**assert_results_match fixture**](https://github.com/100nm/pytest-results/tree/prod/documentation/assert-results-match-fixture.md)
67
+ * [**Configuration**](https://github.com/100nm/pytest-results/tree/prod/documentation/configuration.md)
@@ -0,0 +1,19 @@
1
+ pytest_results/__init__.py,sha256=7RPavMSYUhkvn5_4k0PWJFRILCuwTr5pEY3dZAi2PAg,358
2
+ pytest_results/exceptions.py,sha256=78vjEIk_gWF2fl5_oH0lLEgjssO1lsmT6i-PDGuRxCc,1394
3
+ pytest_results/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ pytest_results/_core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ pytest_results/_core/assert_results_match.py,sha256=UvQspBtdE9yHFPk6vKxnoyhytJTEbz3TfjC7KCYhtmc,2466
6
+ pytest_results/_core/dumpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pytest_results/_core/dumpers/abc.py,sha256=dMy1waEDsTyN9tkcZ-PDNDv6seyrdm3RFnok2a1diY0,355
8
+ pytest_results/_core/dumpers/json.py,sha256=9cg2QQlGeHGrPgVHkRR9mksu9u9Orr2WAnfBG9ZlIlo,275
9
+ pytest_results/_core/dumpers/pydantic.py,sha256=mvdma7H6IKy7aod8IomzAQy2OWn2_MxUL724_qTl9l0,262
10
+ pytest_results/_core/storages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ pytest_results/_core/storages/abc.py,sha256=Cl1yDa9ySJe2okFyRLlu4UmaUytT8izmAzpxMKx0bYs,839
12
+ pytest_results/_core/storages/local.py,sha256=9R47caGzlNhT3G73k_7BGjD8ooEG5H-5-JxN_4Hbc5A,1306
13
+ pytest_results/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ pytest_results/contrib/pytest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ pytest_results/contrib/pytest/plugin.py,sha256=BAQkgrNMVBnrPyBftz9eJkfw2zETDPg8ju8bOyUaydI,3455
16
+ pytest_results-0.1.0.dist-info/METADATA,sha256=RLeNSVgFnNA_kXpv4ZMGS4mfKoTt0MWUCXnS5ijVWzM,2235
17
+ pytest_results-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
+ pytest_results-0.1.0.dist-info/entry_points.txt,sha256=iOHso0eGj305_uM2hcsLwSKhrJKEytoIlso7Qaoey20,65
19
+ pytest_results-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ pytest-results = pytest_results.contrib.pytest.plugin