temper-python 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.
- temper/__init__.py +26 -0
- temper/_evidence.py +60 -0
- temper/plugin.py +140 -0
- temper_python-0.1.0.dist-info/METADATA +135 -0
- temper_python-0.1.0.dist-info/RECORD +8 -0
- temper_python-0.1.0.dist-info/WHEEL +4 -0
- temper_python-0.1.0.dist-info/entry_points.txt +2 -0
- temper_python-0.1.0.dist-info/licenses/LICENSE +21 -0
temper/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Temper adapter for pytest.
|
|
2
|
+
|
|
3
|
+
Mark a test with what it proves::
|
|
4
|
+
|
|
5
|
+
@pytest.mark.covers("store.reserve.never_negative", subject="store.file")
|
|
6
|
+
def test_reserve(): ...
|
|
7
|
+
|
|
8
|
+
or claim from inside it, which is how a parametrized scenario runner names its
|
|
9
|
+
scenario::
|
|
10
|
+
|
|
11
|
+
temper.covers(request, "alerts.low_stock", scenario="alerts.sold_out")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .plugin import add_claim
|
|
15
|
+
|
|
16
|
+
__all__ = ["covers", "exempts"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def covers(request, ref, *, subject=None, scenario=None):
|
|
20
|
+
"""The running test proves ``ref`` (for ``subject``, when it applies per subject)."""
|
|
21
|
+
add_claim(request, "covers", ref, subject, scenario)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def exempts(request, ref, *, subject=None, scenario=None):
|
|
25
|
+
"""The running test proves ``subject`` is excused from ``ref``."""
|
|
26
|
+
add_claim(request, "exempts", ref, subject, scenario)
|
temper/_evidence.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The evidence file: append-only, one file per process, one write per line."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_SEPARATORS = (",", ":")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def encode(record):
|
|
13
|
+
"""One record as one UTF-8 line, keys in the order given, compact."""
|
|
14
|
+
try:
|
|
15
|
+
return (json.dumps(record, separators=_SEPARATORS, ensure_ascii=False) + "\n").encode("utf-8")
|
|
16
|
+
except UnicodeEncodeError:
|
|
17
|
+
# A lone surrogate (an odd test id) cannot be UTF-8; JSON can still escape it.
|
|
18
|
+
return (json.dumps(record, separators=_SEPARATORS, ensure_ascii=True) + "\n").encode("utf-8")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Writer:
|
|
22
|
+
"""Appends records to ``<level>-<pid>-<8 hex>.jsonl`` in a directory.
|
|
23
|
+
|
|
24
|
+
The file is created on the first record, never opened for truncation, and
|
|
25
|
+
never shared: a forked child opens its own.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, directory, level):
|
|
29
|
+
self._dir = Path(directory)
|
|
30
|
+
self._level = level
|
|
31
|
+
self._fd = None
|
|
32
|
+
self._pid = None
|
|
33
|
+
|
|
34
|
+
def ensure_directory(self):
|
|
35
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
def write(self, record):
|
|
38
|
+
data = encode(record)
|
|
39
|
+
fd = self._open()
|
|
40
|
+
written = os.write(fd, data)
|
|
41
|
+
while written < len(data): # a short write is rare; finish the line
|
|
42
|
+
written += os.write(fd, data[written:])
|
|
43
|
+
|
|
44
|
+
def close(self):
|
|
45
|
+
if self._fd is not None and self._pid == os.getpid():
|
|
46
|
+
os.close(self._fd)
|
|
47
|
+
self._fd = None
|
|
48
|
+
|
|
49
|
+
def _open(self):
|
|
50
|
+
pid = os.getpid()
|
|
51
|
+
if self._fd is not None and self._pid == pid:
|
|
52
|
+
return self._fd
|
|
53
|
+
self._fd = None # inherited across a fork: leave the parent's file alone
|
|
54
|
+
self.ensure_directory()
|
|
55
|
+
safe = re.sub(r"[^A-Za-z0-9._-]", "_", self._level)
|
|
56
|
+
name = f"{safe}-{pid}-{secrets.token_hex(4)}.jsonl"
|
|
57
|
+
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_EXCL
|
|
58
|
+
self._fd = os.open(self._dir / name, flags, 0o644)
|
|
59
|
+
self._pid = pid
|
|
60
|
+
return self._fd
|
temper/plugin.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""The pytest plugin: reads what each test claims, records how it ended.
|
|
2
|
+
|
|
3
|
+
The level and the evidence directory come from ``TEMPER_LEVEL`` and
|
|
4
|
+
``TEMPER_EVIDENCE``, read once when pytest starts. Without both, the plugin
|
|
5
|
+
does nothing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from ._evidence import Writer
|
|
14
|
+
|
|
15
|
+
CLAIM_KINDS = ("covers", "exempts")
|
|
16
|
+
_KEYWORDS = ("subject", "scenario")
|
|
17
|
+
|
|
18
|
+
_CLAIMS = pytest.StashKey() # runtime claims made through temper.covers / temper.exempts
|
|
19
|
+
_OUTCOMES = pytest.StashKey() # one outcome per phase report
|
|
20
|
+
_STATE = pytest.StashKey()
|
|
21
|
+
|
|
22
|
+
_PRECEDENCE = ("error", "fail", "skip", "pass") # first one present wins
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _State:
|
|
26
|
+
def __init__(self, level, directory, run):
|
|
27
|
+
self.level = level
|
|
28
|
+
self.run = run
|
|
29
|
+
self.writer = Writer(directory, level)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _claim(kind, args, kwargs, where):
|
|
33
|
+
"""Validate one claim and return (kind, ref, subject, scenario)."""
|
|
34
|
+
if len(args) != 1 or not isinstance(args[0], str) or not args[0]:
|
|
35
|
+
raise ValueError(f"{where}: {kind} takes one non-empty reference, as a string")
|
|
36
|
+
for key in kwargs:
|
|
37
|
+
if key not in _KEYWORDS:
|
|
38
|
+
hint = " (the level comes from the runner, never the test)" if key == "level" else ""
|
|
39
|
+
raise ValueError(f"{where}: {kind} has no keyword {key!r}{hint}; use subject or scenario")
|
|
40
|
+
for key in _KEYWORDS:
|
|
41
|
+
value = kwargs.get(key)
|
|
42
|
+
if value is not None and (not isinstance(value, str) or not value):
|
|
43
|
+
raise ValueError(f"{where}: {kind} {key} must be a non-empty string")
|
|
44
|
+
return kind, args[0], kwargs.get("subject"), kwargs.get("scenario")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def add_claim(request, kind, ref, subject=None, scenario=None):
|
|
48
|
+
"""Claim from inside a test; the claim follows the test's outcome."""
|
|
49
|
+
node = getattr(request, "node", None)
|
|
50
|
+
if not isinstance(node, pytest.Item):
|
|
51
|
+
raise TypeError(f"temper.{kind} needs the `request` fixture of a test")
|
|
52
|
+
kwargs = {k: v for k, v in (("subject", subject), ("scenario", scenario)) if v is not None}
|
|
53
|
+
claim = _claim(kind, (ref,), kwargs, node.nodeid)
|
|
54
|
+
node.stash.setdefault(_CLAIMS, []).append(claim)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _claims(item):
|
|
58
|
+
seen, out = set(), []
|
|
59
|
+
marked = [_claim(m.name, m.args, m.kwargs, item.nodeid) for m in item.iter_markers() if m.name in CLAIM_KINDS]
|
|
60
|
+
for claim in marked + item.stash.get(_CLAIMS, []):
|
|
61
|
+
if claim not in seen:
|
|
62
|
+
seen.add(claim)
|
|
63
|
+
out.append(claim)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pytest_configure(config):
|
|
68
|
+
config.addinivalue_line("markers", "covers(ref, subject=None, scenario=None): the test proves this Temper claim")
|
|
69
|
+
config.addinivalue_line("markers", "exempts(ref, subject=None, scenario=None): the test proves this exemption")
|
|
70
|
+
level = os.environ.get("TEMPER_LEVEL", "")
|
|
71
|
+
directory = os.environ.get("TEMPER_EVIDENCE", "")
|
|
72
|
+
if not level or not directory:
|
|
73
|
+
return
|
|
74
|
+
state = _State(level, directory, os.environ.get("TEMPER_RUN", ""))
|
|
75
|
+
try:
|
|
76
|
+
state.writer.ensure_directory()
|
|
77
|
+
except OSError as err:
|
|
78
|
+
raise pytest.UsageError(f"temper: cannot write evidence to TEMPER_EVIDENCE={directory!r}: {err}")
|
|
79
|
+
config.stash[_STATE] = state
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pytest_unconfigure(config):
|
|
83
|
+
state = config.stash.get(_STATE, None)
|
|
84
|
+
if state is not None:
|
|
85
|
+
state.writer.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def pytest_collection_modifyitems(config, items):
|
|
89
|
+
if config.stash.get(_STATE, None) is None:
|
|
90
|
+
return
|
|
91
|
+
for item in items:
|
|
92
|
+
try:
|
|
93
|
+
_claims(item)
|
|
94
|
+
except ValueError as err:
|
|
95
|
+
raise pytest.UsageError(f"temper: {err}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _outcome(report, call):
|
|
99
|
+
if hasattr(report, "wasxfail"):
|
|
100
|
+
return "fail" # an expected failure never proves anything, whichever way it went
|
|
101
|
+
if report.skipped:
|
|
102
|
+
return "skip"
|
|
103
|
+
if report.passed:
|
|
104
|
+
return "pass"
|
|
105
|
+
if report.when != "call":
|
|
106
|
+
return "error" # setup or teardown broke
|
|
107
|
+
excinfo = call.excinfo
|
|
108
|
+
if excinfo is None or isinstance(excinfo.value, (AssertionError, pytest.fail.Exception)):
|
|
109
|
+
return "fail"
|
|
110
|
+
return "error"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
|
114
|
+
def pytest_runtest_makereport(item, call):
|
|
115
|
+
outcome = yield
|
|
116
|
+
if item.config.stash.get(_STATE, None) is None:
|
|
117
|
+
return
|
|
118
|
+
item.stash.setdefault(_OUTCOMES, []).append(_outcome(outcome.get_result(), call))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
122
|
+
def pytest_runtest_protocol(item, nextitem):
|
|
123
|
+
yield
|
|
124
|
+
state = item.config.stash.get(_STATE, None)
|
|
125
|
+
outcomes = item.stash.get(_OUTCOMES, None)
|
|
126
|
+
if state is None or not outcomes:
|
|
127
|
+
return
|
|
128
|
+
final = next(o for o in _PRECEDENCE if o in outcomes)
|
|
129
|
+
at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
130
|
+
for kind, ref, subject, scenario in _claims(item):
|
|
131
|
+
record = {kind: ref}
|
|
132
|
+
if subject:
|
|
133
|
+
record["subject"] = subject
|
|
134
|
+
if scenario:
|
|
135
|
+
record["scenario"] = scenario
|
|
136
|
+
record.update(test=item.nodeid, level=state.level, outcome=final)
|
|
137
|
+
if state.run:
|
|
138
|
+
record["run"] = state.run
|
|
139
|
+
record["at"] = at
|
|
140
|
+
state.writer.write(record)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: temper-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: pytest adapter for Temper: tests state what they prove, and write evidence.
|
|
5
|
+
Project-URL: Homepage, https://github.com/turbolytics/temper-python
|
|
6
|
+
Project-URL: Temper, https://github.com/turbolytics/temper
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Framework :: Pytest
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Testing
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: pytest>=7
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest-xdist; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# temper-python
|
|
20
|
+
|
|
21
|
+
The [Temper](https://github.com/turbolytics/temper) adapter for pytest. A test
|
|
22
|
+
states what it proves; the adapter records how it ended; `temper matrix` turns
|
|
23
|
+
those records into a matrix of what is proven and what is not.
|
|
24
|
+
|
|
25
|
+
## Getting started
|
|
26
|
+
|
|
27
|
+
You need the `temper` CLI ([install](https://github.com/turbolytics/temper#install))
|
|
28
|
+
and a project with `temper.yml` and a `temper/` directory of declarations.
|
|
29
|
+
`temper guide` explains both.
|
|
30
|
+
|
|
31
|
+
**1. Install the adapter.**
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install temper-python
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
It is a pytest plugin: nothing to import or enable.
|
|
38
|
+
|
|
39
|
+
**2. Mark each test with what it proves.**
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import pytest
|
|
43
|
+
|
|
44
|
+
@pytest.mark.covers("store.reserve.never_negative", subject="store.file")
|
|
45
|
+
def test_reserve_never_goes_negative(): ...
|
|
46
|
+
|
|
47
|
+
@pytest.mark.exempts("store.persists_across_restart", subject="store.memory")
|
|
48
|
+
def test_memory_store_forgets_on_restart(): ...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**3. Run the tests through `temper run`, then build the matrix.**
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
temper clean
|
|
55
|
+
temper run --level unit -- pytest
|
|
56
|
+
temper matrix --all-levels
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
That is the whole integration. Plain `pytest` behaves exactly as before: without
|
|
60
|
+
the variables `temper run` sets, the plugin records nothing.
|
|
61
|
+
|
|
62
|
+
## What a test can say
|
|
63
|
+
|
|
64
|
+
| Mark | Meaning |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `@pytest.mark.covers(ref)` | The test proves a feature or lattice member (`lattice/key`). |
|
|
67
|
+
| `@pytest.mark.covers(ref, subject=id)` | The test proves an invariant or lattice member for that subject. |
|
|
68
|
+
| `@pytest.mark.exempts(ref, subject=id)` | The test proves the subject is excused. Use it only in the test a declaration names in `proven_by`. |
|
|
69
|
+
| `scenario=id` (on either) | The claim came from that declared scenario. |
|
|
70
|
+
|
|
71
|
+
Marks on a class or a module apply to every test inside. A test may carry many
|
|
72
|
+
marks; each is one record with the same outcome.
|
|
73
|
+
|
|
74
|
+
A scenario runner that parametrizes one test over many scenarios claims from
|
|
75
|
+
inside the test, where the scenario is known:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import temper
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize("sid", SCENARIOS)
|
|
81
|
+
def test_scenario(request, sid):
|
|
82
|
+
temper.covers(request, "alerts.low_stock", scenario=sid)
|
|
83
|
+
temper.covers(request, sid, scenario=sid)
|
|
84
|
+
...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`temper.exempts(request, ref, subject=...)` is the same for exemptions.
|
|
88
|
+
|
|
89
|
+
There is no way to set a level. The runner does (`temper run --level`), and a
|
|
90
|
+
test that changes `TEMPER_LEVEL` in its own environment changes nothing: the
|
|
91
|
+
environment is read once, when pytest starts.
|
|
92
|
+
|
|
93
|
+
A malformed mark (no reference, a stray keyword such as `level=`, a non-string)
|
|
94
|
+
stops the run with a message naming the test, when the runner is active.
|
|
95
|
+
|
|
96
|
+
## How outcomes are recorded
|
|
97
|
+
|
|
98
|
+
A record is written once the test is final, after fixtures have torn down.
|
|
99
|
+
|
|
100
|
+
| Outcome | When |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `pass` | The test, its setup and its teardown all passed. The only outcome that counts. |
|
|
103
|
+
| `fail` | An assertion failed, `pytest.fail` was called, or the test was `xfail` (whether it failed or passed). |
|
|
104
|
+
| `skip` | The test was skipped, in any phase. |
|
|
105
|
+
| `error` | An exception other than a failed assertion, or any failure in setup or teardown. |
|
|
106
|
+
|
|
107
|
+
A test that fails and then skips records `fail`. Evidence goes to one new file
|
|
108
|
+
per process, `<level>-<pid>-<8 hex>.jsonl`, opened for append, one write per
|
|
109
|
+
line. Existing evidence is never truncated, so `pytest-xdist` works without
|
|
110
|
+
configuration. The contract every adapter follows is
|
|
111
|
+
[docs/adapters.md](https://github.com/turbolytics/temper/blob/main/docs/adapters.md).
|
|
112
|
+
|
|
113
|
+
Not verified: retry plugins such as `pytest-rerunfailures`.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
119
|
+
make matrix # clean, run the tests through temper, rebuild MATRIX.md
|
|
120
|
+
make check-matrix # fail if the committed matrix is stale
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This project is declared with Temper in `temper/`, and its own tests prove the
|
|
124
|
+
adapter claims with the adapter: see [MATRIX.md](MATRIX.md).
|
|
125
|
+
|
|
126
|
+
## Releasing
|
|
127
|
+
|
|
128
|
+
Set `version` in `pyproject.toml`, merge, then push the matching tag:
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
git tag v0.1.0 && git push origin v0.1.0
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The release workflow checks the tag matches the version, runs the tests, builds,
|
|
135
|
+
and publishes to PyPI through trusted publishing.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
temper/__init__.py,sha256=PeOWgYHJbTderl4JJjMsPMtT8ZcuXz_bLYzcv0OPiys,804
|
|
2
|
+
temper/_evidence.py,sha256=sKT71KPbBX2M3ep43E2KpDZcTjne91tg5d6wLU5rBPY,2003
|
|
3
|
+
temper/plugin.py,sha256=GbAkF--zjhomP18s28FJw67kEkzGsEakRYsToLO-oAs,5085
|
|
4
|
+
temper_python-0.1.0.dist-info/METADATA,sha256=rRUOfJ4IrofhVqp3BZ3_0H-PPNid-A6Y6cRUOmJiRXk,4757
|
|
5
|
+
temper_python-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
temper_python-0.1.0.dist-info/entry_points.txt,sha256=2eoBjS7NuMZfmbdIML3l4wXXTPhiMEA-J67DJ1YsZlA,34
|
|
7
|
+
temper_python-0.1.0.dist-info/licenses/LICENSE,sha256=hrlBpg_NtMec1X4UblatT5hOiwVgDS1JZwvALSbMThY,1068
|
|
8
|
+
temper_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 turbolytics
|
|
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.
|