pytest-results 0.1.0b0__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,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-results
3
+ Version: 0.1.0b0
4
+ Project-URL: Repository, https://github.com/100nm/pytest-results
5
+ Author: remimd
6
+ License: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Natural Language :: English
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: <4,>=3.12
19
+ Requires-Dist: orjson
20
+ Requires-Dist: pytest
21
+ Description-Content-Type: text/markdown
22
+
23
+ # pytest-results
24
+
25
+ [![CI](https://github.com/100nm/pytest-results/actions/workflows/ci.yml/badge.svg)](https://github.com/100nm/pytest-results)
26
+ [![PyPI](https://img.shields.io/pypi/v/pytest-results.svg?color=blue)](https://pypi.org/project/pytest-results/)
27
+ [![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)
28
+
29
+ ## Installation
30
+
31
+ ⚠️ _Requires Python 3.12 or higher_
32
+
33
+ ```bash
34
+ pip install pytest-results
35
+ ```
@@ -0,0 +1,13 @@
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
+ ## Installation
8
+
9
+ ⚠️ _Requires Python 3.12 or higher_
10
+
11
+ ```bash
12
+ pip install pytest-results
13
+ ```
@@ -0,0 +1,102 @@
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.0b0"
19
+ description = ""
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
+
56
+ [tool.hatch.build]
57
+ skip-excluded-dirs = true
58
+
59
+ [tool.hatch.build.targets.sdist]
60
+ include = ["pytest_results"]
61
+
62
+ [tool.hatch.build.targets.wheel]
63
+ packages = ["pytest_results"]
64
+
65
+ [tool.mypy]
66
+ check_untyped_defs = true
67
+ disallow_any_generics = true
68
+ disallow_subclassing_any = true
69
+ disallow_untyped_defs = true
70
+ follow_imports = "silent"
71
+ no_implicit_reexport = true
72
+ plugins = ["pydantic.mypy"]
73
+ warn_redundant_casts = true
74
+ warn_unused_ignores = true
75
+
76
+ [tool.pydantic-mypy]
77
+ init_forbid_extra = true
78
+ init_typed = true
79
+ warn_required_dynamic_aliases = true
80
+
81
+ [tool.pytest.ini_options]
82
+ python_files = "test_*.py"
83
+ addopts = "--tb short --cov ./ --cov-report term-missing:skip-covered"
84
+ testpaths = "**/tests/"
85
+
86
+ [tool.ruff]
87
+ line-length = 88
88
+ indent-width = 4
89
+
90
+ [tool.ruff.format]
91
+ quote-style = "double"
92
+ indent-style = "space"
93
+ skip-magic-trailing-comma = false
94
+ line-ending = "auto"
95
+
96
+ [tool.ruff.lint]
97
+ extend-select = ["F", "I", "N"]
98
+ fixable = ["ALL"]
99
+
100
+ [tool.uv]
101
+ default-groups = ["dev", "test"]
102
+ 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
+ )
@@ -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:
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 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], Protocol):
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,28 @@
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 exists(self, path: Path) -> bool:
12
+ raise NotImplementedError
13
+
14
+ @abstractmethod
15
+ def get_absolute_path(self, relative_path: Path) -> Path:
16
+ raise NotImplementedError
17
+
18
+ @abstractmethod
19
+ def get_temporary_path(self, relative_path: Path) -> Path:
20
+ raise NotImplementedError
21
+
22
+ @abstractmethod
23
+ def read(self, filepath: Path) -> bytes:
24
+ raise NotImplementedError
25
+
26
+ @abstractmethod
27
+ def write(self, filepath: Path, result: bytes = ...) -> None:
28
+ raise NotImplementedError
@@ -0,0 +1,39 @@
1
+ import os
2
+ from contextlib import suppress
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from pytest_results._core.storages.abc import Storage
7
+
8
+
9
+ @dataclass(repr=False, eq=False, frozen=True, slots=True)
10
+ class LocalStorage(Storage):
11
+ dir: Path
12
+ temporary_dir: Path
13
+
14
+ def exists(self, path: Path) -> bool:
15
+ return path.exists()
16
+
17
+ def get_absolute_path(self, relative_path: Path) -> Path:
18
+ return self.__get_absolute_path(relative_path, self.dir)
19
+
20
+ def get_temporary_path(self, relative_path: Path) -> Path:
21
+ return self.__get_absolute_path(relative_path, self.temporary_dir)
22
+
23
+ def read(self, filepath: Path) -> bytes:
24
+ with suppress(FileNotFoundError):
25
+ with open(filepath, "rb") as reader:
26
+ return reader.read()
27
+
28
+ return b""
29
+
30
+ def write(self, filepath: Path, result: bytes = b"") -> None:
31
+ os.makedirs(filepath.parent, exist_ok=True)
32
+
33
+ with open(filepath, "wb") as writer:
34
+ writer.write(result)
35
+
36
+ @staticmethod
37
+ def __get_absolute_path(filepath: Path, directory: Path) -> Path:
38
+ assert not filepath.is_absolute()
39
+ 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 -w -d {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 -w -d {current} {previous}",
99
+ "pycharm": "pycharm diff {current} {previous}",
100
+ "vscode": "code -w -d {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,49 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from pytest_results import Storage
12
+
13
+ __all__ = ("PytestResultsError", "ResultsMismatchError")
14
+
15
+
16
+ class PytestResultsError(Exception): ...
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class ResultsMismatchError(AssertionError, PytestResultsError):
21
+ current_filepath: Path
22
+ previous_filepath: Path
23
+ storage: Storage = field(repr=False)
24
+
25
+ def __str__(self) -> str:
26
+ return self.__to_string(self.current_filepath, self.previous_filepath)
27
+
28
+ def accept_diff(self) -> None:
29
+ self.__ensure_file_exists(self.previous_filepath)
30
+ shutil.copyfile(self.current_filepath, self.previous_filepath)
31
+
32
+ def show_diff(self, command: str) -> None:
33
+ self.__ensure_file_exists(self.previous_filepath)
34
+ command = command.format(
35
+ current=self.current_filepath,
36
+ previous=self.previous_filepath,
37
+ )
38
+ os.system(command)
39
+ time.sleep(1)
40
+
41
+ def __ensure_file_exists(self, filepath: Path) -> None:
42
+ if self.storage.exists(filepath):
43
+ return
44
+
45
+ self.storage.write(filepath)
46
+
47
+ @staticmethod
48
+ def __to_string(current: object, previous: object) -> str:
49
+ return f"Results mismatch\n・Current: `{current}`\n・Previous: `{previous}`"
File without changes