bitexoft-verify 0.2.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.
- bitexoft_verify/__init__.py +3 -0
- bitexoft_verify/__main__.py +4 -0
- bitexoft_verify/checks/__init__.py +1 -0
- bitexoft_verify/checks/a1.py +396 -0
- bitexoft_verify/checks/common.py +300 -0
- bitexoft_verify/cli.py +120 -0
- bitexoft_verify/codes.py +68 -0
- bitexoft_verify/data/README.md +13 -0
- bitexoft_verify/data/a1_impossible_sample.csv +31 -0
- bitexoft_verify/data/a1_match_sample.csv +31 -0
- bitexoft_verify/data/a2_isolated_sample.csv +31 -0
- bitexoft_verify/data/issue-codes.json +57 -0
- bitexoft_verify/manifest.py +103 -0
- bitexoft_verify/report.py +234 -0
- bitexoft_verify/runner.py +101 -0
- bitexoft_verify-0.2.0.dist-info/METADATA +139 -0
- bitexoft_verify-0.2.0.dist-info/RECORD +19 -0
- bitexoft_verify-0.2.0.dist-info/WHEEL +4 -0
- bitexoft_verify-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The `bitexoft.toml` manifest.
|
|
2
|
+
|
|
3
|
+
This is what decouples the checks from the learner's code. Without it, verifying would
|
|
4
|
+
mean imposing package names and paths: "your package is called chaoslab and your domain
|
|
5
|
+
lives in chaoslab/domain". With it, everyone organises their project as they like and
|
|
6
|
+
declares where they put each thing; what is not negotiable is the SHAPE of the
|
|
7
|
+
boundaries, not their names.
|
|
8
|
+
|
|
9
|
+
[project]
|
|
10
|
+
id = "chaos-sports-lab"
|
|
11
|
+
package = "chaoslab"
|
|
12
|
+
|
|
13
|
+
[layers]
|
|
14
|
+
domain = "chaoslab.domain"
|
|
15
|
+
|
|
16
|
+
[cli]
|
|
17
|
+
module = "chaoslab" # run as `python -m chaoslab`
|
|
18
|
+
# main = "main.py" # or a script, if that is your entry point
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# tomllib joined the standard library in 3.11. Before that the same library is `tomli`,
|
|
27
|
+
# which is declared as a conditional dependency. The learner should not have to upgrade
|
|
28
|
+
# their interpreter to read a six-line configuration file.
|
|
29
|
+
try:
|
|
30
|
+
import tomllib
|
|
31
|
+
except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10
|
|
32
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
33
|
+
|
|
34
|
+
MANIFEST_NAME = "bitexoft.toml"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ManifestError(Exception):
|
|
38
|
+
"""The manifest is missing, is not valid TOML, or lacks required keys."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Manifest:
|
|
43
|
+
root: Path
|
|
44
|
+
data: dict
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def project_id(self) -> str:
|
|
48
|
+
return str(self.get("project.id", ""))
|
|
49
|
+
|
|
50
|
+
def get(self, dotted: str, default: object | None = None) -> object | None:
|
|
51
|
+
node: object = self.data
|
|
52
|
+
for part in dotted.split("."):
|
|
53
|
+
if not isinstance(node, dict) or part not in node:
|
|
54
|
+
return default
|
|
55
|
+
node = node[part]
|
|
56
|
+
return node
|
|
57
|
+
|
|
58
|
+
def require(self, dotted: str) -> str:
|
|
59
|
+
value = self.get(dotted)
|
|
60
|
+
if not isinstance(value, str) or value.strip() == "":
|
|
61
|
+
raise ManifestError(f"key '{dotted}' is missing from {MANIFEST_NAME}")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
def missing(self, keys: list[str]) -> list[str]:
|
|
65
|
+
return [k for k in keys if not isinstance(self.get(k), str) or str(self.get(k)).strip() == ""]
|
|
66
|
+
|
|
67
|
+
def layer(self, name: str) -> str | None:
|
|
68
|
+
value = self.get(f"layers.{name}")
|
|
69
|
+
return value if isinstance(value, str) else None
|
|
70
|
+
|
|
71
|
+
def layer_path(self, name: str) -> Path | None:
|
|
72
|
+
"""A layer's folder, resolved from its dotted module path."""
|
|
73
|
+
module = self.layer(name)
|
|
74
|
+
if not module:
|
|
75
|
+
return None
|
|
76
|
+
candidate = self.root / Path(*module.split("."))
|
|
77
|
+
return candidate if candidate.is_dir() else None
|
|
78
|
+
|
|
79
|
+
def resolve(self, reference: str) -> str:
|
|
80
|
+
"""Expands contract placeholders: `<layers.domain>.models` -> `chaoslab.domain.models`."""
|
|
81
|
+
out = reference
|
|
82
|
+
while "<" in out and ">" in out:
|
|
83
|
+
start, end = out.index("<"), out.index(">")
|
|
84
|
+
key = out[start + 1 : end]
|
|
85
|
+
value = self.get(key)
|
|
86
|
+
if not isinstance(value, str):
|
|
87
|
+
raise ManifestError(f"the contract uses '{key}' and the manifest does not declare it")
|
|
88
|
+
out = out[:start] + value + out[end + 1 :]
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load(root: Path) -> Manifest:
|
|
93
|
+
path = root / MANIFEST_NAME
|
|
94
|
+
if not path.is_file():
|
|
95
|
+
raise ManifestError(
|
|
96
|
+
f"{MANIFEST_NAME} not found in {root}. Create it at the repository root: "
|
|
97
|
+
"declare your package and where each layer lives."
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
101
|
+
except tomllib.TOMLDecodeError as exc:
|
|
102
|
+
raise ManifestError(f"{MANIFEST_NAME} is not valid TOML: {exc}") from exc
|
|
103
|
+
return Manifest(root=root, data=data)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""The report: the only thing that leaves this tool and the only thing the platform reads.
|
|
2
|
+
|
|
3
|
+
The same object is written locally and in CI. What changes between the two is
|
|
4
|
+
`provenance.surface`, and all the difference in authority follows from it:
|
|
5
|
+
|
|
6
|
+
local -> informative, for working with answers in seconds
|
|
7
|
+
ci -> official, run pinned and in a clean environment
|
|
8
|
+
|
|
9
|
+
The schema is versioned on purpose: the platform rejects what it does not recognise
|
|
10
|
+
instead of half-interpreting it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from numbers import Real
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
from .codes import ISSUE_CODES
|
|
25
|
+
|
|
26
|
+
# Still v3 (ADR 0028): `issues` is optional, a report without it is valid, and a copy of the
|
|
27
|
+
# platform that does not know the field ignores it.
|
|
28
|
+
SCHEMA = "bitexoft.verify.report/v3"
|
|
29
|
+
|
|
30
|
+
Status = Literal["pass", "fail", "skip"]
|
|
31
|
+
Surface = Literal["local", "ci"]
|
|
32
|
+
|
|
33
|
+
Scalar = str | int | float | bool
|
|
34
|
+
Param = Scalar | list
|
|
35
|
+
|
|
36
|
+
CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _plain(value: object, nested: bool = False) -> Param:
|
|
40
|
+
"""A value as plain JSON: text, a number, a boolean, or a flat list of those."""
|
|
41
|
+
if isinstance(value, str | bool | int | float):
|
|
42
|
+
return value
|
|
43
|
+
if isinstance(value, list | tuple) and not nested:
|
|
44
|
+
return [_plain(item, nested=True) for item in value]
|
|
45
|
+
if isinstance(value, Real):
|
|
46
|
+
return float(value)
|
|
47
|
+
return str(value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Issue:
|
|
52
|
+
"""One problem a check found, as data: a stable code and the values behind it.
|
|
53
|
+
|
|
54
|
+
The sentence a learner reads is not here. The platform writes it, in the learner's language,
|
|
55
|
+
from the code and the parameters (ADR 0027); the CLI writes an English one for the terminal.
|
|
56
|
+
A wrong code or a stray parameter is a bug in a check, so it raises.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
code: str
|
|
60
|
+
params: dict[str, Param] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
if self.code not in ISSUE_CODES:
|
|
64
|
+
raise ValueError(f"unknown issue code {self.code!r}: add it to bitexoft_verify/codes.py")
|
|
65
|
+
if not CODE_PATTERN.match(self.code):
|
|
66
|
+
raise ValueError(f"issue code {self.code!r} is not <area>.<kind> in lower case")
|
|
67
|
+
stray = set(self.params) - set(ISSUE_CODES[self.code])
|
|
68
|
+
if stray:
|
|
69
|
+
raise ValueError(f"issue {self.code!r} does not take {', '.join(sorted(stray))}")
|
|
70
|
+
# What the learner's code returns is not ours to reject: a value that is not plain JSON
|
|
71
|
+
# (a numpy float, an object) becomes a number or text, so describing a failure never
|
|
72
|
+
# becomes a second failure.
|
|
73
|
+
object.__setattr__(self, "params", {name: _plain(value) for name, value in self.params.items()})
|
|
74
|
+
|
|
75
|
+
def as_dict(self) -> dict:
|
|
76
|
+
return {"code": self.code, "params": dict(self.params)}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def issue(code: str, **params: Param) -> Issue:
|
|
80
|
+
return Issue(code, params)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class Check:
|
|
85
|
+
"""One concrete check. `message` explains a failure or justifies a skip.
|
|
86
|
+
|
|
87
|
+
`title` is always English: it is emitted by the tool, and the platform localises
|
|
88
|
+
known check ids, falling back to this text. `issues` says the same as `message` as data, one
|
|
89
|
+
entry per problem (ADR 0028); `message` stays, in English, for the terminal and as the fallback.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
id: str
|
|
93
|
+
title: str
|
|
94
|
+
status: Status
|
|
95
|
+
message: str | None = None
|
|
96
|
+
issues: tuple[Issue, ...] = ()
|
|
97
|
+
|
|
98
|
+
def as_dict(self) -> dict:
|
|
99
|
+
data: dict = {"id": self.id, "title": self.title, "status": self.status}
|
|
100
|
+
if self.message:
|
|
101
|
+
data["message"] = self.message
|
|
102
|
+
if self.issues:
|
|
103
|
+
data["issues"] = [item.as_dict() for item in self.issues]
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Problems:
|
|
108
|
+
"""What one check found wrong: the sentence for the terminal and the issues that say it as data.
|
|
109
|
+
|
|
110
|
+
Each entry keeps its sentence and its issues together, where the problem is found, so the
|
|
111
|
+
message and the issues cannot drift apart. `failing` builds the check with the same sentence the
|
|
112
|
+
check always produced.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self) -> None:
|
|
116
|
+
self._entries: list[tuple[str, tuple[Issue, ...]]] = []
|
|
117
|
+
|
|
118
|
+
def add(self, text: str, code: str, **params: Param) -> None:
|
|
119
|
+
self._entries.append((text, (Issue(code, params),)))
|
|
120
|
+
|
|
121
|
+
def add_group(self, text: str, issues: list[Issue]) -> None:
|
|
122
|
+
"""Several issues told in one sentence."""
|
|
123
|
+
self._entries.append((text, tuple(issues)))
|
|
124
|
+
|
|
125
|
+
def __bool__(self) -> bool:
|
|
126
|
+
return bool(self._entries)
|
|
127
|
+
|
|
128
|
+
def failing(self, check_id: str, title: str, separator: str = " | ", limit: int | None = None) -> Check:
|
|
129
|
+
entries = self._entries[:limit] if limit is not None else self._entries
|
|
130
|
+
return Check(
|
|
131
|
+
check_id,
|
|
132
|
+
title,
|
|
133
|
+
"fail",
|
|
134
|
+
separator.join(text for text, _ in entries),
|
|
135
|
+
tuple(item for _, issues in entries for item in issues),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(slots=True)
|
|
140
|
+
class Gate:
|
|
141
|
+
"""A group of checks. The gate fails if any of them fails."""
|
|
142
|
+
|
|
143
|
+
id: str
|
|
144
|
+
checks: list[Check] = field(default_factory=list)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def status(self) -> Status:
|
|
148
|
+
if any(check.status == "fail" for check in self.checks):
|
|
149
|
+
return "fail"
|
|
150
|
+
if self.checks and all(check.status == "skip" for check in self.checks):
|
|
151
|
+
return "skip"
|
|
152
|
+
return "pass"
|
|
153
|
+
|
|
154
|
+
def as_dict(self) -> dict:
|
|
155
|
+
return {"id": self.id, "status": self.status, "checks": [check.as_dict() for check in self.checks]}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def detect_surface(explicit: str | None = None) -> Surface:
|
|
159
|
+
"""CI detects itself: nobody has to remember a flag."""
|
|
160
|
+
if explicit in ("local", "ci"):
|
|
161
|
+
return explicit # type: ignore[return-value]
|
|
162
|
+
return "ci" if os.environ.get("GITHUB_ACTIONS") == "true" else "local"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def detect_provenance(surface: Surface) -> dict:
|
|
166
|
+
env = os.environ
|
|
167
|
+
repo = env.get("GITHUB_REPOSITORY")
|
|
168
|
+
run_id = env.get("GITHUB_RUN_ID")
|
|
169
|
+
server = env.get("GITHUB_SERVER_URL", "https://github.com")
|
|
170
|
+
return {
|
|
171
|
+
"surface": surface,
|
|
172
|
+
"repo": repo,
|
|
173
|
+
"commit": env.get("GITHUB_SHA"),
|
|
174
|
+
"run_url": f"{server}/{repo}/actions/runs/{run_id}" if repo and run_id else None,
|
|
175
|
+
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(slots=True)
|
|
180
|
+
class Report:
|
|
181
|
+
project: str
|
|
182
|
+
delivery: str
|
|
183
|
+
cli_version: str
|
|
184
|
+
gates: list[Gate]
|
|
185
|
+
surface: Surface = "local"
|
|
186
|
+
# Reserved for the graduated-help mechanism. `help_level` is the highest level of
|
|
187
|
+
# assistance taken for this delivery (0 = none) and `provided_files` lists files the
|
|
188
|
+
# learner did not write. They are declared now, and always emitted, so the platform
|
|
189
|
+
# never has to deal with two report shapes once help ships.
|
|
190
|
+
help_level: int = 0
|
|
191
|
+
provided_files: list[str] = field(default_factory=list)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def failed(self) -> int:
|
|
195
|
+
return sum(1 for gate in self.gates for check in gate.checks if check.status == "fail")
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def passed(self) -> int:
|
|
199
|
+
return sum(1 for gate in self.gates for check in gate.checks if check.status == "pass")
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def skipped(self) -> int:
|
|
203
|
+
return sum(1 for gate in self.gates for check in gate.checks if check.status == "skip")
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def total(self) -> int:
|
|
207
|
+
return sum(len(gate.checks) for gate in self.gates)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def ok(self) -> bool:
|
|
211
|
+
return self.failed == 0
|
|
212
|
+
|
|
213
|
+
def as_dict(self) -> dict:
|
|
214
|
+
return {
|
|
215
|
+
"schema": SCHEMA,
|
|
216
|
+
"cli_version": self.cli_version,
|
|
217
|
+
"project": self.project,
|
|
218
|
+
"delivery": self.delivery,
|
|
219
|
+
"provenance": detect_provenance(self.surface),
|
|
220
|
+
"autonomy": {"help_level": self.help_level, "provided_files": sorted(self.provided_files)},
|
|
221
|
+
"gates": [gate.as_dict() for gate in self.gates],
|
|
222
|
+
"summary": {
|
|
223
|
+
"status": "pass" if self.ok else "fail",
|
|
224
|
+
"passed": self.passed,
|
|
225
|
+
"failed": self.failed,
|
|
226
|
+
"skipped": self.skipped,
|
|
227
|
+
"total": self.total,
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
def write(self, path: Path) -> Path:
|
|
232
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
path.write_text(json.dumps(self.as_dict(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
234
|
+
return path
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Picks and runs the checks for a delivery.
|
|
2
|
+
|
|
3
|
+
Verification is CUMULATIVE: verifying A3 also runs A1 and A2. That is what makes the
|
|
4
|
+
method agile rather than merely incremental — every delivery leaves the whole project
|
|
5
|
+
standing, and a regression in old work surfaces while verifying the new.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .checks import a1
|
|
16
|
+
from .checks.common import Context
|
|
17
|
+
from .manifest import Manifest, load
|
|
18
|
+
from .report import Check, Gate, Report
|
|
19
|
+
|
|
20
|
+
# Deliveries in the order they are meant to be built. Adding one is adding a line here
|
|
21
|
+
# plus its module; nothing else in the tool needs to know.
|
|
22
|
+
DELIVERIES: dict[str, object] = {"A1": a1}
|
|
23
|
+
DELIVERY_ID = re.compile(r"^[A-G][0-9]{1,2}$")
|
|
24
|
+
|
|
25
|
+
# Deliveries whose checks are frozen (ADR 0009). Some of A1's checks are the shared ones in
|
|
26
|
+
# `checks/common.py`, which can describe a failure as structured issues (ADR 0028). A frozen
|
|
27
|
+
# delivery's results must not change, so they are emitted without `issues`: the `message` and
|
|
28
|
+
# the statuses are exactly what they always were, and the platform shows that message as it is.
|
|
29
|
+
# Taking A1 out of this set is a decision for a new ADR, not a tidy-up.
|
|
30
|
+
FROZEN_DELIVERIES = frozenset({"A1"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnknownDelivery(Exception):
|
|
34
|
+
"""That delivery has no published checks yet."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def available_deliveries() -> list[str]:
|
|
38
|
+
return list(DELIVERIES)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _prepare_imports(root: Path) -> None:
|
|
42
|
+
"""The learner's code wins: their root goes first on sys.path."""
|
|
43
|
+
entry = str(root.resolve())
|
|
44
|
+
if entry in sys.path:
|
|
45
|
+
sys.path.remove(entry)
|
|
46
|
+
sys.path.insert(0, entry)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _retag(check: Check, delivery_id: str) -> Check:
|
|
50
|
+
"""Marks an inherited check so a regression says which delivery it came from."""
|
|
51
|
+
return Check(
|
|
52
|
+
id=f"{delivery_id.lower()}_{check.id}",
|
|
53
|
+
title=f"[{delivery_id}] {check.title}",
|
|
54
|
+
status=check.status,
|
|
55
|
+
message=check.message,
|
|
56
|
+
issues=check.issues,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run(
|
|
61
|
+
root: Path,
|
|
62
|
+
delivery_id: str,
|
|
63
|
+
*,
|
|
64
|
+
cli_version: str,
|
|
65
|
+
surface: str = "local",
|
|
66
|
+
cumulative: bool = True,
|
|
67
|
+
manifest: Manifest | None = None,
|
|
68
|
+
timeout: int = 180,
|
|
69
|
+
) -> Report:
|
|
70
|
+
if not DELIVERY_ID.match(delivery_id):
|
|
71
|
+
raise UnknownDelivery(f"'{delivery_id}' is not a delivery identifier, such as A1")
|
|
72
|
+
if delivery_id not in DELIVERIES:
|
|
73
|
+
raise UnknownDelivery(
|
|
74
|
+
f"delivery {delivery_id} has no published checks yet "
|
|
75
|
+
f"(available: {', '.join(available_deliveries())}). Update bitexoft-verify."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
manifest = manifest or load(root)
|
|
79
|
+
_prepare_imports(root)
|
|
80
|
+
ctx = Context(root=root, manifest=manifest, timeout=timeout)
|
|
81
|
+
|
|
82
|
+
order = available_deliveries()
|
|
83
|
+
wanted = order[: order.index(delivery_id) + 1] if cumulative else [delivery_id]
|
|
84
|
+
|
|
85
|
+
merged: dict[str, Gate] = {}
|
|
86
|
+
for current in wanted:
|
|
87
|
+
module = DELIVERIES[current]
|
|
88
|
+
for gate in module.gates(ctx): # type: ignore[attr-defined]
|
|
89
|
+
target = merged.setdefault(gate.id, Gate(gate.id))
|
|
90
|
+
for check in gate.checks:
|
|
91
|
+
if current in FROZEN_DELIVERIES and check.issues:
|
|
92
|
+
check = replace(check, issues=())
|
|
93
|
+
target.checks.append(check if current == delivery_id else _retag(check, current))
|
|
94
|
+
|
|
95
|
+
return Report(
|
|
96
|
+
project=manifest.project_id or "unknown",
|
|
97
|
+
delivery=delivery_id,
|
|
98
|
+
cli_version=cli_version,
|
|
99
|
+
gates=[merged[key] for key in ("acceptance", "design", "evidence") if key in merged],
|
|
100
|
+
surface=surface, # type: ignore[arg-type]
|
|
101
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bitexoft-verify
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Verifica las entregas de los proyectos de Bitexoft: la misma herramienta en tu máquina y en CI.
|
|
5
|
+
Author: Bitexoft
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Keywords: ai,api,education,python,verification
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Education :: Testing
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: tomli>=2.0.1; python_version < '3.11'
|
|
16
|
+
Provides-Extra: api
|
|
17
|
+
Requires-Dist: httpx>=0.27; extra == 'api'
|
|
18
|
+
Requires-Dist: pyyaml>=6.0; extra == 'api'
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# bitexoft-verify
|
|
25
|
+
|
|
26
|
+
The platform's only definition of "correct". It runs in two places and decides the same
|
|
27
|
+
thing in both:
|
|
28
|
+
|
|
29
|
+
| Where | When | What it is worth |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| Your machine | while you work, answers in seconds | informative |
|
|
32
|
+
| GitHub Actions | on every push, pinned, clean environment | **official** |
|
|
33
|
+
|
|
34
|
+
The difference shows up as `provenance.surface` in the report. The platform marks a
|
|
35
|
+
delivery as verified only on a green CI report.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
Python 3.10 or newer — the version Ubuntu 22.04 LTS ships, so a stock Linux works without
|
|
40
|
+
upgrading anything.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install bitexoft-verify # core; one small dependency below 3.11
|
|
44
|
+
pip install 'bitexoft-verify[api]' # adds what later deliveries need to verify APIs
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If pip answers `requires a different Python`, check your version with `python3 -V`. The
|
|
48
|
+
project itself targets a newer Python than the tool does: each delivery's contract states
|
|
49
|
+
what it needs.
|
|
50
|
+
|
|
51
|
+
## Use
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bitexoft-verify check A1 # verify A1
|
|
55
|
+
bitexoft-verify check A3 # verify A3 and, on the way, A1 and A2
|
|
56
|
+
bitexoft-verify check A3 --only # just A3
|
|
57
|
+
bitexoft-verify deliveries # what this version can verify
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Exit codes: `0` all green, `1` something fails, `2` could not even start.
|
|
61
|
+
|
|
62
|
+
## `bitexoft.toml`
|
|
63
|
+
|
|
64
|
+
Organise your project however you like. You declare it here and verification reads it
|
|
65
|
+
from here; what is not negotiable is the **shape** of the boundaries, not their names.
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
[project]
|
|
69
|
+
id = "chaos-sports-lab"
|
|
70
|
+
package = "chaoslab"
|
|
71
|
+
|
|
72
|
+
[layers]
|
|
73
|
+
domain = "chaoslab.domain"
|
|
74
|
+
|
|
75
|
+
[cli]
|
|
76
|
+
module = "chaoslab" # run as `python -m chaoslab`
|
|
77
|
+
# main = "main.py" # or a script, if that is your entry point
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## The three gates
|
|
81
|
+
|
|
82
|
+
- **Acceptance** — that it works: the contract's symbols, impossible objects refused,
|
|
83
|
+
value semantics, the geometry, the entry point and its exit codes.
|
|
84
|
+
- **Design** — that it is built well: the domain's boundary read from the syntax tree,
|
|
85
|
+
your tests passing, at least one test that builds an impossible object.
|
|
86
|
+
- **Evidence** — that you understand it. The CLI **does not judge** this gate: it always
|
|
87
|
+
reports `skip` with a message. You close it on the platform by writing the why and
|
|
88
|
+
comparing it against the rubric.
|
|
89
|
+
|
|
90
|
+
A check never fails for something you cannot fix: if an optional dependency or a git tag
|
|
91
|
+
is missing, it **skips** with a message saying what to do.
|
|
92
|
+
|
|
93
|
+
## Cumulative verification
|
|
94
|
+
|
|
95
|
+
Verifying A3 also runs A1 and A2, with inherited checks tagged `[A1]`. That is what makes
|
|
96
|
+
the method agile: every delivery leaves the whole project standing, and a regression in
|
|
97
|
+
old work shows up while verifying the new.
|
|
98
|
+
|
|
99
|
+
## The sample files
|
|
100
|
+
|
|
101
|
+
Deliveries are verified against files the CLI ships with, not against one you wrote. For
|
|
102
|
+
A1 that is 30 instants crossing all three block states, plus a copy with one coordinate
|
|
103
|
+
off the pitch to check that bad data fails cleanly instead of printing a traceback at the
|
|
104
|
+
user. They are synthetic: no third-party data is distributed here.
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pip install -e '.[dev,api]'
|
|
110
|
+
pytest # builds a fake learner repository and verifies it
|
|
111
|
+
ruff check .
|
|
112
|
+
python tools/make_fixtures.py # regenerate the sample files
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Before changing what a check says when it fails, or the layout of the output, read
|
|
116
|
+
[`docs/foundations/03-feedback-self-explanation-calibration.md`](../docs/foundations/03-feedback-self-explanation-calibration.md)
|
|
117
|
+
(the verifier's messages are the feedback a learner reads) and ADR 0002. The check ids and the report
|
|
118
|
+
schema are a contract with the platform: do not rename them.
|
|
119
|
+
|
|
120
|
+
## Releasing
|
|
121
|
+
|
|
122
|
+
The version is written once, in `__version__` (`src/bitexoft_verify/__init__.py`). The pin in
|
|
123
|
+
`.github/workflows/verify.yml` (`cli-version`) must match its `major.minor`; a test checks it.
|
|
124
|
+
|
|
125
|
+
First release, by hand on pypi.org:
|
|
126
|
+
|
|
127
|
+
1. Account → Publishing → *Add a pending publisher*: project `bitexoft-verify`, owner `bitexoft`,
|
|
128
|
+
repository `sports-lab-v1`, workflow `release-verify.yml`, environment `pypi`.
|
|
129
|
+
2. In the repository settings, create the environment `pypi`.
|
|
130
|
+
|
|
131
|
+
Each release:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
git tag verify-v0.2.0 && git push origin verify-v0.2.0
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The workflow runs `pytest` on 3.10 and 3.12, refuses a tag that differs from `__version__`, builds
|
|
138
|
+
with `python -m build` and publishes with trusted publishing (no tokens). Check it in a clean
|
|
139
|
+
environment: `pipx run --spec bitexoft-verify bitexoft-verify deliveries`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
bitexoft_verify/__init__.py,sha256=ztmdVEmKeQyrOoF-IvxGkoN9Wr8QpwVhaEXHfiJFKvU,91
|
|
2
|
+
bitexoft_verify/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
|
|
3
|
+
bitexoft_verify/cli.py,sha256=U5UkgX5gdnRv_J6otLKFNWESzuG7GRG3EqRIJCnbDkA,4609
|
|
4
|
+
bitexoft_verify/codes.py,sha256=mZkelfk8SAxKVxgFEG56Xbhpnn7Sx9ZJF8zGLb9qOVI,2653
|
|
5
|
+
bitexoft_verify/manifest.py,sha256=Rcdi-FBh6-2wxYCkOCyr5SdUW9QFm9Mgt0mnALCtJXM,3728
|
|
6
|
+
bitexoft_verify/report.py,sha256=XkEu0vOwFDZ4tmi4X5kYXmH9v5o9VojulPixpQa-6CI,8538
|
|
7
|
+
bitexoft_verify/runner.py,sha256=9FPLOBeD3phIsxT5mCA1pDWrozADB8UdAoBiaBEa0CM,3612
|
|
8
|
+
bitexoft_verify/checks/__init__.py,sha256=zluliHovi0N9aMMRr6sXhhYonMcLT6UX3Tr6q6v8N-0,39
|
|
9
|
+
bitexoft_verify/checks/a1.py,sha256=ooeSykNKXQCZ-8gcvh4z9yzbPZZdkOTh68UAXsdVwZQ,15716
|
|
10
|
+
bitexoft_verify/checks/common.py,sha256=mDo-LKMldGjmYMvFR_GWcNwO5FgothVh2YjG65sc_aM,10779
|
|
11
|
+
bitexoft_verify/data/README.md,sha256=FBoTovLSIQ6IKhPOtz5iUFbs-QGFed1BwFbMh4inuX8,706
|
|
12
|
+
bitexoft_verify/data/a1_impossible_sample.csv,sha256=kvnTYoEnt82ZFwMUmGd1UOr6qMMsJ8sRHHH40CpDvk4,5298
|
|
13
|
+
bitexoft_verify/data/a1_match_sample.csv,sha256=8NEKo2gvY_Z12-Q1KBdKnSpRV2SPXOOE58VfAccVH74,5298
|
|
14
|
+
bitexoft_verify/data/a2_isolated_sample.csv,sha256=O5iGlwlS1tlmym1axT7PRtuu-QEWvJzWd2BtbEFvps0,5310
|
|
15
|
+
bitexoft_verify/data/issue-codes.json,sha256=Cv77fIn9nfKvzvPh7tiG9NiRyRYDUpLwHq_UCjpnNyI,1002
|
|
16
|
+
bitexoft_verify-0.2.0.dist-info/METADATA,sha256=vPXFttEZ03Oh4PC0p_D-l-wJSzThQqj1ChNiq7oMlm8,5336
|
|
17
|
+
bitexoft_verify-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
18
|
+
bitexoft_verify-0.2.0.dist-info/entry_points.txt,sha256=MI-OtsbZc0x0PEh3SbFEr87zUEs01vwuENgDX5Tkvas,61
|
|
19
|
+
bitexoft_verify-0.2.0.dist-info/RECORD,,
|