rdf-construct 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.
- rdf_construct/__init__.py +12 -0
- rdf_construct/__main__.py +0 -0
- rdf_construct/cli.py +1762 -0
- rdf_construct/core/__init__.py +33 -0
- rdf_construct/core/config.py +116 -0
- rdf_construct/core/ordering.py +219 -0
- rdf_construct/core/predicate_order.py +212 -0
- rdf_construct/core/profile.py +157 -0
- rdf_construct/core/selector.py +64 -0
- rdf_construct/core/serialiser.py +232 -0
- rdf_construct/core/utils.py +89 -0
- rdf_construct/cq/__init__.py +77 -0
- rdf_construct/cq/expectations.py +365 -0
- rdf_construct/cq/formatters/__init__.py +45 -0
- rdf_construct/cq/formatters/json.py +104 -0
- rdf_construct/cq/formatters/junit.py +104 -0
- rdf_construct/cq/formatters/text.py +146 -0
- rdf_construct/cq/loader.py +300 -0
- rdf_construct/cq/runner.py +321 -0
- rdf_construct/diff/__init__.py +59 -0
- rdf_construct/diff/change_types.py +214 -0
- rdf_construct/diff/comparator.py +338 -0
- rdf_construct/diff/filters.py +133 -0
- rdf_construct/diff/formatters/__init__.py +71 -0
- rdf_construct/diff/formatters/json.py +192 -0
- rdf_construct/diff/formatters/markdown.py +210 -0
- rdf_construct/diff/formatters/text.py +195 -0
- rdf_construct/docs/__init__.py +60 -0
- rdf_construct/docs/config.py +238 -0
- rdf_construct/docs/extractors.py +603 -0
- rdf_construct/docs/generator.py +360 -0
- rdf_construct/docs/renderers/__init__.py +7 -0
- rdf_construct/docs/renderers/html.py +803 -0
- rdf_construct/docs/renderers/json.py +390 -0
- rdf_construct/docs/renderers/markdown.py +628 -0
- rdf_construct/docs/search.py +278 -0
- rdf_construct/docs/templates/html/base.html.jinja +44 -0
- rdf_construct/docs/templates/html/class.html.jinja +152 -0
- rdf_construct/docs/templates/html/hierarchy.html.jinja +28 -0
- rdf_construct/docs/templates/html/index.html.jinja +110 -0
- rdf_construct/docs/templates/html/instance.html.jinja +90 -0
- rdf_construct/docs/templates/html/namespaces.html.jinja +37 -0
- rdf_construct/docs/templates/html/property.html.jinja +124 -0
- rdf_construct/docs/templates/html/single_page.html.jinja +169 -0
- rdf_construct/lint/__init__.py +75 -0
- rdf_construct/lint/config.py +214 -0
- rdf_construct/lint/engine.py +396 -0
- rdf_construct/lint/formatters.py +327 -0
- rdf_construct/lint/rules.py +692 -0
- rdf_construct/main.py +6 -0
- rdf_construct/puml2rdf/__init__.py +103 -0
- rdf_construct/puml2rdf/config.py +230 -0
- rdf_construct/puml2rdf/converter.py +420 -0
- rdf_construct/puml2rdf/merger.py +200 -0
- rdf_construct/puml2rdf/model.py +202 -0
- rdf_construct/puml2rdf/parser.py +565 -0
- rdf_construct/puml2rdf/validators.py +451 -0
- rdf_construct/shacl/__init__.py +56 -0
- rdf_construct/shacl/config.py +166 -0
- rdf_construct/shacl/converters.py +520 -0
- rdf_construct/shacl/generator.py +364 -0
- rdf_construct/shacl/namespaces.py +93 -0
- rdf_construct/stats/__init__.py +29 -0
- rdf_construct/stats/collector.py +178 -0
- rdf_construct/stats/comparator.py +298 -0
- rdf_construct/stats/formatters/__init__.py +83 -0
- rdf_construct/stats/formatters/json.py +38 -0
- rdf_construct/stats/formatters/markdown.py +153 -0
- rdf_construct/stats/formatters/text.py +186 -0
- rdf_construct/stats/metrics/__init__.py +26 -0
- rdf_construct/stats/metrics/basic.py +147 -0
- rdf_construct/stats/metrics/complexity.py +137 -0
- rdf_construct/stats/metrics/connectivity.py +130 -0
- rdf_construct/stats/metrics/documentation.py +128 -0
- rdf_construct/stats/metrics/hierarchy.py +207 -0
- rdf_construct/stats/metrics/properties.py +88 -0
- rdf_construct/uml/__init__.py +22 -0
- rdf_construct/uml/context.py +194 -0
- rdf_construct/uml/mapper.py +371 -0
- rdf_construct/uml/odm_renderer.py +789 -0
- rdf_construct/uml/renderer.py +684 -0
- rdf_construct/uml/uml_layout.py +393 -0
- rdf_construct/uml/uml_style.py +613 -0
- rdf_construct-0.2.0.dist-info/METADATA +431 -0
- rdf_construct-0.2.0.dist-info/RECORD +88 -0
- rdf_construct-0.2.0.dist-info/WHEEL +4 -0
- rdf_construct-0.2.0.dist-info/entry_points.txt +3 -0
- rdf_construct-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Configuration file handling for rdf-construct lint.
|
|
2
|
+
|
|
3
|
+
Supports loading .rdf-lint.yml files with rule settings and severity overrides.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from rdf_construct.lint.engine import LintConfig
|
|
14
|
+
from rdf_construct.lint.rules import Severity, list_rules
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_lint_config(config_path: Path) -> LintConfig:
|
|
18
|
+
"""Load lint configuration from a YAML file.
|
|
19
|
+
|
|
20
|
+
The configuration file format:
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
# Global settings
|
|
24
|
+
level: standard # strict | standard | relaxed
|
|
25
|
+
|
|
26
|
+
# Rules to enable (empty means all)
|
|
27
|
+
enable:
|
|
28
|
+
- orphan-class
|
|
29
|
+
- missing-label
|
|
30
|
+
|
|
31
|
+
# Rules to disable
|
|
32
|
+
disable:
|
|
33
|
+
- inconsistent-naming
|
|
34
|
+
|
|
35
|
+
# Override severity for specific rules
|
|
36
|
+
severity:
|
|
37
|
+
missing-comment: info
|
|
38
|
+
orphan-class: warning
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
config_path: Path to the YAML configuration file.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
LintConfig with settings from the file.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
FileNotFoundError: If config file doesn't exist.
|
|
49
|
+
ValueError: If config file is invalid.
|
|
50
|
+
"""
|
|
51
|
+
if not config_path.exists():
|
|
52
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
53
|
+
|
|
54
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
55
|
+
try:
|
|
56
|
+
data = yaml.safe_load(f)
|
|
57
|
+
except yaml.YAMLError as e:
|
|
58
|
+
raise ValueError(f"Invalid YAML in config file: {e}")
|
|
59
|
+
|
|
60
|
+
if data is None:
|
|
61
|
+
data = {}
|
|
62
|
+
|
|
63
|
+
return _parse_config(data, config_path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_config(data: dict[str, Any], source: Path) -> LintConfig:
|
|
67
|
+
"""Parse configuration dictionary into LintConfig.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
data: Configuration dictionary from YAML.
|
|
71
|
+
source: Source file path (for error messages).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Parsed LintConfig.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If configuration is invalid.
|
|
78
|
+
"""
|
|
79
|
+
config = LintConfig()
|
|
80
|
+
known_rules = set(list_rules())
|
|
81
|
+
|
|
82
|
+
# Parse level
|
|
83
|
+
if "level" in data:
|
|
84
|
+
level = data["level"]
|
|
85
|
+
if level not in ("strict", "standard", "relaxed"):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Invalid level '{level}' in {source}. "
|
|
88
|
+
"Must be 'strict', 'standard', or 'relaxed'."
|
|
89
|
+
)
|
|
90
|
+
config.level = level
|
|
91
|
+
|
|
92
|
+
# Parse enabled rules
|
|
93
|
+
if "enable" in data:
|
|
94
|
+
enabled = data["enable"]
|
|
95
|
+
if not isinstance(enabled, list):
|
|
96
|
+
raise ValueError(f"'enable' must be a list in {source}")
|
|
97
|
+
|
|
98
|
+
for rule_id in enabled:
|
|
99
|
+
if rule_id not in known_rules:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Unknown rule '{rule_id}' in 'enable' section of {source}. "
|
|
102
|
+
f"Available rules: {', '.join(sorted(known_rules))}"
|
|
103
|
+
)
|
|
104
|
+
config.enabled_rules.add(rule_id)
|
|
105
|
+
|
|
106
|
+
# Parse disabled rules
|
|
107
|
+
if "disable" in data:
|
|
108
|
+
disabled = data["disable"]
|
|
109
|
+
if not isinstance(disabled, list):
|
|
110
|
+
raise ValueError(f"'disable' must be a list in {source}")
|
|
111
|
+
|
|
112
|
+
for rule_id in disabled:
|
|
113
|
+
if rule_id not in known_rules:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"Unknown rule '{rule_id}' in 'disable' section of {source}. "
|
|
116
|
+
f"Available rules: {', '.join(sorted(known_rules))}"
|
|
117
|
+
)
|
|
118
|
+
config.disabled_rules.add(rule_id)
|
|
119
|
+
|
|
120
|
+
# Parse severity overrides
|
|
121
|
+
if "severity" in data:
|
|
122
|
+
severities = data["severity"]
|
|
123
|
+
if not isinstance(severities, dict):
|
|
124
|
+
raise ValueError(f"'severity' must be a mapping in {source}")
|
|
125
|
+
|
|
126
|
+
for rule_id, sev_str in severities.items():
|
|
127
|
+
if rule_id not in known_rules:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Unknown rule '{rule_id}' in 'severity' section of {source}. "
|
|
130
|
+
f"Available rules: {', '.join(sorted(known_rules))}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
severity = Severity(sev_str)
|
|
135
|
+
except ValueError:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"Invalid severity '{sev_str}' for rule '{rule_id}' in {source}. "
|
|
138
|
+
"Must be 'error', 'warning', or 'info'."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
config.severity_overrides[rule_id] = severity
|
|
142
|
+
|
|
143
|
+
return config
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def find_config_file(start_dir: Path | None = None) -> Path | None:
|
|
147
|
+
"""Find a lint config file by searching up the directory tree.
|
|
148
|
+
|
|
149
|
+
Looks for files named '.rdf-lint.yml' or '.rdf-lint.yaml' starting
|
|
150
|
+
from start_dir and moving up to the filesystem root.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
start_dir: Directory to start searching from. Defaults to cwd.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Path to config file if found, None otherwise.
|
|
157
|
+
"""
|
|
158
|
+
if start_dir is None:
|
|
159
|
+
start_dir = Path.cwd()
|
|
160
|
+
|
|
161
|
+
config_names = [".rdf-lint.yml", ".rdf-lint.yaml", "rdf-lint.yml", "rdf-lint.yaml"]
|
|
162
|
+
|
|
163
|
+
current = start_dir.resolve()
|
|
164
|
+
|
|
165
|
+
while True:
|
|
166
|
+
for name in config_names:
|
|
167
|
+
config_path = current / name
|
|
168
|
+
if config_path.exists():
|
|
169
|
+
return config_path
|
|
170
|
+
|
|
171
|
+
parent = current.parent
|
|
172
|
+
if parent == current:
|
|
173
|
+
# Reached filesystem root
|
|
174
|
+
break
|
|
175
|
+
current = parent
|
|
176
|
+
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def create_default_config() -> str:
|
|
181
|
+
"""Generate a default configuration file as a string.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
YAML string with commented default configuration.
|
|
185
|
+
"""
|
|
186
|
+
known_rules = sorted(list_rules())
|
|
187
|
+
|
|
188
|
+
return f"""\
|
|
189
|
+
# rdf-construct lint configuration
|
|
190
|
+
# Place this file as .rdf-lint.yml in your project root
|
|
191
|
+
|
|
192
|
+
# Strictness level: strict | standard | relaxed
|
|
193
|
+
# - strict: warnings become errors
|
|
194
|
+
# - standard: default severities
|
|
195
|
+
# - relaxed: warnings become info
|
|
196
|
+
level: standard
|
|
197
|
+
|
|
198
|
+
# Enable only specific rules (empty = all rules)
|
|
199
|
+
# enable:
|
|
200
|
+
# - orphan-class
|
|
201
|
+
# - missing-label
|
|
202
|
+
|
|
203
|
+
# Disable specific rules
|
|
204
|
+
# disable:
|
|
205
|
+
# - inconsistent-naming
|
|
206
|
+
|
|
207
|
+
# Override severity for specific rules
|
|
208
|
+
# severity:
|
|
209
|
+
# missing-comment: info
|
|
210
|
+
# orphan-class: warning
|
|
211
|
+
|
|
212
|
+
# Available rules:
|
|
213
|
+
# {chr(10).join(f'# - {r}' for r in known_rules)}
|
|
214
|
+
"""
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Lint engine for running rules against RDF graphs.
|
|
2
|
+
|
|
3
|
+
The engine coordinates rule execution, applies configuration overrides,
|
|
4
|
+
and collects results for reporting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Sequence
|
|
13
|
+
|
|
14
|
+
from rdflib import Graph, URIRef
|
|
15
|
+
|
|
16
|
+
from rdf_construct.lint.rules import (
|
|
17
|
+
get_all_rules,
|
|
18
|
+
get_rule,
|
|
19
|
+
LintIssue,
|
|
20
|
+
RuleSpec,
|
|
21
|
+
Severity,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_line_number(file_path: Path, entity: URIRef) -> int | None:
|
|
26
|
+
"""Find approximate line number for an entity's definition in a Turtle file.
|
|
27
|
+
|
|
28
|
+
Prioritises finding the entity as a subject (its definition) rather than
|
|
29
|
+
as a predicate or object.
|
|
30
|
+
"""
|
|
31
|
+
if not file_path.exists():
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
uri_str = str(entity)
|
|
35
|
+
|
|
36
|
+
# Extract local name
|
|
37
|
+
if "#" in uri_str:
|
|
38
|
+
local_name = uri_str.split("#")[-1]
|
|
39
|
+
elif "/" in uri_str:
|
|
40
|
+
local_name = uri_str.rsplit("/", 1)[-1]
|
|
41
|
+
else:
|
|
42
|
+
local_name = uri_str
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
46
|
+
lines = f.readlines()
|
|
47
|
+
except (IOError, UnicodeDecodeError):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
# First pass: look for entity as a SUBJECT (definition)
|
|
51
|
+
# Pattern: entity at start of line (after optional whitespace), followed by 'a' or predicate
|
|
52
|
+
subject_patterns = [
|
|
53
|
+
# Full URI as subject
|
|
54
|
+
rf"^\s*<{re.escape(uri_str)}>\s+",
|
|
55
|
+
# Prefixed form as subject at start of line
|
|
56
|
+
rf"^\s*\w+:{re.escape(local_name)}\s+",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
for pattern in subject_patterns:
|
|
60
|
+
regex = re.compile(pattern)
|
|
61
|
+
for i, line in enumerate(lines, start=1):
|
|
62
|
+
if regex.match(line):
|
|
63
|
+
return i
|
|
64
|
+
|
|
65
|
+
# Second pass: find any occurrence (fallback)
|
|
66
|
+
fallback_patterns = [
|
|
67
|
+
re.escape(f"<{uri_str}>"),
|
|
68
|
+
rf"\b\w+:{re.escape(local_name)}\b",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
for pattern in fallback_patterns:
|
|
72
|
+
regex = re.compile(pattern)
|
|
73
|
+
for i, line in enumerate(lines, start=1):
|
|
74
|
+
if regex.search(line):
|
|
75
|
+
return i
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class LintConfig:
|
|
82
|
+
"""Configuration for a lint run.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
level: Strictness level (strict/standard/relaxed).
|
|
86
|
+
enabled_rules: Specific rules to enable (empty = all).
|
|
87
|
+
disabled_rules: Specific rules to disable.
|
|
88
|
+
severity_overrides: Override default severity for specific rules.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
level: str = "standard"
|
|
92
|
+
enabled_rules: set[str] = field(default_factory=set)
|
|
93
|
+
disabled_rules: set[str] = field(default_factory=set)
|
|
94
|
+
severity_overrides: dict[str, Severity] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
def get_effective_rules(self) -> list[RuleSpec]:
|
|
97
|
+
"""Get the list of rules that should run based on config.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of RuleSpec objects to execute.
|
|
101
|
+
"""
|
|
102
|
+
all_rules = get_all_rules()
|
|
103
|
+
|
|
104
|
+
# If specific rules enabled, use only those
|
|
105
|
+
if self.enabled_rules:
|
|
106
|
+
rules = [all_rules[r] for r in self.enabled_rules if r in all_rules]
|
|
107
|
+
else:
|
|
108
|
+
rules = list(all_rules.values())
|
|
109
|
+
|
|
110
|
+
# Remove disabled rules
|
|
111
|
+
rules = [r for r in rules if r.rule_id not in self.disabled_rules]
|
|
112
|
+
|
|
113
|
+
# Apply level filtering
|
|
114
|
+
if self.level == "relaxed":
|
|
115
|
+
# Relaxed: skip INFO-level rules
|
|
116
|
+
rules = [r for r in rules if r.default_severity != Severity.INFO]
|
|
117
|
+
elif self.level == "strict":
|
|
118
|
+
# Strict: all rules, but bump warnings to errors
|
|
119
|
+
pass # No filtering, severity handled in get_effective_severity
|
|
120
|
+
|
|
121
|
+
return rules
|
|
122
|
+
|
|
123
|
+
def get_effective_severity(self, rule_id: str) -> Severity:
|
|
124
|
+
"""Get the effective severity for a rule.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
rule_id: The rule identifier.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The severity to use for this rule.
|
|
131
|
+
"""
|
|
132
|
+
# Check for explicit override
|
|
133
|
+
if rule_id in self.severity_overrides:
|
|
134
|
+
return self.severity_overrides[rule_id]
|
|
135
|
+
|
|
136
|
+
rule = get_rule(rule_id)
|
|
137
|
+
if rule is None:
|
|
138
|
+
return Severity.ERROR
|
|
139
|
+
|
|
140
|
+
# Apply level adjustments
|
|
141
|
+
if self.level == "strict":
|
|
142
|
+
# In strict mode, warnings become errors
|
|
143
|
+
if rule.default_severity == Severity.WARNING:
|
|
144
|
+
return Severity.ERROR
|
|
145
|
+
elif self.level == "relaxed":
|
|
146
|
+
# In relaxed mode, warnings become info
|
|
147
|
+
if rule.default_severity == Severity.WARNING:
|
|
148
|
+
return Severity.INFO
|
|
149
|
+
|
|
150
|
+
return rule.default_severity
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class LintResult:
|
|
155
|
+
"""Result of linting a single file.
|
|
156
|
+
|
|
157
|
+
Attributes:
|
|
158
|
+
file_path: Path to the linted file.
|
|
159
|
+
graph: The parsed RDF graph (for namespace resolution).
|
|
160
|
+
issues: List of issues found.
|
|
161
|
+
error_count: Number of error-level issues.
|
|
162
|
+
warning_count: Number of warning-level issues.
|
|
163
|
+
info_count: Number of info-level issues.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
file_path: Path
|
|
167
|
+
graph: Graph | None = None
|
|
168
|
+
issues: list[LintIssue] = field(default_factory=list)
|
|
169
|
+
error_count: int = 0
|
|
170
|
+
warning_count: int = 0
|
|
171
|
+
info_count: int = 0
|
|
172
|
+
|
|
173
|
+
def add_issue(self, issue: LintIssue) -> None:
|
|
174
|
+
"""Add an issue and update counts."""
|
|
175
|
+
self.issues.append(issue)
|
|
176
|
+
if issue.severity == Severity.ERROR:
|
|
177
|
+
self.error_count += 1
|
|
178
|
+
elif issue.severity == Severity.WARNING:
|
|
179
|
+
self.warning_count += 1
|
|
180
|
+
else:
|
|
181
|
+
self.info_count += 1
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def total_issues(self) -> int:
|
|
185
|
+
"""Total number of issues found."""
|
|
186
|
+
return len(self.issues)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def has_errors(self) -> bool:
|
|
190
|
+
"""Whether any errors were found."""
|
|
191
|
+
return self.error_count > 0
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def has_warnings(self) -> bool:
|
|
195
|
+
"""Whether any warnings were found."""
|
|
196
|
+
return self.warning_count > 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class LintSummary:
|
|
201
|
+
"""Summary of linting multiple files.
|
|
202
|
+
|
|
203
|
+
Attributes:
|
|
204
|
+
results: Individual file results.
|
|
205
|
+
total_errors: Total errors across all files.
|
|
206
|
+
total_warnings: Total warnings across all files.
|
|
207
|
+
total_info: Total info messages across all files.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
results: list[LintResult] = field(default_factory=list)
|
|
211
|
+
total_errors: int = 0
|
|
212
|
+
total_warnings: int = 0
|
|
213
|
+
total_info: int = 0
|
|
214
|
+
|
|
215
|
+
def add_result(self, result: LintResult) -> None:
|
|
216
|
+
"""Add a file result and update totals."""
|
|
217
|
+
self.results.append(result)
|
|
218
|
+
self.total_errors += result.error_count
|
|
219
|
+
self.total_warnings += result.warning_count
|
|
220
|
+
self.total_info += result.info_count
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def exit_code(self) -> int:
|
|
224
|
+
"""Get appropriate exit code based on results.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
0 if no issues, 1 if warnings only, 2 if errors.
|
|
228
|
+
"""
|
|
229
|
+
if self.total_errors > 0:
|
|
230
|
+
return 2
|
|
231
|
+
if self.total_warnings > 0:
|
|
232
|
+
return 1
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def files_with_issues(self) -> int:
|
|
237
|
+
"""Number of files that had at least one issue."""
|
|
238
|
+
return sum(1 for r in self.results if r.total_issues > 0)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class LintEngine:
|
|
242
|
+
"""Engine for running lint rules against RDF graphs.
|
|
243
|
+
|
|
244
|
+
The engine loads graphs, runs configured rules, and collects results.
|
|
245
|
+
It supports linting single files or batches of files.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def __init__(self, config: LintConfig | None = None):
|
|
249
|
+
"""Initialise the lint engine.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
config: Configuration for the lint run. Defaults to standard config.
|
|
253
|
+
"""
|
|
254
|
+
self.config = config or LintConfig()
|
|
255
|
+
|
|
256
|
+
def _populate_line_numbers(self, result: LintResult, file_path: Path) -> None:
|
|
257
|
+
"""Add line numbers to issues by searching the source file."""
|
|
258
|
+
for issue in result.issues:
|
|
259
|
+
if issue.entity and issue.line is None:
|
|
260
|
+
issue.line = find_line_number(file_path, issue.entity)
|
|
261
|
+
|
|
262
|
+
def lint_file(self, file_path: Path) -> LintResult:
|
|
263
|
+
"""Lint a single RDF file.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
file_path: Path to the RDF file.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
LintResult containing all issues found.
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
FileNotFoundError: If file doesn't exist.
|
|
273
|
+
ValueError: If file can't be parsed.
|
|
274
|
+
"""
|
|
275
|
+
result = LintResult(file_path=file_path)
|
|
276
|
+
|
|
277
|
+
# Load the graph
|
|
278
|
+
graph = Graph()
|
|
279
|
+
try:
|
|
280
|
+
# Guess format from extension
|
|
281
|
+
suffix = file_path.suffix.lower()
|
|
282
|
+
if suffix in (".ttl", ".turtle"):
|
|
283
|
+
fmt = "turtle"
|
|
284
|
+
elif suffix in (".rdf", ".xml", ".owl"):
|
|
285
|
+
fmt = "xml"
|
|
286
|
+
elif suffix in (".nt", ".ntriples"):
|
|
287
|
+
fmt = "nt"
|
|
288
|
+
elif suffix in (".n3",):
|
|
289
|
+
fmt = "n3"
|
|
290
|
+
elif suffix in (".jsonld", ".json"):
|
|
291
|
+
fmt = "json-ld"
|
|
292
|
+
else:
|
|
293
|
+
fmt = "turtle" # Default
|
|
294
|
+
|
|
295
|
+
graph.parse(file_path.as_posix(), format=fmt)
|
|
296
|
+
result.graph = graph # Store for namespace resolution
|
|
297
|
+
except Exception as e:
|
|
298
|
+
# Return result with parse error
|
|
299
|
+
result.add_issue(
|
|
300
|
+
LintIssue(
|
|
301
|
+
rule_id="parse-error",
|
|
302
|
+
severity=Severity.ERROR,
|
|
303
|
+
entity=None,
|
|
304
|
+
message=f"Failed to parse file: {e}",
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
# Run rules
|
|
310
|
+
rules = self.config.get_effective_rules()
|
|
311
|
+
|
|
312
|
+
for rule in rules:
|
|
313
|
+
try:
|
|
314
|
+
issues = rule.check_fn(graph)
|
|
315
|
+
for issue in issues:
|
|
316
|
+
# Apply severity override
|
|
317
|
+
effective_severity = self.config.get_effective_severity(issue.rule_id)
|
|
318
|
+
adjusted_issue = LintIssue(
|
|
319
|
+
rule_id=issue.rule_id,
|
|
320
|
+
severity=effective_severity,
|
|
321
|
+
entity=issue.entity,
|
|
322
|
+
message=issue.message,
|
|
323
|
+
line=issue.line,
|
|
324
|
+
)
|
|
325
|
+
result.add_issue(adjusted_issue)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
# Rule execution error
|
|
328
|
+
result.add_issue(
|
|
329
|
+
LintIssue(
|
|
330
|
+
rule_id=f"rule-error:{rule.rule_id}",
|
|
331
|
+
severity=Severity.ERROR,
|
|
332
|
+
entity=None,
|
|
333
|
+
message=f"Rule '{rule.rule_id}' failed: {e}",
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Populate line numbers
|
|
338
|
+
self._populate_line_numbers(result, file_path)
|
|
339
|
+
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
def lint_files(self, file_paths: Sequence[Path]) -> LintSummary:
|
|
343
|
+
"""Lint multiple RDF files.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
file_paths: Paths to RDF files.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
LintSummary containing all results.
|
|
350
|
+
"""
|
|
351
|
+
summary = LintSummary()
|
|
352
|
+
|
|
353
|
+
for path in file_paths:
|
|
354
|
+
result = self.lint_file(path)
|
|
355
|
+
summary.add_result(result)
|
|
356
|
+
|
|
357
|
+
return summary
|
|
358
|
+
|
|
359
|
+
def lint_graph(self, graph: Graph, source_name: str = "<graph>") -> LintResult:
|
|
360
|
+
"""Lint an in-memory RDF graph.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
graph: The RDF graph to lint.
|
|
364
|
+
source_name: Name to use in result (for display).
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
LintResult containing all issues found.
|
|
368
|
+
"""
|
|
369
|
+
result = LintResult(file_path=Path(source_name), graph=graph)
|
|
370
|
+
|
|
371
|
+
rules = self.config.get_effective_rules()
|
|
372
|
+
|
|
373
|
+
for rule in rules:
|
|
374
|
+
try:
|
|
375
|
+
issues = rule.check_fn(graph)
|
|
376
|
+
for issue in issues:
|
|
377
|
+
effective_severity = self.config.get_effective_severity(issue.rule_id)
|
|
378
|
+
adjusted_issue = LintIssue(
|
|
379
|
+
rule_id=issue.rule_id,
|
|
380
|
+
severity=effective_severity,
|
|
381
|
+
entity=issue.entity,
|
|
382
|
+
message=issue.message,
|
|
383
|
+
line=issue.line,
|
|
384
|
+
)
|
|
385
|
+
result.add_issue(adjusted_issue)
|
|
386
|
+
except Exception as e:
|
|
387
|
+
result.add_issue(
|
|
388
|
+
LintIssue(
|
|
389
|
+
rule_id=f"rule-error:{rule.rule_id}",
|
|
390
|
+
severity=Severity.ERROR,
|
|
391
|
+
entity=None,
|
|
392
|
+
message=f"Rule '{rule.rule_id}' failed: {e}",
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
return result
|