ois-validator 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.
- ois_validator/__init__.py +6 -0
- ois_validator/__main__.py +5 -0
- ois_validator/cli.py +59 -0
- ois_validator/digests.py +28 -0
- ois_validator/document.py +107 -0
- ois_validator/junit.py +273 -0
- ois_validator/junit_cli.py +76 -0
- ois_validator/report.py +30 -0
- ois_validator/rules.py +403 -0
- ois_validator/schema.py +128 -0
- ois_validator/schemas/ois-0.1.schema.json +328 -0
- ois_validator/validator.py +17 -0
- ois_validator-0.1.0.dist-info/METADATA +128 -0
- ois_validator-0.1.0.dist-info/RECORD +17 -0
- ois_validator-0.1.0.dist-info/WHEEL +4 -0
- ois_validator-0.1.0.dist-info/entry_points.txt +4 -0
- ois_validator-0.1.0.dist-info/licenses/LICENSE +202 -0
ois_validator/cli.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Command line interface: ois-validator FILE..."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ois_validator.report import Level, Report, Result
|
|
9
|
+
from ois_validator.validator import validate
|
|
10
|
+
|
|
11
|
+
EXIT_CONFORMS = 0
|
|
12
|
+
EXIT_VIOLATION = 1
|
|
13
|
+
EXIT_USAGE = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="ois-validator",
|
|
19
|
+
description="Validate OIS records, bundles and Quality Passports.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("files", nargs="+", metavar="FILE", help="JSON file, or - for stdin")
|
|
22
|
+
parser.add_argument("--format", choices=("text", "json"), default="text")
|
|
23
|
+
args = parser.parse_args(argv)
|
|
24
|
+
|
|
25
|
+
results: dict[str, Result] = {}
|
|
26
|
+
for name in args.files:
|
|
27
|
+
try:
|
|
28
|
+
text = sys.stdin.read() if name == "-" else Path(name).read_text(encoding="utf-8")
|
|
29
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
30
|
+
print(f"ois-validator: {name}: {error}", file=sys.stderr)
|
|
31
|
+
return EXIT_USAGE
|
|
32
|
+
results[name] = _validate_text(text)
|
|
33
|
+
|
|
34
|
+
if args.format == "json":
|
|
35
|
+
out = [
|
|
36
|
+
{"file": name, "conforms": r.conforms, "reports": [x.as_dict() for x in r.reports]}
|
|
37
|
+
for name, r in results.items()
|
|
38
|
+
]
|
|
39
|
+
print(json.dumps(out, indent=2))
|
|
40
|
+
else:
|
|
41
|
+
for name, result in results.items():
|
|
42
|
+
if not result.reports:
|
|
43
|
+
print(f"{name}: conforms")
|
|
44
|
+
for report in result.reports:
|
|
45
|
+
print(f"{name}: {report.level} [{report.rule}] {report.path}: {report.message}")
|
|
46
|
+
|
|
47
|
+
return EXIT_CONFORMS if all(r.conforms for r in results.values()) else EXIT_VIOLATION
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_text(text: str) -> Result:
|
|
51
|
+
try:
|
|
52
|
+
document = json.loads(text)
|
|
53
|
+
except json.JSONDecodeError as error:
|
|
54
|
+
return Result([Report(Level.VIOLATION, "json", f"not valid JSON: {error}", "$")])
|
|
55
|
+
return validate(document)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
sys.exit(main())
|
ois_validator/digests.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Digests of criteria and intents (spec section 6.4), over their RFC 8785 canonical form."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import rfc8785
|
|
7
|
+
|
|
8
|
+
Digest = dict[str, str] # in-toto DigestSet: algorithm -> lowercase hex
|
|
9
|
+
|
|
10
|
+
CRITERION_FIELDS = ("key", "statement", "notation", "kind", "target", "language")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def criterion_sha256(criterion: dict[str, Any]) -> str:
|
|
14
|
+
"""Over the criterion's own key, statement, notation, kind, target and language."""
|
|
15
|
+
fields = {k: criterion[k] for k in CRITERION_FIELDS if k in criterion}
|
|
16
|
+
return hashlib.sha256(rfc8785.dumps(fields)).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def intent_sha256(intent: dict[str, Any]) -> str:
|
|
20
|
+
"""Over the intent without its top-level digest and status, criterion digests included."""
|
|
21
|
+
content = {k: v for k, v in intent.items() if k not in ("digest", "status")}
|
|
22
|
+
return hashlib.sha256(rfc8785.dumps(content)).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def equal(a: Digest, b: Digest) -> bool:
|
|
26
|
+
"""Two digest sets name the same content: they share an algorithm and agree on each."""
|
|
27
|
+
shared = a.keys() & b.keys()
|
|
28
|
+
return bool(shared) and all(a[name] == b[name] for name in shared)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The records of a bundle or passport and how references between them resolve.
|
|
2
|
+
|
|
3
|
+
Resolution follows the conformance fixtures (fixtures/README.md in the specification repository):
|
|
4
|
+
`<id>@<version>` resolves to the intent or actor with that id and version, `<id>` to a record
|
|
5
|
+
with that id and no version, and `<id>@<version>#<key>` to the intent version (the key is R2's
|
|
6
|
+
concern). Only Ref-typed fields are references.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ois_validator.schema import STATEMENT_TYPE
|
|
14
|
+
|
|
15
|
+
Record = dict[str, Any]
|
|
16
|
+
|
|
17
|
+
REFERENCE_FIELDS = {
|
|
18
|
+
"intent": ("producedBy", "supersedes"),
|
|
19
|
+
"artifact": ("producedBy", "inputs", "context", "contextRecordedBy"),
|
|
20
|
+
"actor": (),
|
|
21
|
+
"evidence": ("criterion", "test", "subject", "inputs", "recordedBy"),
|
|
22
|
+
"decision": ("subject", "decidedBy", "evidence"),
|
|
23
|
+
"finding": ("criterion", "intent", "subject", "reportedBy", "resolvedBy", "supersedes"),
|
|
24
|
+
}
|
|
25
|
+
PASSPORT_REFERENCE_FIELDS = ("intents", "scope", "issuedBy")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Entry:
|
|
30
|
+
record: Record
|
|
31
|
+
path: str # JSONPath of the record in the input document
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Reference:
|
|
36
|
+
value: str
|
|
37
|
+
path: str # JSONPath of the reference in the input document
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Document:
|
|
42
|
+
"""The records of a single record, a bundle or a passport, after the schema check passed."""
|
|
43
|
+
|
|
44
|
+
entries: list[Entry]
|
|
45
|
+
container: bool = True # False for a single record: no rules across records apply
|
|
46
|
+
passport: Record | None = None # the predicate, for passports
|
|
47
|
+
subjects: list[dict[str, str]] = field(default_factory=list) # statement subject digests
|
|
48
|
+
_by_id: dict[str, list[Entry]] = field(default_factory=dict, repr=False)
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
for entry in self.entries:
|
|
52
|
+
self._by_id.setdefault(entry.record["id"], []).append(entry)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def of(cls, document: Record) -> "Document":
|
|
56
|
+
if document.get("_type") == STATEMENT_TYPE:
|
|
57
|
+
predicate: Record = document["predicate"]
|
|
58
|
+
# Appendix B covers only the predicate, so the statement's subject is read defensively.
|
|
59
|
+
subject = document.get("subject")
|
|
60
|
+
subjects = [
|
|
61
|
+
s["digest"]
|
|
62
|
+
for s in (subject if isinstance(subject, list) else [])
|
|
63
|
+
if isinstance(s, dict) and isinstance(s.get("digest"), dict)
|
|
64
|
+
]
|
|
65
|
+
entries = _entries(predicate["records"], "$.predicate.records")
|
|
66
|
+
return cls(entries, passport=predicate, subjects=subjects)
|
|
67
|
+
if document["type"] == "bundle":
|
|
68
|
+
return cls(_entries(document["records"], "$.records"))
|
|
69
|
+
return cls([Entry(document, "$")], container=False)
|
|
70
|
+
|
|
71
|
+
def resolve(self, reference: str) -> list[Entry]:
|
|
72
|
+
"""Every record a reference names; more than one only for records in several states."""
|
|
73
|
+
base, _, rest = reference.partition("@")
|
|
74
|
+
version = rest.partition("#")[0]
|
|
75
|
+
candidates = self._by_id.get(base, [])
|
|
76
|
+
if version:
|
|
77
|
+
return [e for e in candidates if str(e.record.get("version")) == version]
|
|
78
|
+
return [e for e in candidates if "version" not in e.record]
|
|
79
|
+
|
|
80
|
+
def record(self, reference: str) -> Record | None:
|
|
81
|
+
"""The record a reference names, or None if it does not resolve."""
|
|
82
|
+
found = self.resolve(reference)
|
|
83
|
+
return found[0].record if found else None
|
|
84
|
+
|
|
85
|
+
def of_type(self, kind: str) -> Iterator[Entry]:
|
|
86
|
+
return (entry for entry in self.entries if entry.record["type"] == kind)
|
|
87
|
+
|
|
88
|
+
def references(self) -> Iterator[Reference]:
|
|
89
|
+
"""Every reference in the document, in document order."""
|
|
90
|
+
for entry in self.entries:
|
|
91
|
+
yield from _references(entry.record, REFERENCE_FIELDS[entry.record["type"]], entry.path)
|
|
92
|
+
if self.passport is not None:
|
|
93
|
+
yield from _references(self.passport, PASSPORT_REFERENCE_FIELDS, "$.predicate")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _entries(records: list[Record], path: str) -> list[Entry]:
|
|
97
|
+
return [Entry(record, f"{path}[{index}]") for index, record in enumerate(records)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _references(record: Record, fields: tuple[str, ...], path: str) -> Iterator[Reference]:
|
|
101
|
+
for name in fields:
|
|
102
|
+
value = record.get(name)
|
|
103
|
+
if isinstance(value, str):
|
|
104
|
+
yield Reference(value, f"{path}.{name}")
|
|
105
|
+
elif isinstance(value, list):
|
|
106
|
+
for index, item in enumerate(value):
|
|
107
|
+
yield Reference(item, f"{path}.{name}[{index}]")
|
ois_validator/junit.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Level 1 adapter: turn JUnit XML reports into an OIS bundle (spec section 11.4).
|
|
2
|
+
|
|
3
|
+
The bundle holds what the quick start asks for: an actor for this adapter, an actor for the
|
|
4
|
+
existing test suite, one characterization intent in draft with a criterion per test case, an
|
|
5
|
+
artifact per test file and an evidence record per test result with derivedFrom unknown.
|
|
6
|
+
|
|
7
|
+
JUnit XML comes from the producer's own pipeline. It is parsed with the standard library, whose
|
|
8
|
+
Expat (2.4.1 and later) refuses entity expansion attacks and does not load external entities.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import xml.etree.ElementTree as ElementTree
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from importlib.metadata import version as package_version
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
21
|
+
|
|
22
|
+
Record = dict[str, Any]
|
|
23
|
+
|
|
24
|
+
RESULTS = ("pass", "skipped", "fail", "error") # from best to worst
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class JUnitReport:
|
|
29
|
+
content: bytes
|
|
30
|
+
written_at: datetime | None = None # file modification time, for timestamps without offset
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Options:
|
|
35
|
+
namespace: str # URI under the producer's domain, e.g. https://ois.acme.example
|
|
36
|
+
suite: str # team or suite name; the test suite actor is named after it
|
|
37
|
+
repo: Path # Git working tree that holds the test files
|
|
38
|
+
location: str | None = None # defaults to git+<origin>@<HEAD>
|
|
39
|
+
run: str | None = None # link to the pipeline run
|
|
40
|
+
now: datetime | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class TestCase:
|
|
45
|
+
classname: str
|
|
46
|
+
name: str
|
|
47
|
+
file: str | None
|
|
48
|
+
result: str
|
|
49
|
+
observed_at: datetime
|
|
50
|
+
report: str # sha256 of the report the case comes from
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_bundle(reports: list[JUnitReport], options: Options) -> Record:
|
|
54
|
+
"""Build the bundle; the caller validates it before passing it on."""
|
|
55
|
+
now = (options.now or datetime.now(UTC)).astimezone(UTC)
|
|
56
|
+
ns = options.namespace.rstrip("/")
|
|
57
|
+
commit = _git(options.repo, "rev-parse", "HEAD")
|
|
58
|
+
location = options.location or location_of(options.repo)
|
|
59
|
+
tracked = [path for path in _git(options.repo, "ls-files", "-z").split("\0") if path]
|
|
60
|
+
cases = _dedupe([case for report in reports for case in _cases(report, now)])
|
|
61
|
+
|
|
62
|
+
adapter = _record(
|
|
63
|
+
"actor",
|
|
64
|
+
f"{ns}/actors/ois-junit",
|
|
65
|
+
now,
|
|
66
|
+
kind="tool",
|
|
67
|
+
name="ois-junit (JUnit XML adapter)",
|
|
68
|
+
version=package_version("ois-validator"),
|
|
69
|
+
)
|
|
70
|
+
suite = _record(
|
|
71
|
+
"actor",
|
|
72
|
+
f"{ns}/actors/{_slug(options.suite)}-tests",
|
|
73
|
+
now,
|
|
74
|
+
kind="tool",
|
|
75
|
+
name=f"{options.suite} test suite",
|
|
76
|
+
version="unknown",
|
|
77
|
+
)
|
|
78
|
+
adapter_ref = f"{adapter['id']}@{adapter['version']}"
|
|
79
|
+
suite_ref = f"{suite['id']}@{suite['version']}"
|
|
80
|
+
|
|
81
|
+
intent = _record(
|
|
82
|
+
"intent",
|
|
83
|
+
f"{ns}/intents/{_slug(options.suite)}-characterization",
|
|
84
|
+
now,
|
|
85
|
+
version=1,
|
|
86
|
+
title=f"Characterization of the {options.suite} test suite",
|
|
87
|
+
status="draft",
|
|
88
|
+
producedBy=adapter_ref,
|
|
89
|
+
criteria=[
|
|
90
|
+
{"key": _key(c), "statement": _statement(c), "notation": "text", "kind": "functional"}
|
|
91
|
+
for c in cases
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
artifacts: dict[str, Record] = {}
|
|
96
|
+
evidence = []
|
|
97
|
+
for case in cases:
|
|
98
|
+
path = _test_file(tracked, case)
|
|
99
|
+
artifact = _artifact(ns, options.repo, commit, location, path, case, suite_ref, now)
|
|
100
|
+
artifacts.setdefault(artifact["id"], artifact)
|
|
101
|
+
record = _record(
|
|
102
|
+
"evidence",
|
|
103
|
+
f"{ns}/evidence/{case.report[:12]}-{_key(case)}",
|
|
104
|
+
now,
|
|
105
|
+
criterion=f"{intent['id']}@1#{_key(case)}",
|
|
106
|
+
test=artifact["id"],
|
|
107
|
+
derivedFrom="unknown",
|
|
108
|
+
result=case.result,
|
|
109
|
+
observedAt=_timestamp(case.observed_at),
|
|
110
|
+
recordedBy=adapter_ref,
|
|
111
|
+
)
|
|
112
|
+
if options.run:
|
|
113
|
+
record["run"] = options.run
|
|
114
|
+
evidence.append(record)
|
|
115
|
+
|
|
116
|
+
combined = hashlib.sha256("".join(c.report for c in cases).encode()).hexdigest()
|
|
117
|
+
records = [adapter, suite, intent, *artifacts.values(), *evidence]
|
|
118
|
+
return _record("bundle", f"{ns}/bundles/junit-{combined[:16]}", now, records=records)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def location_of(repo: Path) -> str:
|
|
122
|
+
"""git+<origin URL>@<HEAD commit>, without credentials; scp-like remotes become ssh URIs."""
|
|
123
|
+
remote = _git(repo, "remote", "get-url", "origin")
|
|
124
|
+
commit = _git(repo, "rev-parse", "HEAD")
|
|
125
|
+
if "://" not in remote and (scp := re.fullmatch(r"(?:[^@/]+@)?([^:/]+):(.+)", remote)):
|
|
126
|
+
remote = f"ssh://{scp[1]}/{scp[2]}"
|
|
127
|
+
parts = urlsplit(remote)
|
|
128
|
+
if parts.scheme not in ("https", "http", "ssh", "git") or not parts.hostname:
|
|
129
|
+
raise ValueError(f"cannot turn remote {remote!r} into a location; pass --location")
|
|
130
|
+
host = parts.hostname + (f":{parts.port}" if parts.port else "")
|
|
131
|
+
return f"git+{urlunsplit((parts.scheme, host, parts.path, '', ''))}@{commit}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cases(report: JUnitReport, now: datetime) -> list[TestCase]:
|
|
135
|
+
root = ElementTree.fromstring(report.content)
|
|
136
|
+
digest = hashlib.sha256(report.content).hexdigest()
|
|
137
|
+
cases = []
|
|
138
|
+
for suite in root.iter("testsuite"):
|
|
139
|
+
observed_at = _observed_at(suite.get("timestamp"), report.written_at or now)
|
|
140
|
+
for case in suite.findall("testcase"):
|
|
141
|
+
cases.append(
|
|
142
|
+
TestCase(
|
|
143
|
+
classname=case.get("classname", ""),
|
|
144
|
+
name=case.get("name", ""),
|
|
145
|
+
file=case.get("file"),
|
|
146
|
+
result=_result(case),
|
|
147
|
+
observed_at=observed_at,
|
|
148
|
+
report=digest,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return cases
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _result(case: ElementTree.Element) -> str:
|
|
155
|
+
if case.find("error") is not None:
|
|
156
|
+
return "error"
|
|
157
|
+
if case.find("failure") is not None:
|
|
158
|
+
return "fail"
|
|
159
|
+
if case.find("skipped") is not None:
|
|
160
|
+
return "skipped"
|
|
161
|
+
return "pass"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _dedupe(cases: list[TestCase]) -> list[TestCase]:
|
|
165
|
+
"""One case per test; a test that ran more than once keeps its worst result."""
|
|
166
|
+
worst: dict[str, TestCase] = {}
|
|
167
|
+
for case in cases:
|
|
168
|
+
known = worst.get(_key(case))
|
|
169
|
+
if known is None or RESULTS.index(case.result) > RESULTS.index(known.result):
|
|
170
|
+
worst[_key(case)] = case
|
|
171
|
+
return list(worst.values())
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _observed_at(timestamp: str | None, fallback: datetime) -> datetime:
|
|
175
|
+
"""The suite's timestamp if it names an offset; JUnit often omits it, so otherwise the time
|
|
176
|
+
the report was written."""
|
|
177
|
+
if timestamp:
|
|
178
|
+
parsed = datetime.fromisoformat(timestamp)
|
|
179
|
+
if parsed.tzinfo is not None:
|
|
180
|
+
return parsed
|
|
181
|
+
return fallback
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _test_file(tracked: list[str], case: TestCase) -> str | None:
|
|
185
|
+
"""The tracked file a test case comes from: its file attribute, or the longest prefix of its
|
|
186
|
+
class name that names exactly one file (com.acme.InvoiceTest -> .../com/acme/InvoiceTest.java,
|
|
187
|
+
tests.test_invoice -> tests/test_invoice.py)."""
|
|
188
|
+
if case.file and case.file in tracked:
|
|
189
|
+
return case.file
|
|
190
|
+
parts = case.classname.split(".")
|
|
191
|
+
for end in range(len(parts), 0, -1):
|
|
192
|
+
stem = "/".join(parts[:end])
|
|
193
|
+
matches = [p for p in tracked if _stem(p) == stem or _stem(p).endswith(f"/{stem}")]
|
|
194
|
+
if len(matches) == 1:
|
|
195
|
+
return matches[0]
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _stem(path: str) -> str:
|
|
200
|
+
return str(PurePosixPath(path).with_suffix(""))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _artifact(
|
|
204
|
+
ns: str,
|
|
205
|
+
repo: Path,
|
|
206
|
+
commit: str,
|
|
207
|
+
location: str,
|
|
208
|
+
path: str | None,
|
|
209
|
+
case: TestCase,
|
|
210
|
+
producer: str,
|
|
211
|
+
now: datetime,
|
|
212
|
+
) -> Record:
|
|
213
|
+
if path is None:
|
|
214
|
+
# No file found: the artifact is the repository at that commit (section 6.4 allows
|
|
215
|
+
# gitCommit alone for artifacts located in Git).
|
|
216
|
+
anchor = hashlib.sha256(case.classname.encode()).hexdigest()[:8]
|
|
217
|
+
return _record(
|
|
218
|
+
"artifact",
|
|
219
|
+
f"{ns}/artifacts/test-{commit[:12]}-{anchor}",
|
|
220
|
+
now,
|
|
221
|
+
kind="test",
|
|
222
|
+
location=location,
|
|
223
|
+
digest={"gitCommit": commit},
|
|
224
|
+
producedBy=producer,
|
|
225
|
+
)
|
|
226
|
+
content = subprocess.run(
|
|
227
|
+
["git", "-C", str(repo), "show", f"{commit}:{path}"], check=True, capture_output=True
|
|
228
|
+
).stdout
|
|
229
|
+
sha256 = hashlib.sha256(content).hexdigest()
|
|
230
|
+
# The id follows path and content, so a changed test file is a new artifact (R10).
|
|
231
|
+
anchor = hashlib.sha256(f"{path}\n{sha256}".encode()).hexdigest()[:16]
|
|
232
|
+
return _record(
|
|
233
|
+
"artifact",
|
|
234
|
+
f"{ns}/artifacts/test-{anchor}",
|
|
235
|
+
now,
|
|
236
|
+
kind="test",
|
|
237
|
+
location=location,
|
|
238
|
+
path=path,
|
|
239
|
+
digest={"sha256": sha256, "gitCommit": commit},
|
|
240
|
+
producedBy=producer,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _key(case: TestCase) -> str:
|
|
245
|
+
"""Stable and valid for any test name: TC- and a hash of class and test name."""
|
|
246
|
+
return "TC-" + hashlib.sha256(f"{case.classname}::{case.name}".encode()).hexdigest()[:12]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _statement(case: TestCase) -> str:
|
|
250
|
+
return f"{case.classname}.{case.name}" if case.classname else case.name
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _slug(text: str) -> str:
|
|
254
|
+
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") or "suite"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _record(type_: str, id_: str, now: datetime, **fields: Any) -> Record:
|
|
258
|
+
return {"ois": "0.1", "type": type_, "id": id_, "createdAt": _timestamp(now), **fields}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _timestamp(moment: datetime) -> str:
|
|
262
|
+
return moment.astimezone(UTC).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _git(repo: Path, *args: str) -> str:
|
|
266
|
+
try:
|
|
267
|
+
return subprocess.run(
|
|
268
|
+
["git", "-C", str(repo), *args], check=True, capture_output=True, text=True
|
|
269
|
+
).stdout.strip()
|
|
270
|
+
except subprocess.CalledProcessError as error:
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"git {' '.join(args)} failed in {repo}: {error.stderr.strip()}"
|
|
273
|
+
) from error
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Command line interface: ois-junit REPORT... --namespace URI --suite NAME"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from xml.etree.ElementTree import ParseError
|
|
9
|
+
|
|
10
|
+
from ois_validator.junit import JUnitReport, Options, to_bundle
|
|
11
|
+
from ois_validator.validator import validate
|
|
12
|
+
|
|
13
|
+
EXIT_OK = 0
|
|
14
|
+
EXIT_INVALID = 1
|
|
15
|
+
EXIT_USAGE = 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv: list[str] | None = None) -> int:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="ois-junit",
|
|
21
|
+
description="Turn JUnit XML reports into an OIS bundle (Level 1, spec section 11.4).",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("reports", nargs="+", metavar="REPORT", help="JUnit XML file, or -")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--namespace",
|
|
26
|
+
required=True,
|
|
27
|
+
help="URI under your domain for record ids, e.g. https://ois.acme.example",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--suite", required=True, help="team or suite that owns the tests")
|
|
30
|
+
parser.add_argument("--repo", type=Path, default=Path("."), help="Git working tree (default .)")
|
|
31
|
+
parser.add_argument("--location", help="artifact location (default git+<origin>@<HEAD>)")
|
|
32
|
+
parser.add_argument("--run", help="URI of the pipeline run")
|
|
33
|
+
parser.add_argument("--output", type=Path, help="write the bundle here instead of stdout")
|
|
34
|
+
args = parser.parse_args(argv)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
reports = [_read(name) for name in args.reports]
|
|
38
|
+
options = Options(
|
|
39
|
+
namespace=args.namespace,
|
|
40
|
+
suite=args.suite,
|
|
41
|
+
repo=args.repo,
|
|
42
|
+
location=args.location,
|
|
43
|
+
run=args.run,
|
|
44
|
+
)
|
|
45
|
+
bundle = to_bundle(reports, options)
|
|
46
|
+
except (OSError, ParseError, ValueError) as error:
|
|
47
|
+
print(f"ois-junit: {error}", file=sys.stderr)
|
|
48
|
+
return EXIT_USAGE
|
|
49
|
+
|
|
50
|
+
result = validate(bundle)
|
|
51
|
+
if not result.conforms:
|
|
52
|
+
for report in result.reports:
|
|
53
|
+
print(
|
|
54
|
+
f"ois-junit: {report.level} [{report.rule}] {report.path}: {report.message}",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
)
|
|
57
|
+
return EXIT_INVALID
|
|
58
|
+
|
|
59
|
+
text = json.dumps(bundle, indent=2, ensure_ascii=False) + "\n"
|
|
60
|
+
if args.output:
|
|
61
|
+
args.output.write_text(text, encoding="utf-8")
|
|
62
|
+
else:
|
|
63
|
+
sys.stdout.write(text)
|
|
64
|
+
return EXIT_OK
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _read(name: str) -> JUnitReport:
|
|
68
|
+
if name == "-":
|
|
69
|
+
return JUnitReport(sys.stdin.buffer.read())
|
|
70
|
+
path = Path(name)
|
|
71
|
+
written_at = datetime.fromtimestamp(path.stat().st_mtime, UTC)
|
|
72
|
+
return JUnitReport(path.read_bytes(), written_at)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
sys.exit(main())
|
ois_validator/report.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Validation reports with the three levels of spec section 11.1."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Level(StrEnum):
|
|
8
|
+
VIOLATION = "violation" # a MUST is broken; the input does not conform
|
|
9
|
+
WARNING = "warning" # a rule asks for a report; the input still conforms
|
|
10
|
+
INFO = "info" # a SHOULD is not followed
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Report:
|
|
15
|
+
level: Level
|
|
16
|
+
rule: str # "schema" for Appendix B, otherwise R1 to R15
|
|
17
|
+
message: str
|
|
18
|
+
path: str # JSONPath into the input document
|
|
19
|
+
|
|
20
|
+
def as_dict(self) -> dict[str, str]:
|
|
21
|
+
return {"level": self.level, "rule": self.rule, "message": self.message, "path": self.path}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Result:
|
|
26
|
+
reports: list[Report] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def conforms(self) -> bool:
|
|
30
|
+
return not any(r.level is Level.VIOLATION for r in self.reports)
|