pytest-results 0.1.0__tar.gz

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,322 @@
1
+ # Created by https://www.toptal.com/developers/gitignore/api/python,pycharm,dotenv,macos
2
+ # Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm,dotenv,macos
3
+
4
+ ### dotenv ###
5
+ .env
6
+
7
+ ### macOS ###
8
+ # General
9
+ .DS_Store
10
+ .AppleDouble
11
+ .LSOverride
12
+
13
+ # Icon must end with two \r
14
+ Icon
15
+
16
+
17
+ # Thumbnails
18
+ ._*
19
+
20
+ # Files that might appear in the root of a volume
21
+ .DocumentRevisions-V100
22
+ .fseventsd
23
+ .Spotlight-V100
24
+ .TemporaryItems
25
+ .Trashes
26
+ .VolumeIcon.icns
27
+ .com.apple.timemachine.donotpresent
28
+
29
+ # Directories potentially created on remote AFP share
30
+ .AppleDB
31
+ .AppleDesktop
32
+ Network Trash Folder
33
+ Temporary Items
34
+ .apdisk
35
+
36
+ ### macOS Patch ###
37
+ # iCloud generated files
38
+ *.icloud
39
+
40
+ ### PyCharm ###
41
+ # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
42
+ # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
43
+
44
+ # User-specific stuff
45
+ .idea/**/workspace.xml
46
+ .idea/**/tasks.xml
47
+ .idea/**/usage.statistics.xml
48
+ .idea/**/dictionaries
49
+ .idea/**/shelf
50
+
51
+ # AWS User-specific
52
+ .idea/**/aws.xml
53
+
54
+ # Generated files
55
+ .idea/**/contentModel.xml
56
+
57
+ # Sensitive or high-churn files
58
+ .idea/**/dataSources/
59
+ .idea/**/dataSources.ids
60
+ .idea/**/dataSources.local.xml
61
+ .idea/**/sqlDataSources.xml
62
+ .idea/**/dynamic.xml
63
+ .idea/**/uiDesigner.xml
64
+ .idea/**/dbnavigator.xml
65
+
66
+ # Gradle
67
+ .idea/**/gradle.xml
68
+ .idea/**/libraries
69
+
70
+ # Gradle and Maven with auto-import
71
+ # When using Gradle or Maven with auto-import, you should exclude module files,
72
+ # since they will be recreated, and may cause churn. Uncomment if using
73
+ # auto-import.
74
+ # .idea/artifacts
75
+ # .idea/compiler.xml
76
+ # .idea/jarRepositories.xml
77
+ # .idea/modules.xml
78
+ # .idea/*.iml
79
+ # .idea/modules
80
+ # *.iml
81
+ # *.ipr
82
+
83
+ # CMake
84
+ cmake-build-*/
85
+
86
+ # Mongo Explorer plugin
87
+ .idea/**/mongoSettings.xml
88
+
89
+ # File-based project format
90
+ *.iws
91
+
92
+ # IntelliJ
93
+ out/
94
+
95
+ # mpeltonen/sbt-idea plugin
96
+ .idea_modules/
97
+
98
+ # JIRA plugin
99
+ atlassian-ide-plugin.xml
100
+
101
+ # Cursive Clojure plugin
102
+ .idea/replstate.xml
103
+
104
+ # SonarLint plugin
105
+ .idea/sonarlint/
106
+
107
+ # Crashlytics plugin (for Android Studio and IntelliJ)
108
+ com_crashlytics_export_strings.xml
109
+ crashlytics.properties
110
+ crashlytics-build.properties
111
+ fabric.properties
112
+
113
+ # Editor-based Rest Client
114
+ .idea/httpRequests
115
+
116
+ # Android studio 3.1+ serialized cache file
117
+ .idea/caches/build_file_checksums.ser
118
+
119
+ ### PyCharm Patch ###
120
+ # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
121
+
122
+ # *.iml
123
+ # modules.xml
124
+ # .idea/misc.xml
125
+ # *.ipr
126
+
127
+ # Sonarlint plugin
128
+ # https://plugins.jetbrains.com/plugin/7973-sonarlint
129
+ .idea/**/sonarlint/
130
+
131
+ # SonarQube Plugin
132
+ # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
133
+ .idea/**/sonarIssues.xml
134
+
135
+ # Markdown Navigator plugin
136
+ # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
137
+ .idea/**/markdown-navigator.xml
138
+ .idea/**/markdown-navigator-enh.xml
139
+ .idea/**/markdown-navigator/
140
+
141
+ # Cache file creation bug
142
+ # See https://youtrack.jetbrains.com/issue/JBR-2257
143
+ .idea/$CACHE_FILE$
144
+
145
+ # CodeStream plugin
146
+ # https://plugins.jetbrains.com/plugin/12206-codestream
147
+ .idea/codestream.xml
148
+
149
+ # Azure Toolkit for IntelliJ plugin
150
+ # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
151
+ .idea/**/azureSettings.xml
152
+
153
+ ### Python ###
154
+ # Byte-compiled / optimized / DLL files
155
+ __pycache__/
156
+ *.py[cod]
157
+ *$py.class
158
+
159
+ # C extensions
160
+ *.so
161
+
162
+ # Distribution / packaging
163
+ .Python
164
+ build/
165
+ develop-eggs/
166
+ dist/
167
+ downloads/
168
+ eggs/
169
+ .eggs/
170
+ lib/
171
+ lib64/
172
+ parts/
173
+ sdist/
174
+ var/
175
+ wheels/
176
+ share/python-wheels/
177
+ *.egg-info/
178
+ .installed.cfg
179
+ *.egg
180
+ MANIFEST
181
+
182
+ # PyInstaller
183
+ # Usually these files are written by a python script from a template
184
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
185
+ *.manifest
186
+ *.spec
187
+
188
+ # Installer logs
189
+ pip-log.txt
190
+ pip-delete-this-directory.txt
191
+
192
+ # Unit test / coverage reports
193
+ htmlcov/
194
+ .tox/
195
+ .nox/
196
+ .coverage
197
+ .coverage.*
198
+ .cache
199
+ nosetests.xml
200
+ coverage.xml
201
+ *.cover
202
+ *.py,cover
203
+ .hypothesis/
204
+ .pytest_cache/
205
+ cover/
206
+
207
+ # Translations
208
+ *.mo
209
+ *.pot
210
+
211
+ # Django stuff:
212
+ *.log
213
+ local_settings.py
214
+ db.sqlite3
215
+ db.sqlite3-journal
216
+
217
+ # Flask stuff:
218
+ instance/
219
+ .webassets-cache
220
+
221
+ # Scrapy stuff:
222
+ .scrapy
223
+
224
+ # Sphinx documentation
225
+ docs/_build/
226
+
227
+ # PyBuilder
228
+ .pybuilder/
229
+ target/
230
+
231
+ # Jupyter Notebook
232
+ .ipynb_checkpoints
233
+
234
+ # IPython
235
+ profile_default/
236
+ ipython_config.py
237
+
238
+ # pyenv
239
+ # For a library or package, you might want to ignore these files since the code is
240
+ # intended to run in multiple environments; otherwise, check them in:
241
+ # .python-version
242
+
243
+ # pipenv
244
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
245
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
246
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
247
+ # install all needed dependencies.
248
+ #Pipfile.lock
249
+
250
+ # poetry
251
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
252
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
253
+ # commonly ignored for libraries.
254
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
255
+ #poetry.lock
256
+
257
+ # pdm
258
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
259
+ #pdm.lock
260
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
261
+ # in version control.
262
+ # https://pdm.fming.dev/#use-with-ide
263
+ .pdm.toml
264
+
265
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
266
+ __pypackages__/
267
+
268
+ # Celery stuff
269
+ celerybeat-schedule
270
+ celerybeat.pid
271
+
272
+ # SageMath parsed files
273
+ *.sage.py
274
+
275
+ # Environments
276
+ .venv
277
+ env/
278
+ venv/
279
+ ENV/
280
+ env.bak/
281
+ venv.bak/
282
+
283
+ # Spyder project settings
284
+ .spyderproject
285
+ .spyproject
286
+
287
+ # Rope project settings
288
+ .ropeproject
289
+
290
+ # mkdocs documentation
291
+ /site
292
+
293
+ # mypy
294
+ .mypy_cache/
295
+ .dmypy.json
296
+ dmypy.json
297
+
298
+ # Pyre type checker
299
+ .pyre/
300
+
301
+ # pytype static type analyzer
302
+ .pytype/
303
+
304
+ # Cython debug symbols
305
+ cython_debug/
306
+
307
+ # PyCharm
308
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
309
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
310
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
311
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
312
+ #.idea/
313
+
314
+ # End of https://www.toptal.com/developers/gitignore/api/python,pycharm,dotenv,macos
315
+
316
+ # Pyenv
317
+ .python-version
318
+
319
+ # Code editors
320
+ .fleet/
321
+ .idea/
322
+ .vscode/
@@ -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,44 @@
1
+ # pytest-results
2
+
3
+ [![CI](https://github.com/100nm/pytest-results/actions/workflows/ci.yml/badge.svg)](https://github.com/100nm/pytest-results)
4
+ [![PyPI](https://img.shields.io/pypi/v/pytest-results.svg?color=blue)](https://pypi.org/project/pytest-results/)
5
+ [![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)
6
+
7
+ Easily spot regressions in your tests.
8
+
9
+ The package lets you write non-regression tests.
10
+
11
+ ## Installation
12
+
13
+ ⚠️ _Requires Python 3.12 or higher_
14
+
15
+ ```bash
16
+ pip install pytest-results
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ First, retrieve the Pytest fixture `assert_results_match` in your test:
22
+
23
+ ```python
24
+ def test_function(assert_results_match):
25
+ result = {"hello": "world"}
26
+ assert_results_match(result)
27
+ ```
28
+
29
+ All results are stored in JSON files at the root of your project in the
30
+ `__pytest_results__` folder.
31
+
32
+ In case of regression, to open the interactive comparison, add the `--ide` parameter
33
+ to Pytest with the name of your IDE:
34
+
35
+ ```bash
36
+ pytest --ide vscode
37
+ ```
38
+
39
+ _At present, the IDEs supported are `cursor`, `pycharm` and `vscode`._
40
+
41
+ ## Resources
42
+
43
+ * [**assert_results_match fixture**](https://github.com/100nm/pytest-results/tree/prod/documentation/assert-results-match-fixture.md)
44
+ * [**Configuration**](https://github.com/100nm/pytest-results/tree/prod/documentation/configuration.md)
@@ -0,0 +1,103 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [dependency-groups]
6
+ dev = [
7
+ "hatch",
8
+ "mypy",
9
+ "pydantic",
10
+ "ruff",
11
+ ]
12
+ test = [
13
+ "pytest-cov",
14
+ ]
15
+
16
+ [project]
17
+ name = "pytest-results"
18
+ version = "0.1.0"
19
+ description = "Easily spot regressions in your tests."
20
+ license = { text = "MIT" }
21
+ readme = "README.md"
22
+ requires-python = ">=3.12, <4"
23
+ authors = [{ name = "remimd" }]
24
+ keywords = []
25
+ classifiers = [
26
+ "Development Status :: 4 - Beta",
27
+ "Topic :: Software Development :: Libraries",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ "Topic :: Software Development :: Testing",
30
+ "Programming Language :: Python",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3 :: Only",
33
+ "Operating System :: OS Independent",
34
+ "Intended Audience :: Developers",
35
+ "Natural Language :: English",
36
+ "Typing :: Typed",
37
+ ]
38
+ dependencies = [
39
+ "orjson",
40
+ "pytest",
41
+ ]
42
+
43
+ [project.entry-points.pytest11]
44
+ pytest-results = "pytest_results.contrib.pytest.plugin"
45
+
46
+ [project.urls]
47
+ Repository = "https://github.com/100nm/pytest-results"
48
+
49
+ [tool.coverage.report]
50
+ exclude_lines = [
51
+ "pass",
52
+ "pragma: no cover",
53
+ "raise NotImplementedError",
54
+ ]
55
+ omit = ["pytest_results/contrib/pytest/plugin.py"]
56
+
57
+ [tool.hatch.build]
58
+ skip-excluded-dirs = true
59
+
60
+ [tool.hatch.build.targets.sdist]
61
+ include = ["pytest_results"]
62
+
63
+ [tool.hatch.build.targets.wheel]
64
+ packages = ["pytest_results"]
65
+
66
+ [tool.mypy]
67
+ check_untyped_defs = true
68
+ disallow_any_generics = true
69
+ disallow_subclassing_any = true
70
+ disallow_untyped_defs = true
71
+ follow_imports = "silent"
72
+ no_implicit_reexport = true
73
+ plugins = ["pydantic.mypy"]
74
+ warn_redundant_casts = true
75
+ warn_unused_ignores = true
76
+
77
+ [tool.pydantic-mypy]
78
+ init_forbid_extra = true
79
+ init_typed = true
80
+ warn_required_dynamic_aliases = true
81
+
82
+ [tool.pytest.ini_options]
83
+ python_files = "test_*.py"
84
+ addopts = "--tb short --cov ./ --cov-append --cov-report term-missing:skip-covered"
85
+ testpaths = "**/tests/"
86
+
87
+ [tool.ruff]
88
+ line-length = 88
89
+ indent-width = 4
90
+
91
+ [tool.ruff.format]
92
+ quote-style = "double"
93
+ indent-style = "space"
94
+ skip-magic-trailing-comma = false
95
+ line-ending = "auto"
96
+
97
+ [tool.ruff.lint]
98
+ extend-select = ["F", "I", "N"]
99
+ fixable = ["ALL"]
100
+
101
+ [tool.uv]
102
+ default-groups = ["dev", "test"]
103
+ package = true
@@ -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()
@@ -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()
@@ -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
@@ -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