digline 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.
- digline/__init__.py +7 -0
- digline/cli/__init__.py +26 -0
- digline/cli/__main__.py +5 -0
- digline/cli/environment.py +65 -0
- digline/cli/loader.py +230 -0
- digline/cli/main.py +634 -0
- digline/cli/view.py +265 -0
- digline/core/__init__.py +169 -0
- digline/core/adapters.py +96 -0
- digline/core/aggregate.py +354 -0
- digline/core/assertions.py +1059 -0
- digline/core/compare.py +409 -0
- digline/core/pii.py +192 -0
- digline/core/protocols.py +109 -0
- digline/core/ratio.py +77 -0
- digline/core/run.py +564 -0
- digline/core/sampling.py +261 -0
- digline/core/types.py +432 -0
- digline/py.typed +0 -0
- digline/report/__init__.py +60 -0
- digline/report/history.py +102 -0
- digline/report/pages.py +777 -0
- digline/report/render.py +780 -0
- digline/report/text.py +383 -0
- digline/run/__init__.py +28 -0
- digline/run/driver.py +284 -0
- digline/run/suite.py +225 -0
- digline/store/__init__.py +34 -0
- digline/store/file_store.py +263 -0
- digline/store/migrate.py +193 -0
- digline/store/protocol.py +150 -0
- digline/targets/__init__.py +26 -0
- digline/targets/pricing.py +116 -0
- digline/targets/provider.py +149 -0
- digline/targets/template.py +115 -0
- digline-0.1.0.dist-info/METADATA +254 -0
- digline-0.1.0.dist-info/RECORD +40 -0
- digline-0.1.0.dist-info/WHEEL +4 -0
- digline-0.1.0.dist-info/entry_points.txt +2 -0
- digline-0.1.0.dist-info/licenses/LICENSE +201 -0
digline/__init__.py
ADDED
digline/cli/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The command line. The last layer, and the only one that touches the world.
|
|
2
|
+
|
|
3
|
+
It reads the clock and asks git; everything below receives those as values.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from digline.cli.main import (
|
|
7
|
+
EXIT_OK,
|
|
8
|
+
EXIT_UNJUDGED,
|
|
9
|
+
EXIT_USAGE,
|
|
10
|
+
EXIT_WORSE,
|
|
11
|
+
OUTPUT_VERSION,
|
|
12
|
+
build_parser,
|
|
13
|
+
exit_code,
|
|
14
|
+
main,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"EXIT_OK",
|
|
19
|
+
"EXIT_UNJUDGED",
|
|
20
|
+
"EXIT_USAGE",
|
|
21
|
+
"EXIT_WORSE",
|
|
22
|
+
"OUTPUT_VERSION",
|
|
23
|
+
"build_parser",
|
|
24
|
+
"exit_code",
|
|
25
|
+
"main",
|
|
26
|
+
]
|
digline/cli/__main__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The clock and git.
|
|
2
|
+
|
|
3
|
+
**This is the only layer allowed to touch either.** The core takes `created_at`
|
|
4
|
+
as an argument and `git_commit` as data precisely so that a run stays
|
|
5
|
+
reproducible and every layer below stays testable without freezing time or
|
|
6
|
+
building a repository. Everything that reads the outside world lives here, in
|
|
7
|
+
one file, where it can be seen.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
__all__ = ["DIRTY_SUFFIX", "git_commit", "utc_now_iso"]
|
|
17
|
+
|
|
18
|
+
DIRTY_SUFFIX = "-dirty"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def utc_now_iso() -> str:
|
|
22
|
+
"""The clock, read once per command and passed down as a value.
|
|
23
|
+
|
|
24
|
+
Microseconds are kept. They were truncated at first, for readability, until
|
|
25
|
+
listing runs showed what that costs: a run's key is derived from its
|
|
26
|
+
timestamp and its configuration, so two runs in the same second with the
|
|
27
|
+
same suite produced the same key and the second silently replaced the first.
|
|
28
|
+
A fast suite against a stubbed target does that on an ordinary Tuesday.
|
|
29
|
+
|
|
30
|
+
A collision now needs two runs in the same microsecond, which takes a real
|
|
31
|
+
coincidence rather than a normal one.
|
|
32
|
+
"""
|
|
33
|
+
return datetime.now(UTC).isoformat()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
37
|
+
try:
|
|
38
|
+
done = subprocess.run(
|
|
39
|
+
["git", "-C", str(root), *args],
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
check=False,
|
|
43
|
+
)
|
|
44
|
+
except OSError:
|
|
45
|
+
return None # git is not installed; not an error, just no commit
|
|
46
|
+
return done.stdout.strip() if done.returncode == 0 else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def git_commit(root: Path) -> str | None:
|
|
50
|
+
"""The commit this run was produced from, or `None` outside a repository.
|
|
51
|
+
|
|
52
|
+
Being outside a repository is not an error: a run produced from a notebook
|
|
53
|
+
or a container legitimately has no commit, and refusing to work there would
|
|
54
|
+
make the tool unusable exactly where people try things first.
|
|
55
|
+
|
|
56
|
+
A dirty tree yields `"<sha>-dirty"`. The run is recorded, but the marker
|
|
57
|
+
travels with it, because such a run **cannot be reproduced from the
|
|
58
|
+
repository** — and a reader deciding whether to act on the numbers needs to
|
|
59
|
+
know that as a fact rather than as an assumption.
|
|
60
|
+
"""
|
|
61
|
+
sha = _git(root, "rev-parse", "HEAD")
|
|
62
|
+
if sha is None:
|
|
63
|
+
return None
|
|
64
|
+
status = _git(root, "status", "--porcelain")
|
|
65
|
+
return f"{sha}{DIRTY_SUFFIX}" if status else sha
|
digline/cli/loader.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Loading a suite and a target, which are Python objects rather than data.
|
|
2
|
+
|
|
3
|
+
A `Judge` is an object, a `Target` is a function, a `Disclosure` is declared in
|
|
4
|
+
code by construction (ADR 0002). None of that is expressible in YAML without
|
|
5
|
+
reinventing a language, and a configuration language grown one escape at a time
|
|
6
|
+
ends up unable to express its own delimiters.
|
|
7
|
+
|
|
8
|
+
So the CLI **imports** the suite; it does not interpret it. And it never goes
|
|
9
|
+
looking: a file found by convention is a file that runs by accident.
|
|
10
|
+
|
|
11
|
+
Two constraints on *how* it loads a suite given as a file path, both learned the
|
|
12
|
+
hard way and both non-negotiable:
|
|
13
|
+
|
|
14
|
+
- **The suite's directory goes on `sys.path`.** A suite imports the application
|
|
15
|
+
under test; that is the normal case. Only for a file path — a
|
|
16
|
+
`package.module:attr` spec is already importable and the path is left alone.
|
|
17
|
+
- **It is compiled from source, never through the bytecode cache.** Writing
|
|
18
|
+
`__pycache__` dirties the user's repository; reading it can run a stale suite.
|
|
19
|
+
- **So is everything that resolves in that directory**, through a finder scoped
|
|
20
|
+
to it alone — because the application under test is what changes between two
|
|
21
|
+
runs, and a stale copy of it hides the very regression the tool exists to
|
|
22
|
+
find.
|
|
23
|
+
|
|
24
|
+
See ADR 0002 §9 for all three.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import importlib
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
from importlib.machinery import (
|
|
33
|
+
EXTENSION_SUFFIXES,
|
|
34
|
+
SOURCE_SUFFIXES,
|
|
35
|
+
ExtensionFileLoader,
|
|
36
|
+
FileFinder,
|
|
37
|
+
SourceFileLoader,
|
|
38
|
+
)
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from types import CodeType, ModuleType
|
|
41
|
+
from typing import TYPE_CHECKING
|
|
42
|
+
|
|
43
|
+
from digline.run import Suite, Target
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from _typeshed import ReadableBuffer
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"SUITE_ATTR",
|
|
50
|
+
"TARGET_ATTR",
|
|
51
|
+
"SourceOnlyLoader",
|
|
52
|
+
"UsageError",
|
|
53
|
+
"load_suite",
|
|
54
|
+
"load_target",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
SUITE_ATTR = "suite"
|
|
58
|
+
TARGET_ATTR = "target"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SourceOnlyLoader(SourceFileLoader):
|
|
62
|
+
"""A loader that always compiles from source, ignoring `__pycache__`.
|
|
63
|
+
|
|
64
|
+
Used **only** for the directory holding the suite, which is where the
|
|
65
|
+
application under test lives. Everything else — the standard library, site
|
|
66
|
+
packages, the rest of the user's environment — keeps the normal machinery,
|
|
67
|
+
because bytecode caching there is a performance win with no correctness
|
|
68
|
+
cost: those modules do not change between two runs of an evaluation.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def get_code(self, fullname: str) -> CodeType:
|
|
72
|
+
path = self.get_filename(fullname)
|
|
73
|
+
return self.source_to_code(self.get_data(path), path) # type: ignore[return-value]
|
|
74
|
+
|
|
75
|
+
def set_data(self, path: str, data: ReadableBuffer, *, _mode: int = 0o666) -> None:
|
|
76
|
+
"""Never write bytecode: the artifact is what dirties the user's tree."""
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _finder(directory: str) -> FileFinder:
|
|
81
|
+
# Deliberately without `BYTECODE_SUFFIXES`: in this directory a lone `.pyc`
|
|
82
|
+
# must not be importable at all.
|
|
83
|
+
return FileFinder(
|
|
84
|
+
directory,
|
|
85
|
+
(SourceOnlyLoader, SOURCE_SUFFIXES),
|
|
86
|
+
(ExtensionFileLoader, EXTENSION_SUFFIXES),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_scoped: set[str] = set()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _scope_to_source(directory: str) -> None:
|
|
94
|
+
"""Make imports resolving in `directory` read from disk, always.
|
|
95
|
+
|
|
96
|
+
The reason, found by running the tool and reading the wrong answer: the
|
|
97
|
+
suite itself is compiled from source, but everything it *imports* went
|
|
98
|
+
through the normal machinery — so a helper module edited within the same
|
|
99
|
+
second and to the same length was served from a stale `.pyc`, and a
|
|
100
|
+
comparison reported that nothing had got worse when something had.
|
|
101
|
+
|
|
102
|
+
Of every defect met in this project this is the only kind that can hide a
|
|
103
|
+
regression, which is why it is worth a finder rather than a warning.
|
|
104
|
+
|
|
105
|
+
The scope is one directory on purpose. A loader for every module would slow
|
|
106
|
+
every import to protect files that do not change during an evaluation, and
|
|
107
|
+
would reach far outside anything this tool has a right to alter.
|
|
108
|
+
"""
|
|
109
|
+
real = os.path.realpath(directory)
|
|
110
|
+
if real in _scoped:
|
|
111
|
+
return
|
|
112
|
+
_scoped.add(real)
|
|
113
|
+
|
|
114
|
+
def hook(path: str) -> FileFinder:
|
|
115
|
+
if os.path.realpath(path) != real:
|
|
116
|
+
# Not ours: let the next hook — the normal one — answer.
|
|
117
|
+
raise ImportError(f"not the suite directory: {path}")
|
|
118
|
+
return _finder(path)
|
|
119
|
+
|
|
120
|
+
sys.path_hooks.insert(0, hook)
|
|
121
|
+
# The cache is consulted before the hooks, so the entry has to be replaced
|
|
122
|
+
# too — otherwise a finder built earlier would keep serving this directory.
|
|
123
|
+
sys.path_importer_cache[directory] = _finder(directory)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class UsageError(Exception):
|
|
127
|
+
"""Something the caller can fix by typing a different command."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _split(spec: str) -> tuple[str, str | None]:
|
|
131
|
+
"""`"pkg.mod:name"` or `"file.py:name"` into module part and attribute.
|
|
132
|
+
|
|
133
|
+
Only a trailing `:name` counts, and only when `name` is an identifier, so a
|
|
134
|
+
Windows path like `C:\\suites\\qa.py` is not mistaken for one.
|
|
135
|
+
"""
|
|
136
|
+
head, sep, tail = spec.rpartition(":")
|
|
137
|
+
if sep and tail.isidentifier():
|
|
138
|
+
return head, tail
|
|
139
|
+
return spec, None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _import(module_part: str, spec: str) -> ModuleType:
|
|
143
|
+
if module_part.endswith(".py") or "/" in module_part or "\\" in module_part:
|
|
144
|
+
path = Path(module_part).resolve()
|
|
145
|
+
if not path.is_file():
|
|
146
|
+
raise UsageError(f"no such file: {path} (from {spec!r})")
|
|
147
|
+
|
|
148
|
+
# Compiled from source rather than imported through the normal
|
|
149
|
+
# machinery, which reads and writes `__pycache__`. Two reasons, both
|
|
150
|
+
# found by running this against a real repository:
|
|
151
|
+
#
|
|
152
|
+
# 1. Writing bytecode leaves artifacts in the user's repository, and a
|
|
153
|
+
# repository with new untracked files is a *dirty* one — our own
|
|
154
|
+
# import would have made every run report itself unreproducible.
|
|
155
|
+
# 2. Reading bytecode can run a stale suite. Python's freshness check is
|
|
156
|
+
# (mtime, size) with one-second granularity, so a file edited within
|
|
157
|
+
# the same second and to the same length runs from cache. For a tool
|
|
158
|
+
# whose premise is reproducibility, "what is on disk" is the only
|
|
159
|
+
# acceptable answer.
|
|
160
|
+
name = f"_digline_suite_{path.stem}"
|
|
161
|
+
try:
|
|
162
|
+
code = compile(path.read_text(encoding="utf-8"), str(path), "exec")
|
|
163
|
+
except SyntaxError as exc:
|
|
164
|
+
raise UsageError(f"{path} does not parse: {exc}") from exc
|
|
165
|
+
|
|
166
|
+
# The suite's own directory goes on the path before it runs.
|
|
167
|
+
#
|
|
168
|
+
# A suite imports the application it evaluates — that is what a suite
|
|
169
|
+
# *is*, not an exceptional case — so `from brief import judge` has to
|
|
170
|
+
# resolve the way it would under `python suite.py` or under pytest from
|
|
171
|
+
# its rootdir. Without this the first real suite fails on its first line
|
|
172
|
+
# with ModuleNotFoundError, and the tool looks broken because it is.
|
|
173
|
+
#
|
|
174
|
+
# Guarded against duplicates: several suites in one directory, or one
|
|
175
|
+
# loaded twice, must not make `sys.path` grow.
|
|
176
|
+
directory = str(path.parent)
|
|
177
|
+
if directory not in sys.path:
|
|
178
|
+
sys.path.insert(0, directory)
|
|
179
|
+
# …and what resolves there is read from disk, never from bytecode.
|
|
180
|
+
_scope_to_source(directory)
|
|
181
|
+
|
|
182
|
+
module = ModuleType(name)
|
|
183
|
+
module.__file__ = str(path)
|
|
184
|
+
# Registered before execution so a module using dataclasses that refer
|
|
185
|
+
# to their own names resolves normally.
|
|
186
|
+
sys.modules[name] = module
|
|
187
|
+
exec(code, module.__dict__) # noqa: S102 — the user's own suite, by request
|
|
188
|
+
return module
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
return importlib.import_module(module_part)
|
|
192
|
+
except ImportError as exc:
|
|
193
|
+
raise UsageError(f"cannot import module {module_part!r}: {exc}") from exc
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _pick(module: ModuleType, attr: str, spec: str, kind: str) -> object:
|
|
197
|
+
value = getattr(module, attr, None)
|
|
198
|
+
if value is None:
|
|
199
|
+
available = ", ".join(sorted(n for n in vars(module) if not n.startswith("_")))
|
|
200
|
+
raise UsageError(
|
|
201
|
+
f"{spec!r} has no attribute {attr!r} to use as the {kind}. "
|
|
202
|
+
f"Define it, or name it explicitly as '{spec}:<attribute>'. "
|
|
203
|
+
f"Module defines: {available or '(nothing public)'}"
|
|
204
|
+
)
|
|
205
|
+
return value
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def load_suite(spec: str) -> tuple[Suite, ModuleType]:
|
|
209
|
+
"""The `Suite`, and the module it came from — the target defaults to the
|
|
210
|
+
same module, so the caller usually needs only one path."""
|
|
211
|
+
module_part, attr = _split(spec)
|
|
212
|
+
module = _import(module_part, spec)
|
|
213
|
+
value = _pick(module, attr or SUITE_ATTR, spec, "suite")
|
|
214
|
+
if not isinstance(value, Suite):
|
|
215
|
+
raise UsageError(f"{spec!r} gave a {type(value).__name__}, not a Suite")
|
|
216
|
+
return value, module
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def load_target(spec: str | None, suite_module: ModuleType, suite_spec: str) -> Target:
|
|
220
|
+
"""`--target` uses the same syntax as `--suite`. Omitted, it is looked up as
|
|
221
|
+
`target` in the suite's own module and nowhere else."""
|
|
222
|
+
if spec is None:
|
|
223
|
+
value = _pick(suite_module, TARGET_ATTR, suite_spec, "target")
|
|
224
|
+
else:
|
|
225
|
+
module_part, attr = _split(spec)
|
|
226
|
+
module = _import(module_part, spec)
|
|
227
|
+
value = _pick(module, attr or TARGET_ATTR, spec, "target")
|
|
228
|
+
if not callable(value):
|
|
229
|
+
raise UsageError(f"the target is a {type(value).__name__}, not callable")
|
|
230
|
+
return value # type: ignore[return-value]
|