ontolint 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.
- ontolint/__init__.py +7 -0
- ontolint/__main__.py +8 -0
- ontolint/_version.py +15 -0
- ontolint/adapters/__init__.py +1 -0
- ontolint/adapters/robot.py +72 -0
- ontolint/adapters/shacl.py +147 -0
- ontolint/catalogue.py +77 -0
- ontolint/cli.py +302 -0
- ontolint/context.py +127 -0
- ontolint/data/rule_catalogue.csv +308 -0
- ontolint/engine.py +157 -0
- ontolint/github.py +27 -0
- ontolint/graph/__init__.py +5 -0
- ontolint/graph/algorithms.py +198 -0
- ontolint/linter.py +96 -0
- ontolint/loader.py +385 -0
- ontolint/models.py +115 -0
- ontolint/ontolint.py +34 -0
- ontolint/ownership.py +159 -0
- ontolint/paths.py +89 -0
- ontolint/policy.py +202 -0
- ontolint/profiles.py +68 -0
- ontolint/py.typed +1 -0
- ontolint/reporting.py +306 -0
- ontolint/rule_info.py +139 -0
- ontolint/rules/__init__.py +5 -0
- ontolint/rules/alignment.py +428 -0
- ontolint/rules/builtin.py +156 -0
- ontolint/rules/class_heuristics.py +195 -0
- ontolint/rules/classes.py +297 -0
- ontolint/rules/external.py +58 -0
- ontolint/rules/governance.py +137 -0
- ontolint/rules/governance_metadata.py +300 -0
- ontolint/rules/hierarchy_quality.py +370 -0
- ontolint/rules/identifiers.py +89 -0
- ontolint/rules/iri_lexical_extras.py +157 -0
- ontolint/rules/iri_quality.py +193 -0
- ontolint/rules/lexical.py +279 -0
- ontolint/rules/lexical_quality.py +291 -0
- ontolint/rules/metadata.py +111 -0
- ontolint/rules/metrics.py +396 -0
- ontolint/rules/obo.py +423 -0
- ontolint/rules/owl.py +415 -0
- ontolint/rules/owl_axioms.py +478 -0
- ontolint/rules/owl_expressions.py +491 -0
- ontolint/rules/registry.py +69 -0
- ontolint/rules/runtime.py +95 -0
- ontolint/rules/safe_loader.py +56 -0
- ontolint/rules/safety.py +253 -0
- ontolint/rules/shacl_authoring.py +393 -0
- ontolint/rules/shacl_quality.py +273 -0
- ontolint/rules/shapes.py +151 -0
- ontolint/rules/skos.py +158 -0
- ontolint/rules/skos_integrity.py +388 -0
- ontolint/rules/specs.py +17 -0
- ontolint/rules/support.py +136 -0
- ontolint/selection.py +94 -0
- ontolint-0.1.0.dist-info/METADATA +456 -0
- ontolint-0.1.0.dist-info/RECORD +62 -0
- ontolint-0.1.0.dist-info/WHEEL +4 -0
- ontolint-0.1.0.dist-info/entry_points.txt +3 -0
- ontolint-0.1.0.dist-info/licenses/LICENSE +202 -0
ontolint/__init__.py
ADDED
ontolint/__main__.py
ADDED
ontolint/_version.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Single fallback for the package version (a test pins it to ``pyproject.toml``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
|
|
7
|
+
FALLBACK_VERSION = "0.1.0"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def package_version() -> str:
|
|
11
|
+
"""Return the installed distribution version, or the packaged fallback when not installed."""
|
|
12
|
+
try:
|
|
13
|
+
return metadata.version("ontolint")
|
|
14
|
+
except metadata.PackageNotFoundError:
|
|
15
|
+
return FALLBACK_VERSION
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional validation backends (pySHACL, ROBOT) that translate results into findings."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""ROBOT adapter: OWL profile validation in an isolated Docker container."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ontolint.models import Finding
|
|
10
|
+
from ontolint.rules.specs import FIXES, SEVERITY
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def robot_profile_issues(ontology: Path, profile: str, image: str, timeout: int) -> list[Finding]:
|
|
14
|
+
"""Run ROBOT profile validation in an isolated Docker container.
|
|
15
|
+
|
|
16
|
+
The ontology's parent directory is mounted read-only and the container has no
|
|
17
|
+
network access. Use a digest-pinned ``image`` in CI for reproducibility.
|
|
18
|
+
"""
|
|
19
|
+
source = ontology.resolve()
|
|
20
|
+
if not source.is_file():
|
|
21
|
+
raise FileNotFoundError(source)
|
|
22
|
+
with tempfile.TemporaryDirectory(prefix="ontolint-robot-") as output_dir:
|
|
23
|
+
output = Path(output_dir)
|
|
24
|
+
report = output / "profile.txt"
|
|
25
|
+
command = [
|
|
26
|
+
"docker",
|
|
27
|
+
"run",
|
|
28
|
+
"--rm",
|
|
29
|
+
"--network",
|
|
30
|
+
"none",
|
|
31
|
+
"--memory",
|
|
32
|
+
"2g",
|
|
33
|
+
"--pids-limit",
|
|
34
|
+
"256",
|
|
35
|
+
"--cpus",
|
|
36
|
+
"2",
|
|
37
|
+
"--cap-drop",
|
|
38
|
+
"ALL",
|
|
39
|
+
"--security-opt",
|
|
40
|
+
"no-new-privileges",
|
|
41
|
+
"-v",
|
|
42
|
+
f"{source.parent}:/input:ro",
|
|
43
|
+
"-v",
|
|
44
|
+
f"{output}:/output",
|
|
45
|
+
"-w",
|
|
46
|
+
"/output",
|
|
47
|
+
image,
|
|
48
|
+
"validate-profile",
|
|
49
|
+
"--profile",
|
|
50
|
+
profile,
|
|
51
|
+
"--input",
|
|
52
|
+
f"/input/{source.name}",
|
|
53
|
+
"--output",
|
|
54
|
+
"/output/profile.txt",
|
|
55
|
+
]
|
|
56
|
+
completed = subprocess.run( # noqa: S603 -- fixed Docker executable and constructed argument list.
|
|
57
|
+
command, capture_output=True, text=True, timeout=timeout, check=False
|
|
58
|
+
)
|
|
59
|
+
if completed.returncode == 0:
|
|
60
|
+
return []
|
|
61
|
+
details = report.read_text(encoding="utf-8", errors="replace").strip() if report.exists() else ""
|
|
62
|
+
details = details or completed.stderr.strip() or completed.stdout.strip() or "ROBOT did not produce a report."
|
|
63
|
+
return [
|
|
64
|
+
Finding(
|
|
65
|
+
"SEM001",
|
|
66
|
+
SEVERITY["SEM001"],
|
|
67
|
+
str(source),
|
|
68
|
+
f"ROBOT OWL {profile} profile validation failed: {details[:4000]}",
|
|
69
|
+
FIXES["SEM001"],
|
|
70
|
+
"certain",
|
|
71
|
+
)
|
|
72
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""pySHACL adapter: SHACL validation results become SHACL001 findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from rdflib import Graph, URIRef
|
|
9
|
+
from rdflib.namespace import RDF, SH
|
|
10
|
+
from rdflib.term import Node
|
|
11
|
+
|
|
12
|
+
from ontolint.models import Finding
|
|
13
|
+
from ontolint.rules.specs import FIXES
|
|
14
|
+
|
|
15
|
+
MAX_LIST_LENGTH = 400
|
|
16
|
+
"""Longest rdf:list the SHACL backend is asked to validate (pySHACL meta-validation is quadratic in it)."""
|
|
17
|
+
MAX_LIST_COST = 160_000
|
|
18
|
+
"""Bound on the sum over lists of ``length ** 2`` (a few seconds of pySHACL work)."""
|
|
19
|
+
|
|
20
|
+
_BAD_IRI_CHARS = frozenset('<>" {}|\\^`')
|
|
21
|
+
"""The characters ``rdflib`` refuses to serialise in an IRI (``rdflib.term._is_valid_uri``)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ShapesRejected(ValueError):
|
|
25
|
+
"""An input graph would make pySHACL crash or run for minutes; it is refused before validation."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _first_unserialisable_iri(graph: Graph) -> str | None:
|
|
29
|
+
seen: set[str] = set()
|
|
30
|
+
for triple in graph:
|
|
31
|
+
for term in triple:
|
|
32
|
+
if isinstance(term, URIRef) and term not in seen:
|
|
33
|
+
seen.add(term)
|
|
34
|
+
if not _BAD_IRI_CHARS.isdisjoint(term):
|
|
35
|
+
return str(term)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _check_iris(graph: Graph, name: str) -> None:
|
|
40
|
+
bad = _first_unserialisable_iri(graph)
|
|
41
|
+
if bad is not None:
|
|
42
|
+
raise ShapesRejected(f"{name} contains an IRI that cannot be serialised: {bad[:80]!r}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class _Lists:
|
|
47
|
+
"""The rdf:list structure of one graph and the cells the checks have walked so far."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
rest: dict[Node, Node]
|
|
51
|
+
cells: set[Node]
|
|
52
|
+
reached: set[Node] = field(default_factory=set[Node])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _walk_list(head: Node, lists: _Lists, *, strict: bool) -> int:
|
|
56
|
+
"""Walk one list from ``head`` and return its length; reject cycles, and in strict mode sharing and length."""
|
|
57
|
+
length, cell = 0, head
|
|
58
|
+
walk: set[Node] = set()
|
|
59
|
+
while cell != RDF.nil and cell in lists.cells:
|
|
60
|
+
if cell in walk:
|
|
61
|
+
raise ShapesRejected(f"{lists.name} contains a cyclic rdf:list (rdf:rest loops back)")
|
|
62
|
+
if cell in lists.reached:
|
|
63
|
+
if strict:
|
|
64
|
+
raise ShapesRejected(f"{lists.name} contains an rdf:list cell shared by several lists")
|
|
65
|
+
break # a shared tail is legal RDF; the cells beyond it were already walked
|
|
66
|
+
walk.add(cell)
|
|
67
|
+
lists.reached.add(cell)
|
|
68
|
+
length += 1
|
|
69
|
+
if strict and length > MAX_LIST_LENGTH:
|
|
70
|
+
raise ShapesRejected(f"{lists.name} contains an rdf:list longer than {MAX_LIST_LENGTH} items")
|
|
71
|
+
cell = lists.rest.get(cell, RDF.nil)
|
|
72
|
+
return length
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_lists(graph: Graph, name: str, *, strict: bool) -> None:
|
|
76
|
+
rest: dict[Node, Node] = {}
|
|
77
|
+
indegree: Counter[Node] = Counter()
|
|
78
|
+
for cell, _, tail in graph.triples((None, RDF.rest, None)):
|
|
79
|
+
if cell in rest and rest[cell] != tail:
|
|
80
|
+
raise ShapesRejected(f"{name} contains an rdf:list cell with several rdf:rest values")
|
|
81
|
+
rest[cell] = tail
|
|
82
|
+
indegree[tail] += 1
|
|
83
|
+
lists = _Lists(name, rest, set(rest) | {s for s, _, _ in graph.triples((None, RDF.first, None))})
|
|
84
|
+
cost = 0
|
|
85
|
+
for head in (c for c in lists.cells if indegree[c] == 0):
|
|
86
|
+
length = _walk_list(head, lists, strict=strict)
|
|
87
|
+
cost += length * length
|
|
88
|
+
if strict and cost > MAX_LIST_COST:
|
|
89
|
+
raise ShapesRejected(f"{name} contains too many long rdf:lists to validate in reasonable time")
|
|
90
|
+
if lists.reached != lists.cells - {RDF.nil}:
|
|
91
|
+
raise ShapesRejected(f"{name} contains a cyclic rdf:list (rdf:rest loops back)")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_graph(graph: Graph, name: str, *, strict: bool = True) -> None:
|
|
95
|
+
"""Raise :class:`ShapesRejected` unless pySHACL can process ``graph`` safely (linear time, no recursion).
|
|
96
|
+
|
|
97
|
+
Every check looks for IRIs rdflib cannot serialise and for cyclic lists. ``strict`` (the shapes graph)
|
|
98
|
+
additionally rejects shared, overlong or numerous long lists, because pySHACL's meta-validation of the
|
|
99
|
+
shapes walks each list once per internal result, which is quadratic in the list length.
|
|
100
|
+
"""
|
|
101
|
+
_check_iris(graph, name)
|
|
102
|
+
_check_lists(graph, name, strict=strict)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def shacl_issues(g: Graph, sg: Graph) -> list[Finding]:
|
|
106
|
+
"""Validate ``g`` against ``sg`` with pySHACL (imported lazily; required only with shapes)."""
|
|
107
|
+
try:
|
|
108
|
+
from pyshacl import validate # noqa: PLC0415 -- optional dependency, required only with --shapes.
|
|
109
|
+
except ImportError:
|
|
110
|
+
raise RuntimeError("pySHACL is needed for --shapes: pip install pyshacl") from None
|
|
111
|
+
check_graph(sg, "shapes graph")
|
|
112
|
+
try:
|
|
113
|
+
conforms, report, _ = validate(
|
|
114
|
+
g, shacl_graph=sg, inference="none", advanced=True, meta_shacl=True, abort_on_first=False
|
|
115
|
+
)
|
|
116
|
+
except ShapesRejected:
|
|
117
|
+
raise
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
# The data graph may hold large enumerations, odd IRIs (IRI001 reports those) or malformed lists, and
|
|
120
|
+
# pySHACL fails on them only when a violation message has to describe such a node: diagnose it then.
|
|
121
|
+
try:
|
|
122
|
+
check_graph(g, "data graph", strict=False)
|
|
123
|
+
except ShapesRejected as problem:
|
|
124
|
+
raise ShapesRejected(f"{problem}; pySHACL failed on it: {type(exc).__name__}: {exc}") from exc
|
|
125
|
+
raise
|
|
126
|
+
if conforms:
|
|
127
|
+
return []
|
|
128
|
+
if not isinstance(report, Graph):
|
|
129
|
+
raise RuntimeError(f"pySHACL returned an unexpected report type: {type(report).__name__}")
|
|
130
|
+
results: list[Finding] = []
|
|
131
|
+
for result in report.subjects(RDF.type, SH.ValidationResult):
|
|
132
|
+
focus = str(next(report.objects(result, SH.focusNode), "(unknown)"))
|
|
133
|
+
message = "; ".join(map(str, report.objects(result, SH.resultMessage))) or "SHACL constraint violation"
|
|
134
|
+
src = str(next(report.objects(result, SH.sourceShape), "(unknown)"))
|
|
135
|
+
results.append(Finding("SHACL001", "error", focus, f"{message} [shape: {src}]", FIXES["SHACL001"], "certain"))
|
|
136
|
+
if not results:
|
|
137
|
+
results.append(
|
|
138
|
+
Finding(
|
|
139
|
+
"SHACL001",
|
|
140
|
+
"error",
|
|
141
|
+
"(shapes)",
|
|
142
|
+
"SHACL returned nonconformant without individual result nodes.",
|
|
143
|
+
FIXES["SHACL001"],
|
|
144
|
+
"certain",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return results
|
ontolint/catalogue.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Typed view of the planning catalogue (implemented and proposed rules).
|
|
2
|
+
|
|
3
|
+
The catalogue records what is *planned* (:class:`CatalogueStatus`); runtime outcomes
|
|
4
|
+
(:class:`ExecutionStatus`) are a different concept and live on :class:`RuleExecution`.
|
|
5
|
+
The canonical file is ``data/rule_catalogue.csv`` in the repository; an identical copy
|
|
6
|
+
ships inside the package so the loader works from an installed wheel (a test enforces
|
|
7
|
+
that the two never drift).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import cache
|
|
15
|
+
from importlib import resources
|
|
16
|
+
|
|
17
|
+
from .models import CatalogueStatus, RuleBackend
|
|
18
|
+
|
|
19
|
+
# Backends of implemented rules; planned rules use free-text backends and map to ``None``.
|
|
20
|
+
# Graph-algorithm rules (hierarchy metrics) and shapes-graph authoring rules run in-process on
|
|
21
|
+
# RDFLib graphs, so they map to the RDFLIB backend even though the catalogue prose is more specific.
|
|
22
|
+
# Mixed backends of still-proposed rows (for example "RDFLib graph or OWL profile backend") stay
|
|
23
|
+
# unmapped until a row is implemented and its actual backend is known.
|
|
24
|
+
BACKEND_IDS: dict[str, RuleBackend] = {
|
|
25
|
+
"RDFLib graph": RuleBackend.RDFLIB,
|
|
26
|
+
"RDFLib graph algorithms": RuleBackend.RDFLIB,
|
|
27
|
+
"pySHACL and RDFLib graph": RuleBackend.RDFLIB,
|
|
28
|
+
"pySHACL": RuleBackend.PYSHACL,
|
|
29
|
+
"ROBOT Docker backend": RuleBackend.ROBOT_DOCKER,
|
|
30
|
+
"CLI": RuleBackend.CLI,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class CatalogueRow:
|
|
36
|
+
"""One catalogue entry."""
|
|
37
|
+
|
|
38
|
+
rule_id: str
|
|
39
|
+
family: str
|
|
40
|
+
title: str
|
|
41
|
+
status: CatalogueStatus
|
|
42
|
+
category: str
|
|
43
|
+
backend: str
|
|
44
|
+
test_requirement: str
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def backend_id(self) -> RuleBackend | None:
|
|
48
|
+
"""The stable backend identifier, or ``None`` for a planned, unmapped backend."""
|
|
49
|
+
return BACKEND_IDS.get(self.backend)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cache
|
|
53
|
+
def load_catalogue() -> tuple[CatalogueRow, ...]:
|
|
54
|
+
"""Load every catalogue row in file order from the packaged CSV."""
|
|
55
|
+
text = resources.files("ontolint").joinpath("data/rule_catalogue.csv").read_text(encoding="utf-8")
|
|
56
|
+
return tuple(
|
|
57
|
+
CatalogueRow(
|
|
58
|
+
rule_id=row["rule_id"],
|
|
59
|
+
family=row["family"],
|
|
60
|
+
title=row["title"],
|
|
61
|
+
status=CatalogueStatus(row["status"]),
|
|
62
|
+
category=row["category"],
|
|
63
|
+
backend=row["backend"],
|
|
64
|
+
test_requirement=row["test_requirement"],
|
|
65
|
+
)
|
|
66
|
+
for row in csv.DictReader(text.splitlines())
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def implemented_rows() -> tuple[CatalogueRow, ...]:
|
|
71
|
+
"""Rows whose rules ship in the built-in registry."""
|
|
72
|
+
return tuple(row for row in load_catalogue() if row.status is CatalogueStatus.IMPLEMENTED)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def planned_rows() -> tuple[CatalogueRow, ...]:
|
|
76
|
+
"""Rows that are proposed but not yet implemented."""
|
|
77
|
+
return tuple(row for row in load_catalogue() if row.status is not CatalogueStatus.IMPLEMENTED)
|
ontolint/cli.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Command-line interface: argument parsing, orchestration, and report output.
|
|
2
|
+
|
|
3
|
+
Exit codes: ``0`` clean, ``1`` findings reach ``--fail-on``, ``2`` tool failure. A tool failure is
|
|
4
|
+
bad input/configuration/selectors, or a requested rule that failed or is unsupported (for example
|
|
5
|
+
``--owl-profile`` without Docker): the report is still written, but the run cannot pass.
|
|
6
|
+
Rules skipped for an optional input that was not supplied, or by an ignore, never affect the code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from rdflib import Graph
|
|
20
|
+
|
|
21
|
+
from . import engine
|
|
22
|
+
from ._version import package_version
|
|
23
|
+
from .catalogue import planned_rows
|
|
24
|
+
from .loader import InputRejected, ParseFailure, load_graph
|
|
25
|
+
from .models import ExecutionStatus, Finding, RuleBackend, RuleExecution
|
|
26
|
+
from .paths import InputFile, resolve_inputs
|
|
27
|
+
from .policy import Policy
|
|
28
|
+
from .profiles import PROFILES, Profile
|
|
29
|
+
from .reporting import FileReport, RunReport, fingerprint, render_report, run_exit_code
|
|
30
|
+
from .rule_info import UnknownRuleError, explain_rule, render_explanation, render_rule_list, rule_rows
|
|
31
|
+
from .rules.registry import RuleRegistry, UnknownSelectorError, default_registry
|
|
32
|
+
from .selection import Selection, SelectorSyntaxError, resolve_selection, split_selectors, unknown_selector_help
|
|
33
|
+
|
|
34
|
+
FORMATS = ["text", "json", "sarif"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _positive_int(text: str) -> int:
|
|
38
|
+
"""argparse type for the loader limits: an integer of at least 1."""
|
|
39
|
+
try:
|
|
40
|
+
value = int(text)
|
|
41
|
+
except ValueError:
|
|
42
|
+
raise argparse.ArgumentTypeError(f"must be a positive integer, got {text!r}") from None
|
|
43
|
+
if value < 1:
|
|
44
|
+
raise argparse.ArgumentTypeError(f"must be a positive integer (at least 1), got {value}")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
49
|
+
"""Create the ``ontolint`` argument parser."""
|
|
50
|
+
ap = argparse.ArgumentParser(description="Ontology anti-pattern linter. No automatic network/import access.")
|
|
51
|
+
ap.add_argument(
|
|
52
|
+
"ontology",
|
|
53
|
+
nargs="*",
|
|
54
|
+
help="Ontology RDF file(s); each is linted independently. Expand globs in your shell; directories are rejected",
|
|
55
|
+
)
|
|
56
|
+
ap.add_argument("--shapes", help="Optional SHACL shapes graph (requires pyshacl)")
|
|
57
|
+
ap.add_argument("--base", help="Only lint declared terms in this namespace")
|
|
58
|
+
ap.add_argument("--config", help="Optional JSON policy with ignored_rules, severity_overrides and thresholds")
|
|
59
|
+
ap.add_argument("--baseline", help="JSON of fingerprints to exclude from displayed and gating findings")
|
|
60
|
+
ap.add_argument("--write-baseline", help="Write full set of fingerprints for future differential linting")
|
|
61
|
+
ap.add_argument("--language", action="append", default=[], help="Required label language; repeatable")
|
|
62
|
+
ap.add_argument("--depth-limit", type=int)
|
|
63
|
+
ap.add_argument("--inheritance-limit", type=int)
|
|
64
|
+
ap.add_argument("--soup-limit", type=int)
|
|
65
|
+
ap.add_argument(
|
|
66
|
+
"--max-input-bytes", type=_positive_int, help="Refuse input files larger than this (default 64 MiB)"
|
|
67
|
+
)
|
|
68
|
+
ap.add_argument(
|
|
69
|
+
"--max-triples", type=_positive_int, help="Refuse inputs with more triples than this (default 5000000)"
|
|
70
|
+
)
|
|
71
|
+
ap.add_argument("--owl-profile", choices=("EL", "QL", "RL", "DL", "Full"))
|
|
72
|
+
ap.add_argument("--robot-image", default="ontolint-robot:1.9.11")
|
|
73
|
+
ap.add_argument("--robot-timeout", type=int, default=120)
|
|
74
|
+
ap.add_argument("--format", choices=FORMATS, default="text")
|
|
75
|
+
ap.add_argument("--output", help="Output file (default stdout)")
|
|
76
|
+
ap.add_argument("--fail-on", choices=["error", "warning", "info", "never"], default=None)
|
|
77
|
+
_add_selection_arguments(ap)
|
|
78
|
+
return ap
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _add_selection_arguments(ap: argparse.ArgumentParser) -> None:
|
|
82
|
+
ap.add_argument("--profile", choices=sorted(PROFILES), help="Named rule profile; see the README for each profile")
|
|
83
|
+
ap.add_argument(
|
|
84
|
+
"--select", action="append", default=[], help="Rule IDs or families to run; comma-separated, repeatable"
|
|
85
|
+
)
|
|
86
|
+
ap.add_argument(
|
|
87
|
+
"--ignore", action="append", default=[], help="Rule IDs or families to skip; comma-separated, repeatable"
|
|
88
|
+
)
|
|
89
|
+
ap.add_argument("--list-rules", action="store_true", help="List implemented and proposed rules, then exit")
|
|
90
|
+
ap.add_argument("--explain", metavar="RULE", help="Explain one rule, then exit")
|
|
91
|
+
ap.add_argument("--version", action="store_true", help="Print the version, then exit")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _policy(args: argparse.Namespace, config: Policy, ignored: frozenset[str]) -> Policy:
|
|
95
|
+
"""Merge CLI options over the policy file; explicit CLI options take precedence."""
|
|
96
|
+
return replace(
|
|
97
|
+
config,
|
|
98
|
+
base=args.base if args.base is not None else config.base,
|
|
99
|
+
languages=tuple(x.lower() for x in args.language) if args.language else config.languages,
|
|
100
|
+
depth_limit=args.depth_limit if args.depth_limit is not None else config.depth_limit,
|
|
101
|
+
inheritance_limit=args.inheritance_limit if args.inheritance_limit is not None else config.inheritance_limit,
|
|
102
|
+
soup_limit=args.soup_limit if args.soup_limit is not None else config.soup_limit,
|
|
103
|
+
max_input_bytes=args.max_input_bytes if args.max_input_bytes is not None else config.max_input_bytes,
|
|
104
|
+
max_triples=args.max_triples if args.max_triples is not None else config.max_triples,
|
|
105
|
+
ignored_rules=ignored,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class RunPlan:
|
|
111
|
+
"""Everything shared by every file of one invocation."""
|
|
112
|
+
|
|
113
|
+
policy: Policy
|
|
114
|
+
selection: Selection
|
|
115
|
+
overrides: dict[str, str]
|
|
116
|
+
shapes: Graph | None
|
|
117
|
+
registry: RuleRegistry
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _lint_file(item: InputFile, graph: Graph, plan: RunPlan, args: argparse.Namespace) -> FileReport:
|
|
121
|
+
"""Lint one parsed file and record an execution outcome for every selected rule."""
|
|
122
|
+
ctx = engine.build_context(graph, plan.shapes, plan.policy)
|
|
123
|
+
if ctx.context.ownership.base_selected_nothing:
|
|
124
|
+
print(
|
|
125
|
+
f"TOOL002: warning: --base {plan.policy.base!r} selects none of the terms declared in {item.display}; "
|
|
126
|
+
"term-level rules have nothing to check (is the base a prefix of the term IRIs?)",
|
|
127
|
+
file=sys.stderr,
|
|
128
|
+
)
|
|
129
|
+
robot = (
|
|
130
|
+
engine.RobotRequest(item.path, args.owl_profile, args.robot_image, args.robot_timeout)
|
|
131
|
+
if args.owl_profile
|
|
132
|
+
else None
|
|
133
|
+
)
|
|
134
|
+
executions: list[RuleExecution] = []
|
|
135
|
+
findings: tuple[Finding, ...] = ()
|
|
136
|
+
run_ids = [s.rule_id for s in plan.selection.run]
|
|
137
|
+
if run_ids:
|
|
138
|
+
result = engine.execute(ctx, run_ids, registry=plan.registry, robot=robot)
|
|
139
|
+
findings, executions = result.findings, list(result.executions)
|
|
140
|
+
executions += [
|
|
141
|
+
RuleExecution(s.rule_id, ExecutionStatus.SKIPPED, "ignored by --ignore or profile")
|
|
142
|
+
for s in plan.selection.ignored
|
|
143
|
+
]
|
|
144
|
+
findings = tuple(replace(f, severity=plan.overrides.get(f.rule, f.severity)) for f in _legacy_order(findings, plan))
|
|
145
|
+
return FileReport(item.display, item.uri, findings, tuple(sorted(executions, key=lambda x: x.rule_id)))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _legacy_order(findings: Sequence[Finding], plan: RunPlan) -> list[Finding]:
|
|
149
|
+
"""Graph rules first (already sorted), then SHACL, then ROBOT: the order reports have always used."""
|
|
150
|
+
rank = {RuleBackend.RDFLIB: 0, RuleBackend.PYSHACL: 1, RuleBackend.ROBOT_DOCKER: 2}
|
|
151
|
+
backends = {s.rule_id: s.backend for s in plan.registry.all()}
|
|
152
|
+
return sorted(findings, key=lambda f: rank.get(backends.get(f.rule, RuleBackend.RDFLIB), 0))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _file_fingerprints(report: FileReport, multi: bool) -> set[str]:
|
|
156
|
+
return {fingerprint(f, report.path if multi else None) for f in report.findings}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _apply_baselines(files: list[FileReport], args: argparse.Namespace) -> list[FileReport]:
|
|
160
|
+
multi = len(files) > 1
|
|
161
|
+
if args.write_baseline:
|
|
162
|
+
everything = sorted(set().union(*(_file_fingerprints(r, multi) for r in files)))
|
|
163
|
+
Path(args.write_baseline).write_text(
|
|
164
|
+
json.dumps({"fingerprints": everything}, indent=2) + "\n", encoding="utf-8"
|
|
165
|
+
)
|
|
166
|
+
if args.baseline:
|
|
167
|
+
prior = set(json.loads(Path(args.baseline).read_text(encoding="utf-8")).get("fingerprints", []))
|
|
168
|
+
files = [
|
|
169
|
+
replace(r, findings=tuple(f for f in r.findings if fingerprint(f, r.path if multi else None) not in prior))
|
|
170
|
+
for r in files
|
|
171
|
+
]
|
|
172
|
+
return files
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
_LOADER_POLICY: ContextVar[Policy | None] = ContextVar("loader_policy", default=None)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_inputs(inputs: list[InputFile]) -> tuple[list[Graph], list[str]]:
|
|
179
|
+
"""Parse every input safely (no network, bounded size); return the graphs and one message per failure.
|
|
180
|
+
|
|
181
|
+
The limits come from the policy of the current run (:data:`_LOADER_POLICY`, set by :func:`_lint`).
|
|
182
|
+
"""
|
|
183
|
+
policy = _LOADER_POLICY.get() or Policy()
|
|
184
|
+
graphs: list[Graph] = []
|
|
185
|
+
errors: list[str] = []
|
|
186
|
+
for item in inputs:
|
|
187
|
+
try:
|
|
188
|
+
graphs.append(load_graph(item, policy))
|
|
189
|
+
except InputRejected as exc:
|
|
190
|
+
where = f" for {item.display}" if len(inputs) > 1 and item.display not in exc.message else ""
|
|
191
|
+
errors.append(f"{exc.rule_id}: {exc.message}{where}")
|
|
192
|
+
except Exception as exc: # noqa: BLE001 -- any other failure (unreadable file ...) is a reportable input error.
|
|
193
|
+
errors.append(_parse_error(exc, item.display if len(inputs) > 1 else None))
|
|
194
|
+
return graphs, errors
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_error(exc: Exception, display: str | None) -> str:
|
|
198
|
+
"""The ``INPUT001`` line for a parse failure; ``display`` names the file when several were given."""
|
|
199
|
+
where = f" for {display}" if display else ""
|
|
200
|
+
name, detail = (exc.cause_type, exc.detail) if isinstance(exc, ParseFailure) else (type(exc).__name__, str(exc))
|
|
201
|
+
return f"INPUT001: RDF input parsing failed{where}: {name}: {detail}"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _info_command(args: argparse.Namespace) -> int | None:
|
|
205
|
+
"""Handle --version, --list-rules and --explain; ``None`` means a normal lint run."""
|
|
206
|
+
registry = default_registry()
|
|
207
|
+
if args.version:
|
|
208
|
+
print(f"ontolint {package_version()}")
|
|
209
|
+
elif args.list_rules:
|
|
210
|
+
print(render_rule_list(rule_rows(registry), "json" if args.format != "text" else "text"))
|
|
211
|
+
elif args.explain:
|
|
212
|
+
try:
|
|
213
|
+
print(render_explanation(explain_rule(args.explain, registry), "json" if args.format != "text" else "text"))
|
|
214
|
+
except UnknownRuleError:
|
|
215
|
+
print(f"TOOL001: unknown rule {args.explain!r}; use --list-rules to see valid identifiers", file=sys.stderr)
|
|
216
|
+
return 2
|
|
217
|
+
else:
|
|
218
|
+
return None
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _profile(args: argparse.Namespace) -> Profile | None:
|
|
223
|
+
return PROFILES[args.profile] if args.profile else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _plan(args: argparse.Namespace, config: Policy, shapes: Graph | None, registry: RuleRegistry) -> RunPlan:
|
|
227
|
+
profile = _profile(args)
|
|
228
|
+
selection = resolve_selection(registry, profile, split_selectors(args.select), split_selectors(args.ignore))
|
|
229
|
+
overrides = {**dict(profile.severity_overrides if profile else ()), **config.severity_mapping}
|
|
230
|
+
return RunPlan(_policy(args, config, config.ignored_rules), selection, overrides, shapes, registry)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _emit(output: str, args: argparse.Namespace) -> None:
|
|
234
|
+
if args.output:
|
|
235
|
+
Path(args.output).write_text(output + "\n", encoding="utf-8")
|
|
236
|
+
else:
|
|
237
|
+
print(output)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _lint(args: argparse.Namespace, stage: list[str]) -> RunReport | int:
|
|
241
|
+
"""Run the whole lint; ``stage`` tracks progress for error messages. Returns a report or an exit code."""
|
|
242
|
+
registry = default_registry()
|
|
243
|
+
config = Policy.from_mapping(json.loads(Path(args.config).read_text(encoding="utf-8")) if args.config else {})
|
|
244
|
+
stage[0] = "RDF input parsing"
|
|
245
|
+
limits = _policy(args, config, config.ignored_rules)
|
|
246
|
+
inputs = resolve_inputs(args.ontology, Path.cwd())
|
|
247
|
+
_LOADER_POLICY.set(limits)
|
|
248
|
+
graphs, errors = _parse_inputs(inputs)
|
|
249
|
+
if errors:
|
|
250
|
+
print("\n".join(errors), file=sys.stderr)
|
|
251
|
+
return 2
|
|
252
|
+
shapes: Graph | None = None
|
|
253
|
+
if args.shapes:
|
|
254
|
+
try:
|
|
255
|
+
shapes = load_graph(resolve_inputs([args.shapes], Path.cwd())[0], limits)
|
|
256
|
+
except InputRejected as exc:
|
|
257
|
+
print(f"{exc.rule_id}: {exc.message}", file=sys.stderr)
|
|
258
|
+
return 2
|
|
259
|
+
except ParseFailure as exc:
|
|
260
|
+
print(_parse_error(exc, None), file=sys.stderr)
|
|
261
|
+
return 2
|
|
262
|
+
stage[0] = "lint execution"
|
|
263
|
+
plan = _plan(args, config, shapes, registry)
|
|
264
|
+
files = [_lint_file(item, graph, plan, args) for item, graph in zip(inputs, graphs, strict=True)]
|
|
265
|
+
return RunReport(tuple(_apply_baselines(files, args)), plan.selection.run + plan.selection.ignored, plan.overrides)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
269
|
+
"""Run the linter CLI and return its process exit code (0 clean, 1 gated findings, 2 tool failure)."""
|
|
270
|
+
parser = build_parser()
|
|
271
|
+
args = parser.parse_args(argv)
|
|
272
|
+
info = _info_command(args)
|
|
273
|
+
if info is not None:
|
|
274
|
+
return info
|
|
275
|
+
if not args.ontology:
|
|
276
|
+
parser.error("at least one ONTOLOGY path is required")
|
|
277
|
+
stage = ["configuration"]
|
|
278
|
+
try:
|
|
279
|
+
outcome = _lint(args, stage)
|
|
280
|
+
except (UnknownSelectorError, SelectorSyntaxError) as exc:
|
|
281
|
+
proposed = frozenset(row.rule_id for row in planned_rows())
|
|
282
|
+
message = unknown_selector_help(exc, proposed) if isinstance(exc, UnknownSelectorError) else str(exc)
|
|
283
|
+
print(f"TOOL001: configuration failed: {type(exc).__name__}: {message}", file=sys.stderr)
|
|
284
|
+
return 2
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
# Parser/configuration errors are tool failures, not SHACL conformance results.
|
|
287
|
+
code = "INPUT001" if stage[0] == "RDF input parsing" else "TOOL001"
|
|
288
|
+
print(f"{code}: {stage[0]} failed: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
289
|
+
return 2
|
|
290
|
+
if isinstance(outcome, int):
|
|
291
|
+
return outcome
|
|
292
|
+
return _finish(outcome, args)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _finish(report: RunReport, args: argparse.Namespace) -> int:
|
|
296
|
+
profile = _profile(args)
|
|
297
|
+
fail_on = args.fail_on or (profile.fail_on if profile else "error")
|
|
298
|
+
code = run_exit_code(report, fail_on)
|
|
299
|
+
_emit(render_report(report, args.format, code), args)
|
|
300
|
+
for path, x in report.broken:
|
|
301
|
+
print(f"TOOL001: rule {x.rule_id} {x.status.value} for {path}: {x.detail}", file=sys.stderr)
|
|
302
|
+
return code
|