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 ADDED
@@ -0,0 +1,7 @@
1
+ """digline — a Python-native evaluation engine for LLM output.
2
+
3
+ The core (`digline.core`) is pure and callable on its own. Upper layers
4
+ depend on it, never the other way round.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -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
+ ]
@@ -0,0 +1,5 @@
1
+ """`python -m digline.cli`, which is also how the tests drive it."""
2
+
3
+ from digline.cli.main import main
4
+
5
+ raise SystemExit(main())
@@ -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]