rdf-construct 0.3.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 +3429 -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/localise/__init__.py +114 -0
- rdf_construct/localise/config.py +508 -0
- rdf_construct/localise/extractor.py +427 -0
- rdf_construct/localise/formatters/__init__.py +36 -0
- rdf_construct/localise/formatters/markdown.py +229 -0
- rdf_construct/localise/formatters/text.py +224 -0
- rdf_construct/localise/merger.py +346 -0
- rdf_construct/localise/reporter.py +356 -0
- rdf_construct/main.py +6 -0
- rdf_construct/merge/__init__.py +165 -0
- rdf_construct/merge/config.py +354 -0
- rdf_construct/merge/conflicts.py +281 -0
- rdf_construct/merge/formatters.py +426 -0
- rdf_construct/merge/merger.py +425 -0
- rdf_construct/merge/migrator.py +339 -0
- rdf_construct/merge/rules.py +377 -0
- rdf_construct/merge/splitter.py +1102 -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/refactor/__init__.py +72 -0
- rdf_construct/refactor/config.py +362 -0
- rdf_construct/refactor/deprecator.py +328 -0
- rdf_construct/refactor/formatters/__init__.py +8 -0
- rdf_construct/refactor/formatters/text.py +311 -0
- rdf_construct/refactor/renamer.py +294 -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.3.0.dist-info/METADATA +496 -0
- rdf_construct-0.3.0.dist-info/RECORD +110 -0
- rdf_construct-0.3.0.dist-info/WHEEL +4 -0
- rdf_construct-0.3.0.dist-info/entry_points.txt +3 -0
- rdf_construct-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Expectation types and matching logic for competency question tests.
|
|
2
|
+
|
|
3
|
+
Supports various expectation styles:
|
|
4
|
+
- Boolean: ASK queries returning true/false
|
|
5
|
+
- Existence: has_results, no_results
|
|
6
|
+
- Count: exact, min, max result counts
|
|
7
|
+
- Values: specific result bindings
|
|
8
|
+
- Contains: subset matching
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from rdflib import URIRef, Literal, BNode
|
|
16
|
+
from rdflib.query import Result
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CheckResult:
|
|
21
|
+
"""Result of an expectation check.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
passed: Whether the expectation was met
|
|
25
|
+
message: Human-readable explanation of the result
|
|
26
|
+
expected: String representation of expected value
|
|
27
|
+
actual: String representation of actual value
|
|
28
|
+
"""
|
|
29
|
+
passed: bool
|
|
30
|
+
message: str
|
|
31
|
+
expected: str = ""
|
|
32
|
+
actual: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Expectation(ABC):
|
|
36
|
+
"""Abstract base class for all expectation types."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def check(self, result: Result) -> CheckResult:
|
|
40
|
+
"""Check if the query result meets this expectation.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
result: SPARQL query result from rdflib
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
CheckResult with pass/fail status and explanation
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def describe(self) -> str:
|
|
52
|
+
"""Return a human-readable description of this expectation."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BooleanExpectation(Expectation):
|
|
57
|
+
"""Expectation for ASK queries returning true/false."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, expected: bool):
|
|
60
|
+
self.expected = expected
|
|
61
|
+
|
|
62
|
+
def check(self, result: Result) -> CheckResult:
|
|
63
|
+
# ASK queries return a boolean result
|
|
64
|
+
actual = bool(result)
|
|
65
|
+
passed = actual == self.expected
|
|
66
|
+
|
|
67
|
+
return CheckResult(
|
|
68
|
+
passed=passed,
|
|
69
|
+
message="ASK query matched" if passed else "ASK query did not match",
|
|
70
|
+
expected=str(self.expected),
|
|
71
|
+
actual=str(actual),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def describe(self) -> str:
|
|
75
|
+
return f"ASK = {self.expected}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class HasResultsExpectation(Expectation):
|
|
79
|
+
"""Expectation that a query returns at least one result."""
|
|
80
|
+
|
|
81
|
+
def check(self, result: Result) -> CheckResult:
|
|
82
|
+
# Convert to list to count (consumes the iterator)
|
|
83
|
+
results_list = list(result)
|
|
84
|
+
count = len(results_list)
|
|
85
|
+
passed = count > 0
|
|
86
|
+
|
|
87
|
+
return CheckResult(
|
|
88
|
+
passed=passed,
|
|
89
|
+
message=f"Found {count} result(s)" if passed else "No results found",
|
|
90
|
+
expected="≥1 results",
|
|
91
|
+
actual=f"{count} results",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def describe(self) -> str:
|
|
95
|
+
return "has results"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NoResultsExpectation(Expectation):
|
|
99
|
+
"""Expectation that a query returns zero results."""
|
|
100
|
+
|
|
101
|
+
def check(self, result: Result) -> CheckResult:
|
|
102
|
+
results_list = list(result)
|
|
103
|
+
count = len(results_list)
|
|
104
|
+
passed = count == 0
|
|
105
|
+
|
|
106
|
+
return CheckResult(
|
|
107
|
+
passed=passed,
|
|
108
|
+
message="No results (as expected)" if passed else f"Expected no results, got {count}",
|
|
109
|
+
expected="0 results",
|
|
110
|
+
actual=f"{count} results",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def describe(self) -> str:
|
|
114
|
+
return "no results"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class CountExpectation(Expectation):
|
|
119
|
+
"""Expectation for specific result counts.
|
|
120
|
+
|
|
121
|
+
Attributes:
|
|
122
|
+
exact: Exact count required (None if not specified)
|
|
123
|
+
min_count: Minimum count (inclusive)
|
|
124
|
+
max_count: Maximum count (inclusive)
|
|
125
|
+
"""
|
|
126
|
+
exact: int | None = None
|
|
127
|
+
min_count: int | None = None
|
|
128
|
+
max_count: int | None = None
|
|
129
|
+
|
|
130
|
+
def check(self, result: Result) -> CheckResult:
|
|
131
|
+
results_list = list(result)
|
|
132
|
+
actual = len(results_list)
|
|
133
|
+
|
|
134
|
+
if self.exact is not None:
|
|
135
|
+
passed = actual == self.exact
|
|
136
|
+
expected_str = f"exactly {self.exact}"
|
|
137
|
+
else:
|
|
138
|
+
passed = True
|
|
139
|
+
expected_parts = []
|
|
140
|
+
|
|
141
|
+
if self.min_count is not None:
|
|
142
|
+
if actual < self.min_count:
|
|
143
|
+
passed = False
|
|
144
|
+
expected_parts.append(f"≥{self.min_count}")
|
|
145
|
+
|
|
146
|
+
if self.max_count is not None:
|
|
147
|
+
if actual > self.max_count:
|
|
148
|
+
passed = False
|
|
149
|
+
expected_parts.append(f"≤{self.max_count}")
|
|
150
|
+
|
|
151
|
+
expected_str = " and ".join(expected_parts) if expected_parts else "any count"
|
|
152
|
+
|
|
153
|
+
if passed:
|
|
154
|
+
message = f"Count {actual} matches expectation"
|
|
155
|
+
else:
|
|
156
|
+
message = f"Count mismatch: expected {expected_str}, got {actual}"
|
|
157
|
+
|
|
158
|
+
return CheckResult(
|
|
159
|
+
passed=passed,
|
|
160
|
+
message=message,
|
|
161
|
+
expected=expected_str,
|
|
162
|
+
actual=str(actual),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def describe(self) -> str:
|
|
166
|
+
if self.exact is not None:
|
|
167
|
+
return f"count = {self.exact}"
|
|
168
|
+
parts = []
|
|
169
|
+
if self.min_count is not None:
|
|
170
|
+
parts.append(f"≥{self.min_count}")
|
|
171
|
+
if self.max_count is not None:
|
|
172
|
+
parts.append(f"≤{self.max_count}")
|
|
173
|
+
return "count " + " and ".join(parts) if parts else "any count"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class ValuesExpectation(Expectation):
|
|
178
|
+
"""Expectation for exact result values.
|
|
179
|
+
|
|
180
|
+
Checks that results match a specific set of bindings exactly.
|
|
181
|
+
"""
|
|
182
|
+
expected_results: list[dict[str, Any]] = field(default_factory=list)
|
|
183
|
+
|
|
184
|
+
def check(self, result: Result) -> CheckResult:
|
|
185
|
+
results_list = list(result)
|
|
186
|
+
actual_bindings = [self._normalize_row(row) for row in results_list]
|
|
187
|
+
expected_bindings = [self._normalize_dict(d) for d in self.expected_results]
|
|
188
|
+
|
|
189
|
+
# Sort for comparison
|
|
190
|
+
actual_sorted = sorted(actual_bindings, key=lambda x: str(sorted(x.items())))
|
|
191
|
+
expected_sorted = sorted(expected_bindings, key=lambda x: str(sorted(x.items())))
|
|
192
|
+
|
|
193
|
+
passed = actual_sorted == expected_sorted
|
|
194
|
+
|
|
195
|
+
if passed:
|
|
196
|
+
message = f"All {len(expected_bindings)} expected result(s) matched"
|
|
197
|
+
else:
|
|
198
|
+
message = "Result values do not match expected"
|
|
199
|
+
|
|
200
|
+
return CheckResult(
|
|
201
|
+
passed=passed,
|
|
202
|
+
message=message,
|
|
203
|
+
expected=str(expected_bindings),
|
|
204
|
+
actual=str(actual_bindings),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _normalize_row(self, row) -> dict[str, str]:
|
|
208
|
+
"""Normalize a result row for comparison."""
|
|
209
|
+
# rdflib ResultRow has asdict() method
|
|
210
|
+
if hasattr(row, 'asdict'):
|
|
211
|
+
row_dict = row.asdict()
|
|
212
|
+
else:
|
|
213
|
+
row_dict = dict(row)
|
|
214
|
+
return {str(k): self._term_to_string(v) for k, v in row_dict.items()}
|
|
215
|
+
|
|
216
|
+
def _normalize_dict(self, d: dict) -> dict[str, str]:
|
|
217
|
+
"""Normalize an expected dict for comparison."""
|
|
218
|
+
return {str(k): str(v) for k, v in d.items()}
|
|
219
|
+
|
|
220
|
+
def _term_to_string(self, term: Any) -> str:
|
|
221
|
+
"""Convert an RDF term to a comparable string."""
|
|
222
|
+
if isinstance(term, URIRef):
|
|
223
|
+
return str(term)
|
|
224
|
+
elif isinstance(term, Literal):
|
|
225
|
+
return str(term.toPython()) if term.toPython() is not None else str(term)
|
|
226
|
+
elif isinstance(term, BNode):
|
|
227
|
+
return f"_:{term}"
|
|
228
|
+
return str(term)
|
|
229
|
+
|
|
230
|
+
def describe(self) -> str:
|
|
231
|
+
return f"values = {len(self.expected_results)} row(s)"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class ContainsExpectation(Expectation):
|
|
236
|
+
"""Expectation that results contain certain bindings (subset match).
|
|
237
|
+
|
|
238
|
+
Unlike ValuesExpectation, this doesn't require exact match -
|
|
239
|
+
it only checks that the expected bindings are present.
|
|
240
|
+
"""
|
|
241
|
+
expected_bindings: list[dict[str, Any]] = field(default_factory=list)
|
|
242
|
+
|
|
243
|
+
def check(self, result: Result) -> CheckResult:
|
|
244
|
+
results_list = list(result)
|
|
245
|
+
actual_bindings = [self._normalize_row(row) for row in results_list]
|
|
246
|
+
|
|
247
|
+
missing = []
|
|
248
|
+
for expected in self.expected_bindings:
|
|
249
|
+
normalized_expected = self._normalize_dict(expected)
|
|
250
|
+
found = False
|
|
251
|
+
for actual in actual_bindings:
|
|
252
|
+
if self._matches(actual, normalized_expected):
|
|
253
|
+
found = True
|
|
254
|
+
break
|
|
255
|
+
if not found:
|
|
256
|
+
missing.append(normalized_expected)
|
|
257
|
+
|
|
258
|
+
passed = len(missing) == 0
|
|
259
|
+
|
|
260
|
+
if passed:
|
|
261
|
+
message = f"All {len(self.expected_bindings)} expected binding(s) found"
|
|
262
|
+
else:
|
|
263
|
+
message = f"Missing {len(missing)} expected binding(s)"
|
|
264
|
+
|
|
265
|
+
return CheckResult(
|
|
266
|
+
passed=passed,
|
|
267
|
+
message=message,
|
|
268
|
+
expected=str(self.expected_bindings),
|
|
269
|
+
actual=f"Missing: {missing}" if missing else "All present",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _matches(self, actual: dict[str, str], expected: dict[str, str]) -> bool:
|
|
273
|
+
"""Check if actual contains all expected key-value pairs."""
|
|
274
|
+
for key, value in expected.items():
|
|
275
|
+
if key not in actual or actual[key] != value:
|
|
276
|
+
return False
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
def _normalize_row(self, row) -> dict[str, str]:
|
|
280
|
+
"""Normalize a result row for comparison."""
|
|
281
|
+
# rdflib ResultRow has asdict() method
|
|
282
|
+
if hasattr(row, 'asdict'):
|
|
283
|
+
row_dict = row.asdict()
|
|
284
|
+
else:
|
|
285
|
+
row_dict = dict(row)
|
|
286
|
+
return {str(k): self._term_to_string(v) for k, v in row_dict.items()}
|
|
287
|
+
|
|
288
|
+
def _normalize_dict(self, d: dict) -> dict[str, str]:
|
|
289
|
+
"""Normalize an expected dict for comparison."""
|
|
290
|
+
return {str(k): str(v) for k, v in d.items()}
|
|
291
|
+
|
|
292
|
+
def _term_to_string(self, term: Any) -> str:
|
|
293
|
+
"""Convert an RDF term to a comparable string."""
|
|
294
|
+
if isinstance(term, URIRef):
|
|
295
|
+
return str(term)
|
|
296
|
+
elif isinstance(term, Literal):
|
|
297
|
+
return str(term.toPython()) if term.toPython() is not None else str(term)
|
|
298
|
+
elif isinstance(term, BNode):
|
|
299
|
+
return f"_:{term}"
|
|
300
|
+
return str(term)
|
|
301
|
+
|
|
302
|
+
def describe(self) -> str:
|
|
303
|
+
return f"contains {len(self.expected_bindings)} binding(s)"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def parse_expectation(expect: Any) -> Expectation:
|
|
307
|
+
"""Parse an expectation from YAML configuration.
|
|
308
|
+
|
|
309
|
+
Supports multiple formats:
|
|
310
|
+
- Boolean: true/false for ASK queries
|
|
311
|
+
- String: "has_results" or "no_results"
|
|
312
|
+
- Dict with count: {"count": 5}, {"min_results": 1}, {"max_results": 10}
|
|
313
|
+
- Dict with results: {"results": [{"var": "value"}]}
|
|
314
|
+
- Dict with contains: {"contains": [{"var": "value"}]}
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
expect: Raw expectation value from YAML
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Appropriate Expectation subclass instance
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
ValueError: If expectation format is unrecognised
|
|
324
|
+
"""
|
|
325
|
+
# Boolean for ASK queries
|
|
326
|
+
if isinstance(expect, bool):
|
|
327
|
+
return BooleanExpectation(expect)
|
|
328
|
+
|
|
329
|
+
# String shortcuts
|
|
330
|
+
if isinstance(expect, str):
|
|
331
|
+
if expect == "has_results":
|
|
332
|
+
return HasResultsExpectation()
|
|
333
|
+
elif expect == "no_results":
|
|
334
|
+
return NoResultsExpectation()
|
|
335
|
+
elif expect.lower() == "true":
|
|
336
|
+
return BooleanExpectation(True)
|
|
337
|
+
elif expect.lower() == "false":
|
|
338
|
+
return BooleanExpectation(False)
|
|
339
|
+
else:
|
|
340
|
+
raise ValueError(f"Unknown expectation string: {expect}")
|
|
341
|
+
|
|
342
|
+
# Dict with specific keys
|
|
343
|
+
if isinstance(expect, dict):
|
|
344
|
+
# Exact count
|
|
345
|
+
if "count" in expect:
|
|
346
|
+
return CountExpectation(exact=expect["count"])
|
|
347
|
+
|
|
348
|
+
# Min/max count
|
|
349
|
+
if "min_results" in expect or "max_results" in expect:
|
|
350
|
+
return CountExpectation(
|
|
351
|
+
min_count=expect.get("min_results"),
|
|
352
|
+
max_count=expect.get("max_results"),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Exact values
|
|
356
|
+
if "results" in expect:
|
|
357
|
+
return ValuesExpectation(expected_results=expect["results"])
|
|
358
|
+
|
|
359
|
+
# Subset contains
|
|
360
|
+
if "contains" in expect:
|
|
361
|
+
return ContainsExpectation(expected_bindings=expect["contains"])
|
|
362
|
+
|
|
363
|
+
raise ValueError(f"Unknown expectation dict keys: {expect.keys()}")
|
|
364
|
+
|
|
365
|
+
raise ValueError(f"Unknown expectation format: {type(expect).__name__}")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Output formatters for competency question test results.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- text: Human-readable console output
|
|
5
|
+
- json: Structured JSON for programmatic use
|
|
6
|
+
- junit: JUnit XML for CI integration
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from rdf_construct.cq.formatters.text import format_text
|
|
10
|
+
from rdf_construct.cq.formatters.json import format_json
|
|
11
|
+
from rdf_construct.cq.formatters.junit import format_junit
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"format_text",
|
|
15
|
+
"format_json",
|
|
16
|
+
"format_junit",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def format_results(results, format_name: str = "text",
|
|
21
|
+
verbose: bool = False) -> str:
|
|
22
|
+
"""Format test results using the specified formatter.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
results: CQTestResults to format
|
|
26
|
+
format_name: One of "text", "json", "junit"
|
|
27
|
+
verbose: Include verbose details
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Formatted string
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If format_name is unknown
|
|
34
|
+
"""
|
|
35
|
+
formatters = {
|
|
36
|
+
"text": lambda r, v: format_text(r, verbose=v),
|
|
37
|
+
"json": lambda r, v: format_json(r, verbose=v),
|
|
38
|
+
"junit": lambda r, v: format_junit(r, verbose=v),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if format_name not in formatters:
|
|
42
|
+
valid = ", ".join(formatters.keys())
|
|
43
|
+
raise ValueError(f"Unknown format '{format_name}'. Valid formats: {valid}")
|
|
44
|
+
|
|
45
|
+
return formatters[format_name](results, verbose)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""JSON output formatter for competency question test results.
|
|
2
|
+
|
|
3
|
+
Produces structured JSON for programmatic consumption.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rdf_construct.cq.runner import CQTestResults, CQTestResult, CQStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_json(results: CQTestResults, verbose: bool = False,
|
|
13
|
+
indent: int = 2) -> str:
|
|
14
|
+
"""Format test results as JSON.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
results: Test results to format
|
|
18
|
+
verbose: Include additional details
|
|
19
|
+
indent: JSON indentation level (0 for compact)
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
JSON string
|
|
23
|
+
"""
|
|
24
|
+
data = _build_json_data(results, verbose)
|
|
25
|
+
return json.dumps(data, indent=indent if indent > 0 else None, default=str)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_json_data(results: CQTestResults, verbose: bool) -> dict[str, Any]:
|
|
29
|
+
"""Build the JSON data structure."""
|
|
30
|
+
data: dict[str, Any] = {}
|
|
31
|
+
|
|
32
|
+
# Metadata
|
|
33
|
+
if results.ontology_file:
|
|
34
|
+
data["ontology"] = str(results.ontology_file)
|
|
35
|
+
|
|
36
|
+
if results.suite.name:
|
|
37
|
+
data["suite_name"] = results.suite.name
|
|
38
|
+
|
|
39
|
+
if results.suite.version:
|
|
40
|
+
data["suite_version"] = results.suite.version
|
|
41
|
+
|
|
42
|
+
# Test results
|
|
43
|
+
data["questions"] = [
|
|
44
|
+
_format_result(r, verbose) for r in results.results
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# Summary
|
|
48
|
+
data["summary"] = {
|
|
49
|
+
"total": results.total_count,
|
|
50
|
+
"passed": results.passed_count,
|
|
51
|
+
"failed": results.failed_count,
|
|
52
|
+
"errors": results.error_count,
|
|
53
|
+
"skipped": results.skipped_count,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if verbose:
|
|
57
|
+
data["summary"]["duration_ms"] = round(results.total_duration_ms, 2)
|
|
58
|
+
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _format_result(result: CQTestResult, verbose: bool) -> dict[str, Any]:
|
|
63
|
+
"""Format a single test result as a dict."""
|
|
64
|
+
data: dict[str, Any] = {
|
|
65
|
+
"id": result.test.id,
|
|
66
|
+
"name": result.test.name,
|
|
67
|
+
"status": result.status.value,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Add tags if present
|
|
71
|
+
if result.test.tags:
|
|
72
|
+
data["tags"] = result.test.tags
|
|
73
|
+
|
|
74
|
+
# Add timing
|
|
75
|
+
if result.duration_ms:
|
|
76
|
+
data["duration_ms"] = round(result.duration_ms, 2)
|
|
77
|
+
|
|
78
|
+
# Add result count for SELECT queries
|
|
79
|
+
if result.result_count is not None:
|
|
80
|
+
data["result_count"] = result.result_count
|
|
81
|
+
|
|
82
|
+
# Add failure details
|
|
83
|
+
if result.status == CQStatus.FAIL and result.check_result:
|
|
84
|
+
data["expected"] = result.check_result.expected
|
|
85
|
+
data["actual"] = result.check_result.actual
|
|
86
|
+
data["message"] = result.check_result.message
|
|
87
|
+
|
|
88
|
+
# Add error details
|
|
89
|
+
if result.status == CQStatus.ERROR and result.error:
|
|
90
|
+
data["error"] = result.error
|
|
91
|
+
|
|
92
|
+
# Add skip reason
|
|
93
|
+
if result.status == CQStatus.SKIP and result.test.skip_reason:
|
|
94
|
+
data["skip_reason"] = result.test.skip_reason
|
|
95
|
+
|
|
96
|
+
# Add verbose details
|
|
97
|
+
if verbose:
|
|
98
|
+
if result.test.description:
|
|
99
|
+
data["description"] = result.test.description
|
|
100
|
+
|
|
101
|
+
# Include the query in verbose mode
|
|
102
|
+
data["query"] = result.test.query.strip()
|
|
103
|
+
|
|
104
|
+
return data
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""JUnit XML output formatter for competency question test results.
|
|
2
|
+
|
|
3
|
+
Produces JUnit XML format for CI integration (GitHub Actions, GitLab CI, Jenkins).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from xml.dom import minidom
|
|
8
|
+
|
|
9
|
+
from rdf_construct.cq.runner import CQTestResults, CQTestResult, CQStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_junit(results: CQTestResults, verbose: bool = False) -> str:
|
|
13
|
+
"""Format test results as JUnit XML.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
results: Test results to format
|
|
17
|
+
verbose: Include additional details in output
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
JUnit XML string
|
|
21
|
+
"""
|
|
22
|
+
# Build testsuite element
|
|
23
|
+
testsuite = ET.Element("testsuite")
|
|
24
|
+
|
|
25
|
+
# Set testsuite attributes
|
|
26
|
+
if results.suite.name:
|
|
27
|
+
testsuite.set("name", results.suite.name)
|
|
28
|
+
else:
|
|
29
|
+
testsuite.set("name", "Competency Questions")
|
|
30
|
+
|
|
31
|
+
testsuite.set("tests", str(results.total_count))
|
|
32
|
+
testsuite.set("failures", str(results.failed_count))
|
|
33
|
+
testsuite.set("errors", str(results.error_count))
|
|
34
|
+
testsuite.set("skipped", str(results.skipped_count))
|
|
35
|
+
testsuite.set("time", f"{results.total_duration_ms / 1000:.3f}")
|
|
36
|
+
|
|
37
|
+
if results.ontology_file:
|
|
38
|
+
testsuite.set("file", str(results.ontology_file))
|
|
39
|
+
|
|
40
|
+
# Add testcase elements
|
|
41
|
+
for result in results.results:
|
|
42
|
+
testcase = _format_testcase(result, verbose)
|
|
43
|
+
testsuite.append(testcase)
|
|
44
|
+
|
|
45
|
+
# Convert to string with pretty printing
|
|
46
|
+
rough_string = ET.tostring(testsuite, encoding="unicode")
|
|
47
|
+
reparsed = minidom.parseString(rough_string)
|
|
48
|
+
return reparsed.toprettyxml(indent=" ", encoding=None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _format_testcase(result: CQTestResult, verbose: bool) -> ET.Element:
|
|
52
|
+
"""Format a single test result as a testcase element."""
|
|
53
|
+
testcase = ET.Element("testcase")
|
|
54
|
+
|
|
55
|
+
# Required attributes
|
|
56
|
+
testcase.set("name", f"{result.test.id}: {result.test.name}")
|
|
57
|
+
testcase.set("classname", "CompetencyQuestions")
|
|
58
|
+
testcase.set("time", f"{result.duration_ms / 1000:.3f}")
|
|
59
|
+
|
|
60
|
+
# Status-specific content
|
|
61
|
+
if result.status == CQStatus.FAIL:
|
|
62
|
+
failure = ET.SubElement(testcase, "failure")
|
|
63
|
+
if result.check_result:
|
|
64
|
+
failure.set("message", result.check_result.message)
|
|
65
|
+
failure.text = (
|
|
66
|
+
f"Expected: {result.check_result.expected}\n"
|
|
67
|
+
f"Actual: {result.check_result.actual}"
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
failure.set("message", "Test failed")
|
|
71
|
+
|
|
72
|
+
elif result.status == CQStatus.ERROR:
|
|
73
|
+
error = ET.SubElement(testcase, "error")
|
|
74
|
+
if result.error:
|
|
75
|
+
error.set("message", result.error)
|
|
76
|
+
error.set("type", "QueryError")
|
|
77
|
+
error.text = result.error
|
|
78
|
+
else:
|
|
79
|
+
error.set("message", "Unknown error")
|
|
80
|
+
|
|
81
|
+
elif result.status == CQStatus.SKIP:
|
|
82
|
+
skipped = ET.SubElement(testcase, "skipped")
|
|
83
|
+
if result.test.skip_reason:
|
|
84
|
+
skipped.set("message", result.test.skip_reason)
|
|
85
|
+
|
|
86
|
+
# Add system-out for verbose mode
|
|
87
|
+
if verbose:
|
|
88
|
+
system_out = ET.SubElement(testcase, "system-out")
|
|
89
|
+
output_lines = []
|
|
90
|
+
|
|
91
|
+
if result.test.description:
|
|
92
|
+
output_lines.append(f"Description: {result.test.description}")
|
|
93
|
+
|
|
94
|
+
if result.test.tags:
|
|
95
|
+
output_lines.append(f"Tags: {', '.join(result.test.tags)}")
|
|
96
|
+
|
|
97
|
+
if result.result_count is not None:
|
|
98
|
+
output_lines.append(f"Result count: {result.result_count}")
|
|
99
|
+
|
|
100
|
+
output_lines.append(f"Query:\n{result.test.query}")
|
|
101
|
+
|
|
102
|
+
system_out.text = "\n".join(output_lines)
|
|
103
|
+
|
|
104
|
+
return testcase
|