codemeta-drift 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.
- codemeta_drift/__init__.py +4 -0
- codemeta_drift/checks.py +243 -0
- codemeta_drift/cli.py +166 -0
- codemeta_drift/conformance.py +89 -0
- codemeta_drift/curated-3.1.json +207 -0
- codemeta_drift/export.py +246 -0
- codemeta_drift/mapping.schema.json +70 -0
- codemeta_drift/py.typed +0 -0
- codemeta_drift-0.1.0.dist-info/METADATA +209 -0
- codemeta_drift-0.1.0.dist-info/RECORD +13 -0
- codemeta_drift-0.1.0.dist-info/WHEEL +4 -0
- codemeta_drift-0.1.0.dist-info/entry_points.txt +3 -0
- codemeta_drift-0.1.0.dist-info/licenses/LICENSE +201 -0
codemeta_drift/checks.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Find where CodeMeta's context, definitions, property table, examples and crosswalks disagree.
|
|
2
|
+
|
|
3
|
+
It uses only the standard library and imports nothing from this package, so the
|
|
4
|
+
codemeta repository can copy it as is for a CI check.
|
|
5
|
+
|
|
6
|
+
Every check reads files from a mapping of repository-relative path to text. When
|
|
7
|
+
a check's input file is missing, it reports ``SKIPPED`` instead of failing, so the
|
|
8
|
+
same code runs on every CodeMeta release.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import difflib
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from collections.abc import Iterator, Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
CONTEXT = "codemeta.jsonld"
|
|
20
|
+
DEFINITIONS = "codemeta_all.jsonld"
|
|
21
|
+
TABLE = "properties_description.csv"
|
|
22
|
+
AGGREGATE = "crosswalk.csv"
|
|
23
|
+
|
|
24
|
+
VALID_EXAMPLES = ("codemeta.json", "examples/codemeta.json", "examples/codemeta-software.json")
|
|
25
|
+
"""Files that should only use terms of the CodeMeta context. The other files in
|
|
26
|
+
examples/ use the schema.org context or are invalid on purpose."""
|
|
27
|
+
|
|
28
|
+
NOT_PROPERTIES = frozenset({"type", "id", "schema", "codemeta"})
|
|
29
|
+
"""Context keys that are keyword aliases or prefixes, not properties."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Finding:
|
|
34
|
+
"""One disagreement between two CodeMeta files.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
code: Stable identifier of the rule, such as ``TABLE_NO_TERM``.
|
|
38
|
+
file: The file (or group of crosswalk files) where it shows.
|
|
39
|
+
term: The property or format it is about.
|
|
40
|
+
detail: What disagrees, for a human reader.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
code: str
|
|
44
|
+
file: str
|
|
45
|
+
term: str
|
|
46
|
+
detail: str
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return f"{self.code} {self.file}: {self.term}: {self.detail}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
type Row = tuple[int, list[str]]
|
|
53
|
+
"""A CSV record and the file line it starts on."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def csv_rows(text: str, key: int = 0) -> list[Row]:
|
|
57
|
+
"""Records of a CSV file with a value in column ``key``, each with its first line number."""
|
|
58
|
+
reader = csv.reader(io.StringIO(text))
|
|
59
|
+
rows, line = [], 1
|
|
60
|
+
for row in reader:
|
|
61
|
+
if len(row) > key and row[key]:
|
|
62
|
+
rows.append((line, row))
|
|
63
|
+
line = reader.line_num + 1
|
|
64
|
+
return rows
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def context_terms(files: Mapping[str, str]) -> dict[str, str]:
|
|
68
|
+
"""Maps each property term of the context to its prefixed IRI."""
|
|
69
|
+
context = json.loads(files[CONTEXT])["@context"]
|
|
70
|
+
terms = {}
|
|
71
|
+
for key, value in context.items():
|
|
72
|
+
iri = value.get("@id") if isinstance(value, dict) else value
|
|
73
|
+
is_class = not key[:1].islower() # Person, Organization...
|
|
74
|
+
is_prefix = isinstance(iri, str) and iri.endswith(("/", "#")) # schema, spdx...
|
|
75
|
+
if key in NOT_PROPERTIES or is_class or is_prefix or not isinstance(iri, str):
|
|
76
|
+
continue
|
|
77
|
+
terms[key] = iri
|
|
78
|
+
return terms
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def table_rows(files: Mapping[str, str]) -> list[Row]:
|
|
82
|
+
"""Property table records with a property name, header dropped."""
|
|
83
|
+
return csv_rows(files[TABLE], key=1)[1:]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def crosswalk_paths(files: Mapping[str, str]) -> list[str]:
|
|
87
|
+
"""Paths of the per-format crosswalk files, sorted."""
|
|
88
|
+
return sorted(p for p in files if p.startswith("crosswalks/") and p.endswith(".csv"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_name(crosswalk: str) -> str:
|
|
92
|
+
"""The format a crosswalk file maps to: the second cell of its header, as in "Property,Zenodo"."""
|
|
93
|
+
(_, header), *_ = csv_rows(crosswalk)
|
|
94
|
+
return header[1]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def used_terms(node: object, found: set[str]) -> set[str]:
|
|
98
|
+
"""Collects every non-keyword key of a JSON document, outside @context."""
|
|
99
|
+
if isinstance(node, dict):
|
|
100
|
+
for key, value in node.items():
|
|
101
|
+
if key == "@context":
|
|
102
|
+
continue
|
|
103
|
+
if not key.startswith("@"):
|
|
104
|
+
found.add(key)
|
|
105
|
+
used_terms(value, found)
|
|
106
|
+
elif isinstance(node, list):
|
|
107
|
+
for value in node:
|
|
108
|
+
used_terms(value, found)
|
|
109
|
+
return found
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_table(files: Mapping[str, str]) -> Iterator[Finding]:
|
|
113
|
+
"""Property table against itself and against the context."""
|
|
114
|
+
rows = [(row[0], row[1]) for _, row in table_rows(files)]
|
|
115
|
+
for (parent, name), count in sorted(Counter(rows).items()):
|
|
116
|
+
if count > 1:
|
|
117
|
+
yield Finding("TABLE_DUPLICATE", TABLE, name, f"{parent},{name} appears {count} times")
|
|
118
|
+
terms = context_terms(files)
|
|
119
|
+
names = {name for _, name in rows}
|
|
120
|
+
for name in sorted(names - terms.keys()):
|
|
121
|
+
yield Finding("TABLE_NO_TERM", TABLE, name, f"row has no term in {CONTEXT}")
|
|
122
|
+
for name in sorted(terms.keys() - names):
|
|
123
|
+
yield Finding("TERM_NO_ROW", CONTEXT, name, f"term has no row in {TABLE}")
|
|
124
|
+
for parent, name in rows:
|
|
125
|
+
prefix = parent.split(":")[0]
|
|
126
|
+
iri = terms.get(name)
|
|
127
|
+
if iri is not None and not iri.startswith(prefix + ":"):
|
|
128
|
+
yield Finding("TABLE_NAMESPACE", TABLE, name, f"row {parent},{name} but {CONTEXT} maps {name} to {iri}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def check_definitions(files: Mapping[str, str]) -> Iterator[Finding]:
|
|
132
|
+
"""Property definitions against the context."""
|
|
133
|
+
terms = context_terms(files)
|
|
134
|
+
graph = json.loads(files[DEFINITIONS])["@graph"]
|
|
135
|
+
|
|
136
|
+
def is_property(node: dict) -> bool:
|
|
137
|
+
kind = node.get("@type")
|
|
138
|
+
return "rdf:Property" in (kind if isinstance(kind, list) else [kind])
|
|
139
|
+
|
|
140
|
+
for node in sorted(filter(is_property, graph), key=lambda n: n["@id"]):
|
|
141
|
+
label, iri = node.get("rdfs:label", ""), node["@id"]
|
|
142
|
+
if label not in terms:
|
|
143
|
+
alias = [t for t, i in terms.items() if i == iri]
|
|
144
|
+
hint = f"; {CONTEXT} has {iri} as {alias[0]}" if alias else ""
|
|
145
|
+
yield Finding("DEFINITION_NO_TERM", DEFINITIONS, label, f"defines {iri} but {CONTEXT} has no {label}{hint}")
|
|
146
|
+
elif terms[label] != iri:
|
|
147
|
+
yield Finding(
|
|
148
|
+
"DEFINITION_IRI", DEFINITIONS, label, f"defines {iri} but {CONTEXT} maps it to {terms[label]}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def check_examples(files: Mapping[str, str]) -> Iterator[Finding]:
|
|
153
|
+
"""Valid examples use only context terms."""
|
|
154
|
+
terms = context_terms(files)
|
|
155
|
+
for path in VALID_EXAMPLES:
|
|
156
|
+
if path not in files:
|
|
157
|
+
continue
|
|
158
|
+
keys = used_terms(json.loads(files[path]), set())
|
|
159
|
+
for key in sorted(k for k in keys if ":" not in k and k not in terms and k not in NOT_PROPERTIES):
|
|
160
|
+
yield Finding("EXAMPLE_NO_TERM", path, key, f"not a term of {CONTEXT}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def column_diff(code: str, label: str, column: list[tuple[int, str]], table: list[Row]) -> Iterator[Finding]:
|
|
164
|
+
"""Where a property column departs from the table's, matched by name, not by position.
|
|
165
|
+
|
|
166
|
+
``column`` holds (file line, property) pairs of the checked file.
|
|
167
|
+
"""
|
|
168
|
+
names, wanted = [name for _, name in column], [row[1] for _, row in table]
|
|
169
|
+
matcher = difflib.SequenceMatcher(a=names, b=wanted, autojunk=False)
|
|
170
|
+
for op, a1, a2, b1, b2 in matcher.get_opcodes():
|
|
171
|
+
if op in {"delete", "replace"}:
|
|
172
|
+
# Name the table row only for a one-for-one swap, where it is the likely rename.
|
|
173
|
+
instead = f"; the table has {wanted[b1]} there" if op == "replace" and a2 - a1 == b2 - b1 == 1 else ""
|
|
174
|
+
for line, name in column[a1:a2]:
|
|
175
|
+
yield Finding(code, label, name, f"line {line} is not in {TABLE}{instead}")
|
|
176
|
+
if op in {"insert", "replace"}:
|
|
177
|
+
for line, row in table[b1:b2]:
|
|
178
|
+
yield Finding(code, label, row[1], f"no row for {TABLE} line {line}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def check_crosswalks(files: Mapping[str, str]) -> Iterator[Finding]:
|
|
182
|
+
"""Each crosswalk file lists the table's properties in the table's order."""
|
|
183
|
+
table = table_rows(files)
|
|
184
|
+
paths = crosswalk_paths(files)
|
|
185
|
+
# Crosswalks with the same property column share one set of findings.
|
|
186
|
+
groups: dict[tuple[tuple[int, str], ...], list[str]] = {}
|
|
187
|
+
for path in paths:
|
|
188
|
+
column = tuple((line, row[0]) for line, row in csv_rows(files[path])[1:])
|
|
189
|
+
groups.setdefault(column, []).append(path)
|
|
190
|
+
for column, members in groups.items():
|
|
191
|
+
label = f"crosswalks/*.csv (all {len(paths)})" if len(members) == len(paths) > 1 else ", ".join(members)
|
|
192
|
+
yield from column_diff("CROSSWALK_ROWS", label, list(column), table)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
AGGREGATE_FIXED_COLUMNS = 4
|
|
196
|
+
"""crosswalk.csv starts with Parent Type, Property, Type and Description; formats follow."""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def check_aggregate(files: Mapping[str, str]) -> Iterator[Finding]:
|
|
200
|
+
"""The committed crosswalk.csv has every format and the table's properties."""
|
|
201
|
+
(_, header), *rows = csv_rows(files[AGGREGATE], key=1)
|
|
202
|
+
yield from column_diff("AGGREGATE_ROWS", AGGREGATE, [(line, row[1]) for line, row in rows], table_rows(files))
|
|
203
|
+
paths = crosswalk_paths(files)
|
|
204
|
+
if not paths:
|
|
205
|
+
return
|
|
206
|
+
formats: dict[str, list[str]] = {}
|
|
207
|
+
for path in paths:
|
|
208
|
+
formats.setdefault(format_name(files[path]), []).append(path)
|
|
209
|
+
columns = set(header[AGGREGATE_FIXED_COLUMNS:])
|
|
210
|
+
for name, members in sorted(formats.items()):
|
|
211
|
+
if len(members) > 1:
|
|
212
|
+
yield Finding("CROSSWALK_FORMAT", ", ".join(members), name, "files share one format name")
|
|
213
|
+
if name not in columns:
|
|
214
|
+
yield Finding("AGGREGATE_FORMAT", AGGREGATE, name, f"no column for {', '.join(members)}")
|
|
215
|
+
for name in sorted(columns - formats.keys()):
|
|
216
|
+
yield Finding("AGGREGATE_FORMAT", AGGREGATE, name, "column has no file in crosswalks/")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
CHECKS = (
|
|
220
|
+
(check_table, (CONTEXT, TABLE)),
|
|
221
|
+
(check_definitions, (CONTEXT, DEFINITIONS)),
|
|
222
|
+
(check_examples, (CONTEXT,)),
|
|
223
|
+
(check_crosswalks, (TABLE,)),
|
|
224
|
+
(check_aggregate, (TABLE, AGGREGATE)),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def check(files: Mapping[str, str]) -> list[Finding]:
|
|
229
|
+
"""Every finding for one snapshot of the codemeta repository, SKIPPED ones first.
|
|
230
|
+
|
|
231
|
+
Raises ValueError, KeyError, IndexError, TypeError or AttributeError when an input file is
|
|
232
|
+
malformed (not JSON, not UTF-8, an unexpected shape); a caller must not read that as drift.
|
|
233
|
+
"""
|
|
234
|
+
findings: list[Finding] = []
|
|
235
|
+
skipped: set[str] = set()
|
|
236
|
+
for run, needs in CHECKS:
|
|
237
|
+
gaps = {p for p in needs if p not in files}
|
|
238
|
+
if gaps:
|
|
239
|
+
skipped |= gaps
|
|
240
|
+
continue
|
|
241
|
+
findings.extend(run(files))
|
|
242
|
+
reason = "file not present, checks that need it did not run"
|
|
243
|
+
return [*(Finding("SKIPPED", path, "", reason) for path in sorted(skipped)), *findings]
|
codemeta_drift/cli.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Command line: ``codemeta-drift <checkout> [--ref REF] [--json]``.
|
|
2
|
+
|
|
3
|
+
Exit status 0 means no drift, 1 means at least one finding (SKIPPED ones do
|
|
4
|
+
not count), 2 means bad arguments, an unreadable or malformed checkout, or one
|
|
5
|
+
without the context and the property table.
|
|
6
|
+
|
|
7
|
+
``codemeta-drift export [checkout] --tag TAG [--out FILE] [--schemas DIR]`` writes the
|
|
8
|
+
mapping export from the commit of tag TAG, never the working tree, since every row says
|
|
9
|
+
it was read from that tag. It exits 0 when written, 1 when a row can't be made (such as a
|
|
10
|
+
crosswalk cell with no path and no curated row, or a path not in its format's schema), with
|
|
11
|
+
the findings on standard error so standard output is never a partial file, and 2 as above,
|
|
12
|
+
or when the checkout has no tag TAG or a file can't be read or written.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import fnmatch
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import asdict
|
|
21
|
+
from importlib import metadata
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from codemeta_drift.checks import CONTEXT, TABLE, check
|
|
25
|
+
from codemeta_drift.conformance import SCHEMA_FILES, conform
|
|
26
|
+
from codemeta_drift.export import curated_tags, export
|
|
27
|
+
|
|
28
|
+
JSON_VERSION = 1
|
|
29
|
+
"""The ``--json`` document's version: a minor release may add keys; removing or retyping one bumps it."""
|
|
30
|
+
|
|
31
|
+
INPUTS = (
|
|
32
|
+
"codemeta.jsonld",
|
|
33
|
+
"codemeta_all.jsonld",
|
|
34
|
+
"properties_description.csv",
|
|
35
|
+
"crosswalk.csv",
|
|
36
|
+
"codemeta.json",
|
|
37
|
+
"examples/*.json",
|
|
38
|
+
"crosswalks/*.csv",
|
|
39
|
+
)
|
|
40
|
+
"""Glob patterns, relative to the repository root, of every file a check reads."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_input(path: str) -> bool:
|
|
44
|
+
"""Whether a repository-relative path matches INPUTS, with ``*`` stopping at ``/`` as in Path.glob."""
|
|
45
|
+
return any(fnmatch.fnmatchcase(path, g) and path.count("/") == g.count("/") for g in INPUTS)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_dir(root: Path) -> dict[str, str]:
|
|
49
|
+
"""The input files of a working tree, as path -> text."""
|
|
50
|
+
return {p.relative_to(root).as_posix(): p.read_text(encoding="utf-8") for g in INPUTS for p in root.glob(g)}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_ref(root: Path, ref: str) -> dict[str, str]:
|
|
54
|
+
"""The input files at a commit, read with git without touching the working tree."""
|
|
55
|
+
|
|
56
|
+
def git(*args: str, stdin: bytes = b"") -> bytes:
|
|
57
|
+
return subprocess.run(["git", "-C", str(root), *args], input=stdin, check=True, capture_output=True).stdout
|
|
58
|
+
|
|
59
|
+
commit = git("rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}").strip().decode()
|
|
60
|
+
|
|
61
|
+
# Each entry is "<mode> <type> <object>\t<path>". -z keeps git from quoting
|
|
62
|
+
# non-ASCII names, which would then not match INPUTS.
|
|
63
|
+
blobs = {}
|
|
64
|
+
for entry in git("ls-tree", "-r", "-z", commit).decode("utf-8").split("\0"):
|
|
65
|
+
if entry:
|
|
66
|
+
meta, path = entry.split("\t", 1)
|
|
67
|
+
_, kind, oid = meta.split()
|
|
68
|
+
if kind == "blob" and is_input(path):
|
|
69
|
+
blobs[path] = oid
|
|
70
|
+
|
|
71
|
+
# One cat-file process reads every file; one git process per file doubles the run time.
|
|
72
|
+
# Its output is "<object> blob <size>\n<content>\n" per file, in the order asked.
|
|
73
|
+
out = git("cat-file", "--batch", stdin="".join(f"{oid}\n" for oid in blobs.values()).encode())
|
|
74
|
+
files, pos = {}, 0
|
|
75
|
+
for path in blobs:
|
|
76
|
+
header_end = out.index(b"\n", pos)
|
|
77
|
+
start = header_end + 1
|
|
78
|
+
end = start + int(out[pos:header_end].split()[2])
|
|
79
|
+
files[path] = out[start:end].decode("utf-8")
|
|
80
|
+
pos = end + 1
|
|
81
|
+
return files
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def export_main(argv: list[str]) -> int:
|
|
85
|
+
"""Write the mapping export for one CodeMeta tag, or list the cells that block it."""
|
|
86
|
+
parser = argparse.ArgumentParser(prog="codemeta-drift export", description="Write a mapping export.")
|
|
87
|
+
parser.add_argument("checkout", type=Path, nargs="?", default=Path(), help="root of a codemeta clone (default: .)")
|
|
88
|
+
parser.add_argument("--tag", required=True, help="the CodeMeta release tag to read; picks the curated rows")
|
|
89
|
+
parser.add_argument("--out", type=Path, help="write here instead of standard output")
|
|
90
|
+
parser.add_argument("--schemas", type=Path, help="check every path against the format schemas in this directory")
|
|
91
|
+
args = parser.parse_args(argv)
|
|
92
|
+
|
|
93
|
+
if args.tag not in curated_tags():
|
|
94
|
+
print(f"codemeta-drift: no curated rows for {args.tag}; have {', '.join(curated_tags())}", file=sys.stderr)
|
|
95
|
+
return 2
|
|
96
|
+
# Each step names what it failed on: the checkout, a schema file or the output file.
|
|
97
|
+
target: object = f"{args.checkout} at tag {args.tag}"
|
|
98
|
+
try:
|
|
99
|
+
files = read_ref(args.checkout, f"refs/tags/{args.tag}")
|
|
100
|
+
if TABLE not in files:
|
|
101
|
+
print(f"codemeta-drift: {target} has no {TABLE}", file=sys.stderr)
|
|
102
|
+
return 2
|
|
103
|
+
mapping, findings = export(files, args.tag)
|
|
104
|
+
if args.schemas:
|
|
105
|
+
schemas = {}
|
|
106
|
+
for fmt, name in SCHEMA_FILES.items():
|
|
107
|
+
target = args.schemas / name
|
|
108
|
+
schemas[fmt] = json.loads(target.read_text("utf-8"))
|
|
109
|
+
findings += conform(mapping["rows"], schemas)
|
|
110
|
+
if findings:
|
|
111
|
+
print("\n".join(map(str, findings)), file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
text = json.dumps(mapping, indent=2, ensure_ascii=False) + "\n"
|
|
114
|
+
if args.out:
|
|
115
|
+
target = args.out
|
|
116
|
+
args.out.write_text(text, encoding="utf-8")
|
|
117
|
+
else:
|
|
118
|
+
print(text, end="")
|
|
119
|
+
except subprocess.CalledProcessError as err:
|
|
120
|
+
print(f"codemeta-drift: cannot read {target}: {err.stderr.decode().strip()}", file=sys.stderr)
|
|
121
|
+
return 2
|
|
122
|
+
except (OSError, ValueError, KeyError, IndexError, TypeError, AttributeError) as err:
|
|
123
|
+
print(f"codemeta-drift: cannot read or write {target}: {type(err).__name__}: {err}", file=sys.stderr)
|
|
124
|
+
return 2
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv: list[str] | None = None) -> int:
|
|
129
|
+
"""Run every check on one snapshot and print the findings, or run a subcommand."""
|
|
130
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
131
|
+
if argv[:1] == ["export"]:
|
|
132
|
+
return export_main(argv[1:])
|
|
133
|
+
parser = argparse.ArgumentParser(
|
|
134
|
+
prog="codemeta-drift",
|
|
135
|
+
description="Report where CodeMeta's files disagree.",
|
|
136
|
+
epilog="codemeta-drift export --help describes the mapping export.",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {metadata.version('codemeta-drift')}")
|
|
139
|
+
parser.add_argument("checkout", type=Path, help="root of a codemeta clone")
|
|
140
|
+
parser.add_argument("--ref", help="read this commit, tag or branch instead of the working tree")
|
|
141
|
+
parser.add_argument("--json", action="store_true", help="print the findings as one JSON document")
|
|
142
|
+
args = parser.parse_args(argv)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
files = read_ref(args.checkout, args.ref) if args.ref else read_dir(args.checkout)
|
|
146
|
+
if missing := [p for p in (CONTEXT, TABLE) if p not in files]:
|
|
147
|
+
print(f"codemeta-drift: {args.checkout} has no {' or '.join(missing)}", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
findings = check(files)
|
|
150
|
+
except subprocess.CalledProcessError as err:
|
|
151
|
+
print(f"codemeta-drift: cannot read {args.checkout}: {err.stderr.decode().strip()}", file=sys.stderr)
|
|
152
|
+
return 2
|
|
153
|
+
# A malformed file must not exit 1, which means drift.
|
|
154
|
+
except (OSError, ValueError, KeyError, IndexError, TypeError, AttributeError) as err:
|
|
155
|
+
print(f"codemeta-drift: cannot read {args.checkout}: {type(err).__name__}: {err}", file=sys.stderr)
|
|
156
|
+
return 2
|
|
157
|
+
if args.json:
|
|
158
|
+
tool = {"name": "codemeta-drift", "version": metadata.version("codemeta-drift")}
|
|
159
|
+
print(json.dumps({"version": JSON_VERSION, "tool": tool, "findings": [asdict(f) for f in findings]}, indent=2))
|
|
160
|
+
else:
|
|
161
|
+
print("\n".join(map(str, findings)))
|
|
162
|
+
return 1 if any(f.code != "SKIPPED" for f in findings) else 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
sys.exit(main())
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Check that every path in a mapping export names a property in its format's own JSON Schema.
|
|
2
|
+
|
|
3
|
+
A step resolves when the schema in reach has the key under ``properties``, a matching
|
|
4
|
+
``patternProperties`` entry, or an ``additionalProperties`` schema. Local ``$ref``s are followed,
|
|
5
|
+
and a key found in any branch of ``allOf``, ``anyOf`` or ``oneOf`` counts. ``key[]`` and
|
|
6
|
+
``key[k=v]`` need the value to be a list (``items``); a filter needs ``k`` in the item schema.
|
|
7
|
+
An external ``$ref`` is not followed, so a key only reachable through one does not resolve.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterator, Mapping
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from codemeta_drift.checks import Finding
|
|
15
|
+
from codemeta_drift.export import PATH
|
|
16
|
+
|
|
17
|
+
SCHEMA_FILES = {
|
|
18
|
+
"cff-1.2.0": "cff-1.2.0.json",
|
|
19
|
+
"pep621": "pyproject.json",
|
|
20
|
+
"npm": "package.json",
|
|
21
|
+
"cargo": "cargo.json",
|
|
22
|
+
}
|
|
23
|
+
"""The schema file for each format that has one. Zenodo and codemeta are not checked."""
|
|
24
|
+
|
|
25
|
+
STEP = re.compile(r"(?P<key>@?[\w-]+)(?:\[(?:(?P<filter>@?[\w-]+)=[^\]]+)?\])?")
|
|
26
|
+
|
|
27
|
+
type Schema = dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def branches(root: Schema, node: object, seen: frozenset[int] = frozenset()) -> Iterator[Schema]:
|
|
31
|
+
"""The node and every schema it stands for through local $ref, allOf, anyOf and oneOf."""
|
|
32
|
+
if not isinstance(node, dict) or id(node) in seen:
|
|
33
|
+
return
|
|
34
|
+
seen |= {id(node)}
|
|
35
|
+
yield node
|
|
36
|
+
ref = node.get("$ref")
|
|
37
|
+
if isinstance(ref, str) and ref.startswith("#"):
|
|
38
|
+
target: Any = root
|
|
39
|
+
for part in filter(None, ref[1:].split("/")):
|
|
40
|
+
target = target.get(part.replace("~1", "/").replace("~0", "~"), {}) if isinstance(target, dict) else {}
|
|
41
|
+
yield from branches(root, target, seen)
|
|
42
|
+
for combinator in ("allOf", "anyOf", "oneOf"):
|
|
43
|
+
for sub in node.get(combinator, ()):
|
|
44
|
+
yield from branches(root, sub, seen)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def child(node: Schema, key: str) -> object:
|
|
48
|
+
"""The schema of ``key`` inside one object schema, or None when the object has no such key."""
|
|
49
|
+
if key in node.get("properties", {}):
|
|
50
|
+
return node["properties"][key]
|
|
51
|
+
for pattern, sub in node.get("patternProperties", {}).items():
|
|
52
|
+
if re.search(pattern, key):
|
|
53
|
+
return sub
|
|
54
|
+
extra = node.get("additionalProperties")
|
|
55
|
+
return extra if isinstance(extra, dict) else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve(root: Schema, path: str) -> str | None:
|
|
59
|
+
"""Why one path alternative (no `` | ``) is not in the schema, or None when every step resolves."""
|
|
60
|
+
if " | " in path or not PATH.fullmatch(path):
|
|
61
|
+
return "not a path"
|
|
62
|
+
nodes: list[object] = [root]
|
|
63
|
+
for match in STEP.finditer(path):
|
|
64
|
+
key, selector = match["key"], match[0][len(match["key"]) :]
|
|
65
|
+
found = [c for n in nodes for b in branches(root, n) if (c := child(b, key)) is not None]
|
|
66
|
+
if not found:
|
|
67
|
+
return f"no {key!r}"
|
|
68
|
+
if selector:
|
|
69
|
+
found = [b["items"] for n in found for b in branches(root, n) if isinstance(b.get("items"), dict)]
|
|
70
|
+
if not found:
|
|
71
|
+
return f"{key!r} is not a list"
|
|
72
|
+
if (k := match["filter"]) and not any(child(b, k) is not None for n in found for b in branches(root, n)):
|
|
73
|
+
return f"items of {key!r} have no {k!r}"
|
|
74
|
+
nodes = found
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def conform(rows: list[dict[str, Any]], schemas: Mapping[str, Schema]) -> list[Finding]:
|
|
79
|
+
"""A ``PATH_NOT_IN_SCHEMA`` finding for each path alternative its format's schema lacks.
|
|
80
|
+
|
|
81
|
+
Rows of a format with no entry in ``schemas``, and rows whose path is null, are not checked.
|
|
82
|
+
"""
|
|
83
|
+
return [
|
|
84
|
+
Finding("PATH_NOT_IN_SCHEMA", row["format"], row["term"], f"{alternative}: {why}")
|
|
85
|
+
for row in rows
|
|
86
|
+
if (schema := schemas.get(row["format"])) is not None
|
|
87
|
+
for alternative in (row["path"] or "").split(" | ")
|
|
88
|
+
if alternative and (why := resolve(schema, alternative))
|
|
89
|
+
]
|