pytest-checkpoint 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,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import pytest
6
+
7
+ from pytest_checkpoint.plugin import CheckpointPlugin, CheckpointPluginOpts, CollectBehavior
8
+
9
+ if TYPE_CHECKING:
10
+ from pytest import Config, Parser
11
+
12
+
13
+ _DEFAULT_LAP_OUT = ".pytest_checkpoint/lap.json"
14
+
15
+
16
+ def pytest_addoption(parser: Parser) -> None:
17
+ group = parser.getgroup(
18
+ name=CheckpointPlugin.name,
19
+ description=CheckpointPlugin.name,
20
+ )
21
+ group.addoption(
22
+ CheckpointPluginOpts.LAP_OUT,
23
+ action="store",
24
+ default=_DEFAULT_LAP_OUT,
25
+ help="Output file to store lap information for recording a checkpoint",
26
+ )
27
+ group.addoption(
28
+ CheckpointPluginOpts.COLLECT_BEHAVIOR,
29
+ action="store",
30
+ default=CollectBehavior.DESELECT,
31
+ # NOTE: In python 3.11, the choices argument is not supported for enums
32
+ # We must convert it to a list of strings
33
+ choices=list(CollectBehavior),
34
+ help="This will determine how tests are collected when a checkpoint is restored",
35
+ )
36
+
37
+
38
+ @pytest.hookimpl(trylast=True)
39
+ def pytest_configure(config: Config) -> None:
40
+ config.pluginmanager.register(CheckpointPlugin(config), CheckpointPlugin.name)
41
+
42
+
43
+ __all__ = [
44
+ "pytest_addoption",
45
+ "pytest_configure",
46
+ ]
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class Lap:
8
+ passed: list[str] = field(default_factory=list)
9
+ failed: list[str] = field(default_factory=list)
10
+
11
+ def mark_passed(self, test: str) -> None:
12
+ """Call this when a test has passed so we can record it in the lap.
13
+
14
+ Args:
15
+ test (str): The test name that passed.
16
+ """
17
+ if test in self.passed:
18
+ return
19
+
20
+ self.passed.append(test)
21
+ if test in self.failed:
22
+ self.failed.remove(test)
23
+
24
+ def mark_failed(self, test: str) -> None:
25
+ """Call this when a test has failed so we can record it in the lap.
26
+
27
+ Args:
28
+ test (str): The test name that failed.
29
+ """
30
+ if test in self.failed:
31
+ return
32
+
33
+ self.failed.append(test)
34
+ if test in self.passed:
35
+ self.passed.remove(test)
36
+
37
+ def is_recorded(self, test: str) -> bool:
38
+ """Check if a test has been recorded in the lap.
39
+
40
+ Args:
41
+ test (str): The test name to check.
42
+
43
+ Returns:
44
+ bool: True if the test has been recorded, False otherwise.
45
+ """
46
+ return test in self.passed or test in self.failed
47
+
48
+ def encode(self) -> dict[str, list[str]]:
49
+ """Encode the lap data into a dictionary.
50
+
51
+ Returns:
52
+ dict: The lap data encoded into a dictionary.
53
+ """
54
+ return {
55
+ "passed": self.passed,
56
+ "failed": self.failed,
57
+ }
58
+
59
+ @classmethod
60
+ def decode(cls, data: dict[str, list[str]]) -> Lap:
61
+ """Decode a dictionary into a Lap object.
62
+
63
+ Args:
64
+ data (dict): The dictionary to decode.
65
+
66
+ Returns:
67
+ Lap: The Lap object decoded from the dictionary.
68
+ """
69
+ return cls(
70
+ passed=data["passed"],
71
+ failed=data["failed"],
72
+ )
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ from _pytest.logging import ColoredLevelFormatter, get_log_level_for_setting
7
+
8
+ if TYPE_CHECKING:
9
+ from pytest import Config
10
+
11
+
12
+ def get_logger(config: Config, name: str) -> logging.Logger:
13
+ level = get_log_level_for_setting(config, "log_cli_level")
14
+ if level is None:
15
+ level = logging.DEBUG if config.option.verbose > 0 else logging.INFO
16
+
17
+ # TODO: Color does not work as expected.
18
+ formatter = ColoredLevelFormatter(
19
+ config.get_terminal_writer(), config.getini("log_cli_format"), datefmt=config.getini("log_cli_date_format")
20
+ )
21
+ handler = logging.StreamHandler()
22
+ handler.setLevel(level)
23
+ handler.setFormatter(formatter)
24
+ logger = logging.getLogger(name)
25
+ logger.setLevel(level)
26
+ logger.addHandler(handler)
27
+ return logger
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pytest_checkpoint.lap import Lap
9
+ from pytest_checkpoint.logger import get_logger
10
+
11
+ if TYPE_CHECKING:
12
+ from pytest import Config, Item, Session, TestReport
13
+
14
+
15
+ class RunTestPhase(StrEnum):
16
+ SETUP = "setup"
17
+ CALL = "call"
18
+ TEARDOWN = "teardown"
19
+
20
+
21
+ class CheckpointPluginOpts(StrEnum):
22
+ LAP_OUT = "--lap-out"
23
+ COLLECT_BEHAVIOR = "--collect-behavior"
24
+
25
+
26
+ class CollectBehavior(StrEnum):
27
+ DESELECT = "deselect"
28
+ SKIP = "skip"
29
+
30
+
31
+ class CheckpointPlugin:
32
+ name = "pytest-checkpoint"
33
+
34
+ def __init__(self, config: Config) -> None:
35
+ self.config = config
36
+ self.logger = get_logger(config, self.name)
37
+ self.lap_file = Path(config.getoption(CheckpointPluginOpts.LAP_OUT))
38
+ self.collect_behavior = CollectBehavior(config.getoption(CheckpointPluginOpts.COLLECT_BEHAVIOR))
39
+ self._lap: Lap | None = None
40
+
41
+ @property
42
+ def lap(self) -> Lap:
43
+ if self._lap:
44
+ return self._lap
45
+
46
+ if self.lap_file.exists():
47
+ # TODO: figure out a better way to do logging. Possibly do a report instead.
48
+ # Write a line before logging
49
+ self.config.get_terminal_writer().line()
50
+ self.logger.debug("Restoring checkpoint from %s", self.lap_file)
51
+ self._lap = Lap.decode(json.loads(self.lap_file.read_text()))
52
+ else:
53
+ self._lap = Lap()
54
+ return self._lap
55
+
56
+ def pytest_collection_modifyitems(self, session: Session, config: Config, items: list[Item]) -> None:
57
+ """https://docs.pytest.org/en/stable/reference/reference.html#pytest.hookspec.pytest_collection_modifyitems
58
+
59
+ Here we will record all the tests that are expected to run.
60
+
61
+ Args:
62
+ session (Session): The pytest session object.
63
+ """
64
+ deselected: set[Item] = set()
65
+ for item in items:
66
+ if item.nodeid in self.lap.passed:
67
+ if self.collect_behavior == CollectBehavior.DESELECT:
68
+ self.logger.debug("Deselecting %s from checkpoint", item.nodeid)
69
+ deselected.add(item)
70
+ continue
71
+ elif self.collect_behavior == CollectBehavior.SKIP:
72
+ self.logger.debug("Skipping %s from checkpoint", item.nodeid)
73
+ item.add_marker("skip")
74
+
75
+ items[:] = [item for item in items if item not in deselected]
76
+ config.hook.pytest_deselected(items=deselected)
77
+
78
+ def pytest_runtest_logreport(self, report: TestReport) -> None:
79
+ """https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.hookspec.pytest_runtest_logreport
80
+
81
+ We will inspect the report object to determine if the test passed, failed, or was skipped.
82
+
83
+ It handles this for each phase of the test: setup, call, and teardown.
84
+
85
+ Args:
86
+ report (TestReport): The report about to be logged.
87
+ """
88
+ # Special case for unittest expected failures
89
+ # This is not handled in the internal 'TestReport.from_item_and_call' method
90
+ is_unittest_expected_failure = report.keywords.get("__unittest_expecting_failure__", 0) == 1
91
+
92
+ if report.when == RunTestPhase.SETUP:
93
+ if report.failed and is_unittest_expected_failure is False:
94
+ self.lap.mark_failed(report.nodeid)
95
+ elif report.skipped and (hasattr(report, "wasxfail") or is_unittest_expected_failure):
96
+ # Edge case where setup failure happens on a xfail test.
97
+ # In these cases we should mark the test as passed
98
+ # After this we skip call and go to teardown
99
+ self.lap.mark_passed(report.nodeid)
100
+
101
+ return None
102
+
103
+ if report.when == RunTestPhase.CALL:
104
+ if report.skipped or report.passed:
105
+ self.lap.mark_passed(report.nodeid)
106
+
107
+ if report.failed and is_unittest_expected_failure is False:
108
+ self.lap.mark_failed(report.nodeid)
109
+ elif report.failed and is_unittest_expected_failure:
110
+ self.lap.mark_passed(report.nodeid)
111
+
112
+ return None
113
+
114
+ if report.when == RunTestPhase.TEARDOWN:
115
+ if report.failed and is_unittest_expected_failure is False:
116
+ self.lap.mark_failed(report.nodeid)
117
+ self._checkpoint()
118
+
119
+ def _checkpoint(self) -> None:
120
+ """Checkpoint your progress.
121
+
122
+ This will write the lap info to the output file.
123
+ """
124
+ self.logger.debug("Creating checkpoint to %s", self.lap_file)
125
+ self.lap_file.parent.mkdir(parents=True, exist_ok=True)
126
+ self.lap_file.write_text(json.dumps(self.lap.encode()))
File without changes
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-checkpoint
3
+ Version: 0.1.0
4
+ Summary: Restore a checkpoint in pytest
5
+ Author-email: Nick Grisafi <njgrisafi@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Framework :: Pytest
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Testing
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: pytest>=8.3.5
19
+ Description-Content-Type: text/markdown
20
+
21
+ # pytest-checkpoint
22
+
23
+ <!-- <img src="./docs/logo.png" alt="pytest-checkpoint Logo" width="250" /> -->
24
+ <img src="https://i.postimg.cc/wMKBDZ79/logo.webp" alt="pytest-checkpoint Logo" width="250" />
25
+
26
+ ## Overview
27
+
28
+ `pytest-checkpoint` is a plugin for the [pytest](https://docs.pytest.org/en/stable/) testing framework that allows developers to restore their testing state from a checkpoint. This feature is particularly useful for long-running test suites, enabling you to pick up where you left off without having to rerun all tests.
29
+
30
+ ### Perfect for Interruptible Environments
31
+
32
+ Running long test suites can be challenging when your environment isn't guaranteed to be stable. For example:
33
+ - Your CI pipeline might time out
34
+ - Your development machine might need a reboot
35
+ - Your cloud instance might be interrupted (i.e. AWS Spot instances)
36
+
37
+ `pytest-checkpoint` automatically saves your test progress, so you can pick up exactly where you left off when the system comes back online. This saves valuable time and computing resources, especially in environments where interruptions are common.
38
+
39
+ ## Features
40
+
41
+ - **Checkpoint Restoration**: Automatically restore the state of tests from a previously saved checkpoint.
42
+ - **Flexible Collection Behavior**: Choose how tests are collected when restoring from a checkpoint (deselect or skip).
43
+ - **Detailed Logging**: Get insights into the checkpointing process with debug logs.
44
+
45
+ ## Installation
46
+
47
+ You can install `pytest-checkpoint` using pip:
48
+
49
+ ```bash
50
+ pip install pytest-checkpoint
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ To use `pytest-checkpoint`, you need to add options to your pytest configuration. You can do this by adding the following lines to your `pytest.ini` or directly in the command line:
56
+
57
+ ```ini
58
+ [pytest]
59
+ lap_out = .pytest_checkpoint/lap.json
60
+ collect_behavior = deselect # or skip
61
+ ```
62
+
63
+ ### Command Line Options
64
+
65
+ - `--lap-out`: Specify the output file to store lap information for recording a checkpoint. Default is `.pytest_checkpoint/lap.json`.
66
+ - `--collect-behavior`: Determines how tests are collected when a checkpoint is restored. Options are `deselect` or `skip`.
67
+
68
+ ### Example
69
+
70
+ Run your tests as usual, and the plugin will handle the checkpointing automatically:
71
+
72
+ ```bash
73
+ pytest
74
+ ```
75
+
76
+ If a test fails or is skipped, the plugin will mark it accordingly in the checkpoint file.
77
+
78
+ ## Contributing
79
+
80
+ We welcome contributions from the community! To contribute to `pytest-checkpoint`, please follow these steps:
81
+
82
+ 1. Fork the repository.
83
+ 2. Create a new branch for your feature or bug fix.
84
+ 3. Make your changes and commit them.
85
+ 4. Push your branch to your forked repository.
86
+ 5. Create a pull request describing your changes.
87
+
88
+ ## License
89
+
90
+ `pytest-checkpoint` is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
91
+
92
+ ## Authors
93
+
94
+ - **Nick Grisafi** - [njgrisafi@gmail.com](mailto:njgrisafi@gmail.com)
95
+
96
+ ## Acknowledgments
97
+
98
+ - Thanks to the [pytest](https://docs.pytest.org/en/stable/) community for their support and contributions to the testing ecosystem.
99
+
100
+ ---
101
+
102
+ For more information, please refer to the [documentation](https://docs.pytest.org/en/stable/) or check the source code in this repository.
@@ -0,0 +1,9 @@
1
+ pytest_checkpoint/__init__.py,sha256=xQnekaCL4wSUtpcIURF_ap18RmvEKViL4YJt7Db4idE,1276
2
+ pytest_checkpoint/lap.py,sha256=me4F2KS2S7HBZ-a-9E_NokfnNfzOWrKIWFDMjfejJfU,1904
3
+ pytest_checkpoint/logger.py,sha256=d1wuyCOKYwNL2a9ADOFUHDyozdcA_0IHLZxw6Ze9AEs,858
4
+ pytest_checkpoint/plugin.py,sha256=txMxiZ65zhAJM9shqEjnHCKmZTtQ0NpwfyaAxvdr5fs,4746
5
+ pytest_checkpoint/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pytest_checkpoint-0.1.0.dist-info/METADATA,sha256=6UmWjrLQTb2GnyHCxqYDRRuc5LmLMexULf3a9Unog5w,3854
7
+ pytest_checkpoint-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ pytest_checkpoint-0.1.0.dist-info/licenses/LICENSE,sha256=hVSXDknAkp0GbEr5CYV8X0bauLYoQVUmbeBAgJMHv8Y,1073
9
+ pytest_checkpoint-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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Nicholas Grisafi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.