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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ temper = temper.plugin
@@ -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.