pytest-ditto 0.0.1__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,12 @@
1
+ .ditto
2
+
3
+ .pytest_cache
4
+ __pycache__
5
+ *.egg-info
6
+
7
+ venv*
8
+
9
+ .idea/
10
+ .vscode/
11
+
12
+ dist/
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.3
2
+ Name: pytest-ditto
3
+ Version: 0.0.1
4
+ Author-email: Lachlan Taylor <lachlanbtaylor@proton.me>
5
+ Maintainer-email: Lachlan Taylor <lachlanbtaylor@proton.me>
6
+ License-Expression: MIT
7
+ Keywords: pytest
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Framework :: Pytest
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: pandas
22
+ Requires-Dist: pyarrow
23
+ Requires-Dist: pytest>=3.5.0
24
+ Requires-Dist: pyyaml
25
+ Provides-Extra: dev
26
+ Requires-Dist: hatch-vcs>=0.4.0; extra == 'dev'
27
+ Requires-Dist: hatch>=1.9.4; extra == 'dev'
28
+ Requires-Dist: pre-commit; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # pytest-ditto
@@ -0,0 +1 @@
1
+ # pytest-ditto
@@ -0,0 +1,6 @@
1
+ import pytest
2
+
3
+ from ditto.snapshot import Snapshot
4
+ from ditto._unittest import DittoTestCase
5
+
6
+ record = pytest.mark.record
@@ -0,0 +1,29 @@
1
+ import unittest
2
+ import inspect
3
+ from typing import ClassVar
4
+ from pathlib import Path
5
+
6
+ from ditto.snapshot import Snapshot
7
+
8
+
9
+ def _calling_test_path() -> Path:
10
+ frame = inspect.currentframe()
11
+ outer_frames = inspect.getouterframes(frame)
12
+ # 2 calls back up the stack, index 1 of the frame has the calling filepath
13
+ return Path(outer_frames[2][1]).parent
14
+
15
+
16
+ class DittoTestCase(unittest.TestCase):
17
+
18
+ record: ClassVar[bool] = True
19
+
20
+ @property
21
+ def snapshot(self) -> Snapshot:
22
+ path = _calling_test_path() / ".ditto"
23
+ path.mkdir(exist_ok=True)
24
+
25
+ return Snapshot(
26
+ path=path,
27
+ name=".".join(self.id().split(".")[-3:]),
28
+ record=self.record,
29
+ )
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1'
@@ -0,0 +1,37 @@
1
+ from ditto.io.protocol import SnapshotIO
2
+ from ditto.io._yaml import YamlIO
3
+ from ditto.io._json import JsonIO
4
+ from ditto.io._pickle import PickleIO
5
+ from ditto.io._pandas_parquet import PandasParquetIO
6
+
7
+
8
+ __all__ = [
9
+ "SnapshotIO",
10
+ "YamlIO",
11
+ "JsonIO",
12
+ "PickleIO",
13
+ "PandasParquetIO",
14
+ "register",
15
+ "get",
16
+ "default",
17
+ ]
18
+
19
+
20
+ _NAME_IO_MAP: dict[str, type[SnapshotIO]] = {
21
+ "pkl": PickleIO,
22
+ "json": JsonIO,
23
+ "yaml": YamlIO,
24
+ "pandas_parquet": PandasParquetIO,
25
+ }
26
+
27
+
28
+ def register(name: str, io: type[SnapshotIO]) -> None:
29
+ _NAME_IO_MAP[name] = io
30
+
31
+
32
+ def get(name: str, default: SnapshotIO = PickleIO) -> SnapshotIO:
33
+ return _NAME_IO_MAP.get(name, default)
34
+
35
+
36
+ def default() -> type[SnapshotIO]:
37
+ return PickleIO
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+ from typing import ClassVar, Any
3
+
4
+ import json
5
+
6
+
7
+ class JsonIO:
8
+ extension: ClassVar[str] = "json"
9
+
10
+ @staticmethod
11
+ def save(data: Any, filepath: Path) -> None:
12
+ with open(filepath, "w") as f:
13
+ json.dump(data, f)
14
+
15
+ @staticmethod
16
+ def load(filepath: Path) -> Any:
17
+ with open(filepath, "r") as f:
18
+ return json.load(f)
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+ from typing import ClassVar
3
+
4
+ import pandas as pd
5
+
6
+ from ditto.io.protocol import SnapshotIO
7
+
8
+
9
+ class PandasParquetIO(SnapshotIO):
10
+ extension: ClassVar[str] = "parquet"
11
+
12
+ @staticmethod
13
+ def save(data: pd.DataFrame, filepath: Path) -> None:
14
+ data.to_parquet(filepath)
15
+
16
+ @staticmethod
17
+ def load(filepath: Path) -> pd.DataFrame:
18
+ return pd.read_parquet(filepath)
@@ -0,0 +1,20 @@
1
+ from pathlib import Path
2
+ from typing import ClassVar, Any
3
+
4
+ import pickle
5
+
6
+ from ditto.io import SnapshotIO
7
+
8
+
9
+ class PickleIO(SnapshotIO):
10
+ extension: ClassVar[str] = "pkl"
11
+
12
+ @staticmethod
13
+ def save(data: Any, filepath: Path) -> None:
14
+ with open(filepath, "wb") as f:
15
+ pickle.dump(data, f)
16
+
17
+ @staticmethod
18
+ def load(filepath: Path) -> Any:
19
+ with open(filepath, "rb") as f:
20
+ return pickle.load(f)
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+ from typing import ClassVar, Any
3
+
4
+ import yaml
5
+
6
+
7
+ class YamlIO:
8
+ extension: ClassVar[str] = "yaml"
9
+
10
+ @staticmethod
11
+ def save(data: Any, filepath: Path) -> None:
12
+ with open(filepath, "w") as f:
13
+ yaml.dump(data, f, Dumper=yaml.SafeDumper)
14
+
15
+ @staticmethod
16
+ def load(filepath: Path) -> Any:
17
+ with open(filepath, "r") as f:
18
+ return yaml.load(f, Loader=yaml.SafeLoader)
@@ -0,0 +1,13 @@
1
+ from pathlib import Path
2
+ from typing import ClassVar, Any, Protocol
3
+
4
+
5
+ # TODO: maybe use abstract base class instead
6
+ class SnapshotIO(Protocol):
7
+ extension: ClassVar[str]
8
+
9
+ @staticmethod
10
+ def save(data: Any, filepath: Path) -> None: ...
11
+
12
+ @staticmethod
13
+ def load(filepath: Path) -> Any: ...
@@ -0,0 +1,71 @@
1
+ from typing import Any
2
+
3
+ import pytest
4
+
5
+ from ditto.snapshot import Snapshot
6
+ from ditto import io
7
+
8
+
9
+ @pytest.fixture(scope="function")
10
+ def snapshot(request) -> Snapshot:
11
+
12
+ io_name = None
13
+ parameters = {}
14
+ for mark in request.node.iter_markers(name="record"):
15
+ if mark.args:
16
+ if io_name is not None:
17
+ pytest.fail("Only one 'record' mark is allowed.")
18
+ io_name = mark.args[0]
19
+
20
+ if mark.kwargs:
21
+ parameters.update(mark.kwargs)
22
+
23
+ io_name = io_name if io_name is not None else "pkl"
24
+
25
+ # TODO: do i like this?
26
+ # if io_name is None:
27
+ # pytest.fail("'record' is a required mark when using the 'snapshot' fixture.")
28
+
29
+ path = request.path.parent / ".ditto"
30
+ path.mkdir(exist_ok=True)
31
+
32
+ # Get the snapshot identifier from the 'record' mark parameters (via kwargs) if it
33
+ # exists; otherwise, use the test function name.
34
+ identifier = parameters.get("identifier", request.node.name)
35
+
36
+ return Snapshot(
37
+ path=path,
38
+ name=identifier,
39
+ # record=True,
40
+ io=io.get(io_name, default=io.PickleIO),
41
+ )
42
+
43
+
44
+ def pytest_configure(config):
45
+ # register an additional marker
46
+ config.addinivalue_line("markers", "record(io): snapshot values")
47
+
48
+
49
+ # def pytest_runtest_setup(item):
50
+ # # envnames = [mark.args[0] for mark in item.iter_markers(name="env")]
51
+ # # if envnames:
52
+ # # if item.config.getoption("-E") not in envnames:
53
+ # # pytest.skip("test requires env in {!r}".format(envnames))
54
+ # #
55
+ # for mark in item.iter_markers(name="record"):
56
+ # msg =f"recording: args={mark.args}; kwargs={mark.kwargs}"
57
+ #
58
+ # path = item.path.parent / ".ditto"
59
+ # name = mark.kwargs.get("identifier", mark.name)
60
+ # identifier = mark.kwargs.get("identifier")
61
+ # io_name = next(iter(mark.args))
62
+ #
63
+ # snapshot = Snapshot(
64
+ # path=path,
65
+ # name=name,
66
+ # record=False,
67
+ # io=io.get(io_name, default=io.PickleIO),
68
+ # identifier=identifier,
69
+ # )
70
+ # if not snapshot.filepath(identifier=identifier).exists():
71
+ # pytest.skip(msg)
@@ -0,0 +1,73 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+ import pytest
4
+
5
+ from ditto import io
6
+
7
+
8
+ class Snapshot:
9
+
10
+ data: Any | None
11
+
12
+ def __init__(
13
+ self,
14
+ path: Path,
15
+ name: str,
16
+ record: bool = False,
17
+ io: io.SnapshotIO = io.PickleIO,
18
+ identifier: str | None = None,
19
+ ) -> None:
20
+ self.path = path
21
+ self.name = name
22
+ self.record = record
23
+ self.io = io if io is not None else io.default()
24
+ self.identifier = identifier
25
+ self.data = None
26
+
27
+ def filepath(self, identifier: str | None = None) -> Path:
28
+ identifier = identifier if identifier is not None else ""
29
+ identifier = f"{self.name}@{identifier}" if identifier else self.name
30
+ return self.path / f"{identifier}.{self.io.extension}"
31
+
32
+ def _save(self, data: Any, identifier: str | None = None) -> None:
33
+ identifier = identifier if identifier is not None else ""
34
+ self.io.save(data, self.filepath(identifier))
35
+
36
+ def _load(self, identifier: str | None = None) -> Any:
37
+ identifier = identifier if identifier is not None else ""
38
+ return self.io.load(self.filepath(identifier))
39
+
40
+ def __call__(self, data: Any, identifier: str | None = None) -> Any:
41
+ # If the snapshot data exists, and we are not recording, load the data from the
42
+ # snapshot file; otherwise, save the data to the snapshot file.
43
+
44
+ # TODO: At the moment there is no way to re-record snapshots. The approach is to
45
+ # manually delete the snapshot files and re-run the tests. Using another mark,
46
+ # e.g., 'record' might be a good way to do this?
47
+ if self.filepath(identifier).exists():
48
+ self.data = self._load(identifier)
49
+
50
+ else:
51
+ self._save(data, identifier)
52
+ self.data = data
53
+
54
+ _msg = (
55
+ f"\nNo snapshot found: {identifier=}"
56
+ f"\nRecoding new snapshot to {self.filepath(identifier)!r}. "
57
+ "\nRun again to test with recorded snapshot."
58
+ )
59
+
60
+ # FIXME: For tests that contain multiple snapshots, when initially recording
61
+ # the snapshot files, this call to pytest.skip results in the test exiting
62
+ # early and the remaining snapshots to remain unsaved. This means we need
63
+ # to run the test N times to get all snapshot files saved, where N is the
64
+ # number of snapshot calls in the test.
65
+ pytest.skip(_msg)
66
+
67
+ return self.data
68
+
69
+ def __repr__(self) -> str:
70
+ return f"{self.__class__.__name__.lower()}({self.data.__repr__()})"
71
+
72
+ def __str__(self) -> str:
73
+ return f"{self.__class__.__name__.lower()}({self.data.__str__()})"
@@ -0,0 +1,166 @@
1
+ [project]
2
+ name = "pytest-ditto"
3
+ dynamic = [
4
+ "version",
5
+ ]
6
+ description = ""
7
+ keywords = ["pytest"]
8
+ authors = [{ name = "Lachlan Taylor", email = "lachlanbtaylor@proton.me" }]
9
+ maintainers = [{ name = "Lachlan Taylor", email = "lachlanbtaylor@proton.me" }]
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ readme = "README.md"
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Framework :: Pytest",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Topic :: Software Development :: Testing",
19
+ "Programming Language :: Python",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Operating System :: OS Independent",
26
+ ]
27
+ dependencies = [
28
+ "pytest>=3.5.0",
29
+ "pyyaml",
30
+ "pandas",
31
+ "pyarrow",
32
+ ]
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pre-commit",
36
+ "hatch>=1.9.4",
37
+ "hatch-vcs>=0.4.0",
38
+ ]
39
+
40
+ [project.entry-points.pytest11]
41
+ recording = "ditto.plugin"
42
+
43
+
44
+ [build-system]
45
+ requires = ["hatchling", "hatch-vcs"]
46
+ build-backend = "hatchling.build"
47
+
48
+
49
+ [tool.hatch.build]
50
+ hooks.vcs.version-file = "ditto/_version.py"
51
+ hooks.vcs.template = "__version__ = {version!r}"
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["ditto"]
55
+ strict-naming = false
56
+
57
+ [tool.hatch.build.targets.sdist]
58
+ packages = ["ditto"]
59
+ strict-naming = false
60
+
61
+ [tool.hatch.version]
62
+ source = "vcs"
63
+
64
+ [tool.hatch.envs.default]
65
+ python = "3.10"
66
+ dependencies = [
67
+ "pytest",
68
+ ]
69
+
70
+ [tool.hatch.envs.default.scripts]
71
+ test = "pytest {args:tests}"
72
+
73
+ [[tool.hatch.envs.all.matrix]]
74
+ python = ["3.10", "3.11", "3.12"]
75
+
76
+
77
+ [tool.pytest.ini_options]
78
+ testpaths = "tests"
79
+ filterwarnings = [
80
+ # "error",
81
+ "ignore::UserWarning",
82
+ # note the use of single quote below to denote "raw" strings in TOML
83
+ 'ignore::DeprecationWarning',
84
+ ]
85
+
86
+
87
+ [tool.black]
88
+ line-length = 88
89
+ target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
90
+ exclude = '''
91
+ # A regex preceded with ^/ will apply only to files and directories
92
+ # in the root of the project.
93
+ ^/(
94
+ (
95
+ \.eggs
96
+ | \.git
97
+ | \.pytest_cache
98
+ | \.vscode
99
+ | \.mypy_cache
100
+ | __pycache__
101
+ | _cache
102
+ | app_data
103
+ | logs
104
+ | venv
105
+ | build
106
+ | dist
107
+ )/
108
+ )
109
+ '''
110
+
111
+ [tool.coverage.run]
112
+ branch = true
113
+ source = ["ditto"]
114
+
115
+ [tool.coverage.report]
116
+ show_missing = true
117
+ fail_under = 80
118
+
119
+ [tool.ruff]
120
+ line-length = 88 # same as Black.
121
+ target-version = "py310"
122
+
123
+ [tool.ruff.lint]
124
+ # Enable the following rule sets:
125
+ # pycodestyle (`E`) https://docs.astral.sh/ruff/rules/#pyflakes-f
126
+ # Pyflakes (`F`) https://docs.astral.sh/ruff/rules/#pyflakes-f
127
+ # flake8-bugbear (B) https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
128
+ # flake8-simplify (SIM) https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
129
+ # flake8-quotes (Q) https://docs.astral.sh/ruff/rules/#flake8-quotes-q
130
+ select = ["E", "F", "C90", "B", "SIM"]
131
+ extend-select = ["Q"]
132
+ ignore = []
133
+ # F401 - imported but unused
134
+ # F841 - local variable assigned but never used
135
+ # SIM300 - Yoda conditions are discouraged
136
+ extend-ignore = ["F401", "F841", "SIM300"]
137
+
138
+ # Allow autofix for all enabled rules (when `--fix`) is provided.
139
+ fixable = ["A", "B", "C", "D", "E", "F"]
140
+ unfixable = []
141
+
142
+ # Exclude a variety of commonly ignored directories.
143
+ exclude = [
144
+ ".bzr",
145
+ ".direnv",
146
+ ".eggs",
147
+ ".git",
148
+ ".hg",
149
+ ".mypy_cache",
150
+ ".nox",
151
+ ".pants.d",
152
+ ".pytype",
153
+ ".ruff_cache",
154
+ ".svn",
155
+ ".tox",
156
+ ".venv",
157
+ "__pypackages__",
158
+ "_build",
159
+ "buck-out",
160
+ "build",
161
+ "dist",
162
+ "node_modules",
163
+ "venv",
164
+ ]
165
+
166
+ mccabe.max-complexity = 5