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.
- pytest_checkpoint/__init__.py +46 -0
- pytest_checkpoint/lap.py +72 -0
- pytest_checkpoint/logger.py +27 -0
- pytest_checkpoint/plugin.py +126 -0
- pytest_checkpoint/py.typed +0 -0
- pytest_checkpoint-0.1.0.dist-info/METADATA +102 -0
- pytest_checkpoint-0.1.0.dist-info/RECORD +9 -0
- pytest_checkpoint-0.1.0.dist-info/WHEEL +4 -0
- pytest_checkpoint-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
pytest_checkpoint/lap.py
ADDED
|
@@ -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,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.
|