rdf-construct 0.2.1__py3-none-any.whl → 0.4.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 +1 -1
- rdf_construct/cli.py +1794 -0
- rdf_construct/describe/__init__.py +93 -0
- rdf_construct/describe/analyzer.py +176 -0
- rdf_construct/describe/documentation.py +146 -0
- rdf_construct/describe/formatters/__init__.py +47 -0
- rdf_construct/describe/formatters/json.py +65 -0
- rdf_construct/describe/formatters/markdown.py +275 -0
- rdf_construct/describe/formatters/text.py +315 -0
- rdf_construct/describe/hierarchy.py +232 -0
- rdf_construct/describe/imports.py +213 -0
- rdf_construct/describe/metadata.py +187 -0
- rdf_construct/describe/metrics.py +145 -0
- rdf_construct/describe/models.py +552 -0
- rdf_construct/describe/namespaces.py +180 -0
- rdf_construct/describe/profiles.py +415 -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/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/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-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/METADATA +91 -6
- {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/RECORD +43 -7
- {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/WHEEL +0 -0
- {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/entry_points.txt +0 -0
- {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""String extraction from RDF ontologies.
|
|
2
|
+
|
|
3
|
+
Extracts translatable strings (labels, comments, definitions) from ontology
|
|
4
|
+
files and generates translation files in YAML format.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rdflib import Graph, Literal, URIRef
|
|
12
|
+
from rdflib.namespace import OWL, RDF, RDFS
|
|
13
|
+
|
|
14
|
+
from rdf_construct.localise.config import (
|
|
15
|
+
EntityTranslations,
|
|
16
|
+
ExtractConfig,
|
|
17
|
+
TranslationEntry,
|
|
18
|
+
TranslationFile,
|
|
19
|
+
TranslationFileMetadata,
|
|
20
|
+
TranslationStatus,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Standard property URIs
|
|
25
|
+
LABEL_PROPERTIES = [
|
|
26
|
+
"http://www.w3.org/2000/01/rdf-schema#label",
|
|
27
|
+
"http://www.w3.org/2004/02/skos/core#prefLabel",
|
|
28
|
+
"http://www.w3.org/2004/02/skos/core#altLabel",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
COMMENT_PROPERTIES = [
|
|
32
|
+
"http://www.w3.org/2000/01/rdf-schema#comment",
|
|
33
|
+
"http://www.w3.org/2004/02/skos/core#definition",
|
|
34
|
+
"http://www.w3.org/2004/02/skos/core#example",
|
|
35
|
+
"http://www.w3.org/2004/02/skos/core#note",
|
|
36
|
+
"http://www.w3.org/2004/02/skos/core#scopeNote",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
DEFAULT_PROPERTIES = LABEL_PROPERTIES[:2] + COMMENT_PROPERTIES[:2]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ExtractionResult:
|
|
44
|
+
"""Result of a string extraction operation.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
success: Whether extraction succeeded.
|
|
48
|
+
translation_file: Generated translation file.
|
|
49
|
+
total_entities: Number of entities processed.
|
|
50
|
+
total_strings: Number of strings extracted.
|
|
51
|
+
skipped_entities: Number of entities skipped.
|
|
52
|
+
error: Error message if failed.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
success: bool
|
|
56
|
+
translation_file: TranslationFile | None = None
|
|
57
|
+
total_entities: int = 0
|
|
58
|
+
total_strings: int = 0
|
|
59
|
+
skipped_entities: int = 0
|
|
60
|
+
error: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StringExtractor:
|
|
64
|
+
"""Extracts translatable strings from RDF ontologies.
|
|
65
|
+
|
|
66
|
+
The extractor identifies entities (classes, properties, individuals) and
|
|
67
|
+
extracts text values for configured properties (rdfs:label, rdfs:comment, etc.)
|
|
68
|
+
in the source language. The output is a translation file with empty
|
|
69
|
+
translation fields ready for translators.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, config: ExtractConfig | None = None):
|
|
73
|
+
"""Initialise the extractor.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
config: Extraction configuration. Uses defaults if not provided.
|
|
77
|
+
"""
|
|
78
|
+
self.config = config or ExtractConfig()
|
|
79
|
+
|
|
80
|
+
def extract(
|
|
81
|
+
self,
|
|
82
|
+
graph: Graph,
|
|
83
|
+
source_file: Path | str,
|
|
84
|
+
target_language: str | None = None,
|
|
85
|
+
) -> ExtractionResult:
|
|
86
|
+
"""Extract translatable strings from an RDF graph.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
graph: RDF graph to extract from.
|
|
90
|
+
source_file: Path to source file (for metadata).
|
|
91
|
+
target_language: Override target language from config.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
ExtractionResult with translation file.
|
|
95
|
+
"""
|
|
96
|
+
target_lang = target_language or self.config.target_language
|
|
97
|
+
if not target_lang:
|
|
98
|
+
return ExtractionResult(
|
|
99
|
+
success=False,
|
|
100
|
+
error="No target language specified",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
# Get all entities from the graph
|
|
105
|
+
entities = self._collect_entities(graph)
|
|
106
|
+
|
|
107
|
+
# Extract translations for each entity
|
|
108
|
+
entity_translations: list[EntityTranslations] = []
|
|
109
|
+
total_strings = 0
|
|
110
|
+
skipped = 0
|
|
111
|
+
|
|
112
|
+
for entity_uri, entity_type in entities:
|
|
113
|
+
# Check for deprecation
|
|
114
|
+
if not self.config.include_deprecated and self._is_deprecated(
|
|
115
|
+
graph, entity_uri
|
|
116
|
+
):
|
|
117
|
+
skipped += 1
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Extract labels for this entity
|
|
121
|
+
labels = self._extract_entity_labels(
|
|
122
|
+
graph,
|
|
123
|
+
entity_uri,
|
|
124
|
+
target_lang,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if not labels:
|
|
128
|
+
if self.config.include_unlabelled:
|
|
129
|
+
# Include entity with empty labels
|
|
130
|
+
pass
|
|
131
|
+
else:
|
|
132
|
+
skipped += 1
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
if labels:
|
|
136
|
+
entity_translations.append(
|
|
137
|
+
EntityTranslations(
|
|
138
|
+
uri=str(entity_uri),
|
|
139
|
+
entity_type=entity_type,
|
|
140
|
+
labels=labels,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
total_strings += len(labels)
|
|
144
|
+
|
|
145
|
+
# Build translation file
|
|
146
|
+
metadata = TranslationFileMetadata(
|
|
147
|
+
source_file=str(source_file),
|
|
148
|
+
source_language=self.config.source_language,
|
|
149
|
+
target_language=target_lang,
|
|
150
|
+
generated=datetime.now(),
|
|
151
|
+
properties=[self._shorten_property(p) for p in self.config.properties],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
translation_file = TranslationFile(
|
|
155
|
+
metadata=metadata,
|
|
156
|
+
entities=entity_translations,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return ExtractionResult(
|
|
160
|
+
success=True,
|
|
161
|
+
translation_file=translation_file,
|
|
162
|
+
total_entities=len(entity_translations),
|
|
163
|
+
total_strings=total_strings,
|
|
164
|
+
skipped_entities=skipped,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
except Exception as e:
|
|
168
|
+
return ExtractionResult(
|
|
169
|
+
success=False,
|
|
170
|
+
error=str(e),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _collect_entities(self, graph: Graph) -> list[tuple[URIRef, str]]:
|
|
174
|
+
"""Collect all entities from the graph with their types.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
graph: RDF graph.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
List of (URI, type_string) tuples.
|
|
181
|
+
"""
|
|
182
|
+
entities: list[tuple[URIRef, str]] = []
|
|
183
|
+
seen: set[URIRef] = set()
|
|
184
|
+
|
|
185
|
+
# Classes
|
|
186
|
+
for cls_type in [OWL.Class, RDFS.Class]:
|
|
187
|
+
for s in graph.subjects(RDF.type, cls_type):
|
|
188
|
+
if isinstance(s, URIRef) and s not in seen:
|
|
189
|
+
seen.add(s)
|
|
190
|
+
entities.append((s, "owl:Class"))
|
|
191
|
+
|
|
192
|
+
# Object Properties
|
|
193
|
+
for s in graph.subjects(RDF.type, OWL.ObjectProperty):
|
|
194
|
+
if isinstance(s, URIRef) and s not in seen:
|
|
195
|
+
seen.add(s)
|
|
196
|
+
entities.append((s, "owl:ObjectProperty"))
|
|
197
|
+
|
|
198
|
+
# Datatype Properties
|
|
199
|
+
for s in graph.subjects(RDF.type, OWL.DatatypeProperty):
|
|
200
|
+
if isinstance(s, URIRef) and s not in seen:
|
|
201
|
+
seen.add(s)
|
|
202
|
+
entities.append((s, "owl:DatatypeProperty"))
|
|
203
|
+
|
|
204
|
+
# Annotation Properties
|
|
205
|
+
for s in graph.subjects(RDF.type, OWL.AnnotationProperty):
|
|
206
|
+
if isinstance(s, URIRef) and s not in seen:
|
|
207
|
+
seen.add(s)
|
|
208
|
+
entities.append((s, "owl:AnnotationProperty"))
|
|
209
|
+
|
|
210
|
+
# RDF Properties
|
|
211
|
+
for s in graph.subjects(RDF.type, RDF.Property):
|
|
212
|
+
if isinstance(s, URIRef) and s not in seen:
|
|
213
|
+
seen.add(s)
|
|
214
|
+
entities.append((s, "rdf:Property"))
|
|
215
|
+
|
|
216
|
+
# Named Individuals
|
|
217
|
+
for s in graph.subjects(RDF.type, OWL.NamedIndividual):
|
|
218
|
+
if isinstance(s, URIRef) and s not in seen:
|
|
219
|
+
seen.add(s)
|
|
220
|
+
entities.append((s, "owl:NamedIndividual"))
|
|
221
|
+
|
|
222
|
+
# Sort by URI for consistent output
|
|
223
|
+
entities.sort(key=lambda x: str(x[0]))
|
|
224
|
+
|
|
225
|
+
return entities
|
|
226
|
+
|
|
227
|
+
def _extract_entity_labels(
|
|
228
|
+
self,
|
|
229
|
+
graph: Graph,
|
|
230
|
+
entity: URIRef,
|
|
231
|
+
target_lang: str,
|
|
232
|
+
) -> list[TranslationEntry]:
|
|
233
|
+
"""Extract label properties for a single entity.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
graph: RDF graph.
|
|
237
|
+
entity: Entity URI.
|
|
238
|
+
target_lang: Target language code.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
List of TranslationEntry objects.
|
|
242
|
+
"""
|
|
243
|
+
labels: list[TranslationEntry] = []
|
|
244
|
+
source_lang = self.config.source_language
|
|
245
|
+
|
|
246
|
+
for prop_uri_str in self.config.properties:
|
|
247
|
+
prop_uri = URIRef(self._expand_property(prop_uri_str))
|
|
248
|
+
|
|
249
|
+
# Find source language literals
|
|
250
|
+
source_literals = self._get_language_literals(
|
|
251
|
+
graph, entity, prop_uri, source_lang
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if not source_literals:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
# Check for existing translation if missing_only mode
|
|
258
|
+
if self.config.missing_only:
|
|
259
|
+
existing = self._get_language_literals(
|
|
260
|
+
graph, entity, prop_uri, target_lang
|
|
261
|
+
)
|
|
262
|
+
if existing:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
for source_text in source_literals:
|
|
266
|
+
labels.append(
|
|
267
|
+
TranslationEntry(
|
|
268
|
+
property=self._shorten_property(str(prop_uri)),
|
|
269
|
+
source_text=source_text,
|
|
270
|
+
translation="",
|
|
271
|
+
status=TranslationStatus.PENDING,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return labels
|
|
276
|
+
|
|
277
|
+
def _get_language_literals(
|
|
278
|
+
self,
|
|
279
|
+
graph: Graph,
|
|
280
|
+
subject: URIRef,
|
|
281
|
+
predicate: URIRef,
|
|
282
|
+
language: str,
|
|
283
|
+
) -> list[str]:
|
|
284
|
+
"""Get literal values for a specific language.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
graph: RDF graph.
|
|
288
|
+
subject: Subject URI.
|
|
289
|
+
predicate: Predicate URI.
|
|
290
|
+
language: Language code.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of literal string values.
|
|
294
|
+
"""
|
|
295
|
+
results: list[str] = []
|
|
296
|
+
|
|
297
|
+
for obj in graph.objects(subject, predicate):
|
|
298
|
+
if isinstance(obj, Literal):
|
|
299
|
+
# Match language exactly or match untagged literals for source
|
|
300
|
+
obj_lang = obj.language
|
|
301
|
+
if obj_lang == language:
|
|
302
|
+
results.append(str(obj))
|
|
303
|
+
elif obj_lang is None and language == self.config.source_language:
|
|
304
|
+
# Treat untagged literals as source language
|
|
305
|
+
results.append(str(obj))
|
|
306
|
+
|
|
307
|
+
return results
|
|
308
|
+
|
|
309
|
+
def _is_deprecated(self, graph: Graph, entity: URIRef) -> bool:
|
|
310
|
+
"""Check if an entity is deprecated.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
graph: RDF graph.
|
|
314
|
+
entity: Entity URI.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
True if entity is deprecated.
|
|
318
|
+
"""
|
|
319
|
+
# Check owl:deprecated
|
|
320
|
+
for obj in graph.objects(entity, OWL.deprecated):
|
|
321
|
+
if isinstance(obj, Literal) and obj.toPython() is True:
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
# Check owl:DeprecatedClass / owl:DeprecatedProperty
|
|
325
|
+
deprecated_types = [OWL.DeprecatedClass, OWL.DeprecatedProperty]
|
|
326
|
+
for dtype in deprecated_types:
|
|
327
|
+
if (entity, RDF.type, dtype) in graph:
|
|
328
|
+
return True
|
|
329
|
+
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
def _expand_property(self, prop: str) -> str:
|
|
333
|
+
"""Expand a CURIE to full URI.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
prop: Property string (CURIE or full URI).
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Full URI string.
|
|
340
|
+
"""
|
|
341
|
+
prefixes = {
|
|
342
|
+
"rdfs:": "http://www.w3.org/2000/01/rdf-schema#",
|
|
343
|
+
"skos:": "http://www.w3.org/2004/02/skos/core#",
|
|
344
|
+
"owl:": "http://www.w3.org/2002/07/owl#",
|
|
345
|
+
"rdf:": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
346
|
+
"dc:": "http://purl.org/dc/elements/1.1/",
|
|
347
|
+
"dcterms:": "http://purl.org/dc/terms/",
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for prefix, namespace in prefixes.items():
|
|
351
|
+
if prop.startswith(prefix):
|
|
352
|
+
return namespace + prop[len(prefix) :]
|
|
353
|
+
|
|
354
|
+
return prop
|
|
355
|
+
|
|
356
|
+
def _shorten_property(self, prop: str) -> str:
|
|
357
|
+
"""Shorten a full URI to CURIE if possible.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
prop: Full property URI.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
CURIE or original URI.
|
|
364
|
+
"""
|
|
365
|
+
namespaces = {
|
|
366
|
+
"http://www.w3.org/2000/01/rdf-schema#": "rdfs:",
|
|
367
|
+
"http://www.w3.org/2004/02/skos/core#": "skos:",
|
|
368
|
+
"http://www.w3.org/2002/07/owl#": "owl:",
|
|
369
|
+
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf:",
|
|
370
|
+
"http://purl.org/dc/elements/1.1/": "dc:",
|
|
371
|
+
"http://purl.org/dc/terms/": "dcterms:",
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for namespace, prefix in namespaces.items():
|
|
375
|
+
if prop.startswith(namespace):
|
|
376
|
+
return prefix + prop[len(namespace) :]
|
|
377
|
+
|
|
378
|
+
return prop
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def extract_strings(
|
|
382
|
+
source: Path,
|
|
383
|
+
target_language: str,
|
|
384
|
+
output: Path | None = None,
|
|
385
|
+
source_language: str = "en",
|
|
386
|
+
properties: list[str] | None = None,
|
|
387
|
+
include_deprecated: bool = False,
|
|
388
|
+
missing_only: bool = False,
|
|
389
|
+
) -> ExtractionResult:
|
|
390
|
+
"""Extract translatable strings from an ontology file.
|
|
391
|
+
|
|
392
|
+
Convenience function for simple extraction.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
source: Source ontology file.
|
|
396
|
+
target_language: Target language code.
|
|
397
|
+
output: Output file path. Auto-generated if not provided.
|
|
398
|
+
source_language: Source language code.
|
|
399
|
+
properties: Properties to extract. Uses defaults if not provided.
|
|
400
|
+
include_deprecated: Include deprecated entities.
|
|
401
|
+
missing_only: Only extract missing translations.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
ExtractionResult with translation file.
|
|
405
|
+
"""
|
|
406
|
+
# Load graph
|
|
407
|
+
graph = Graph()
|
|
408
|
+
graph.parse(source)
|
|
409
|
+
|
|
410
|
+
# Build config
|
|
411
|
+
config = ExtractConfig(
|
|
412
|
+
source_language=source_language,
|
|
413
|
+
target_language=target_language,
|
|
414
|
+
properties=properties or list(DEFAULT_PROPERTIES),
|
|
415
|
+
include_deprecated=include_deprecated,
|
|
416
|
+
missing_only=missing_only,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Extract
|
|
420
|
+
extractor = StringExtractor(config)
|
|
421
|
+
result = extractor.extract(graph, source, target_language)
|
|
422
|
+
|
|
423
|
+
# Save if requested
|
|
424
|
+
if result.success and output and result.translation_file:
|
|
425
|
+
result.translation_file.save(output)
|
|
426
|
+
|
|
427
|
+
return result
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Formatters for localise command output.
|
|
2
|
+
|
|
3
|
+
Provides output formatting for:
|
|
4
|
+
- Console text output
|
|
5
|
+
- Markdown reports
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from rdf_construct.localise.formatters.text import TextFormatter
|
|
9
|
+
from rdf_construct.localise.formatters.markdown import MarkdownFormatter
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"TextFormatter",
|
|
13
|
+
"MarkdownFormatter",
|
|
14
|
+
"get_formatter",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_formatter(format_name: str, use_colour: bool = True) -> TextFormatter | MarkdownFormatter:
|
|
19
|
+
"""Get a formatter by name.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
format_name: Formatter name ("text" or "markdown").
|
|
23
|
+
use_colour: Whether to use colour output (text only).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Formatter instance.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: If format name is unknown.
|
|
30
|
+
"""
|
|
31
|
+
if format_name == "text":
|
|
32
|
+
return TextFormatter(use_colour=use_colour)
|
|
33
|
+
elif format_name in ("markdown", "md"):
|
|
34
|
+
return MarkdownFormatter()
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError(f"Unknown format: {format_name}")
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Markdown formatter for coverage reports.
|
|
2
|
+
|
|
3
|
+
Generates markdown-formatted coverage reports suitable for documentation
|
|
4
|
+
or inclusion in PRs/issues.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from rdf_construct.localise.extractor import ExtractionResult
|
|
10
|
+
from rdf_construct.localise.merger import MergeResult
|
|
11
|
+
from rdf_construct.localise.reporter import CoverageReport
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MarkdownFormatter:
|
|
15
|
+
"""Formats localise results as Markdown."""
|
|
16
|
+
|
|
17
|
+
def format_extraction_result(self, result: ExtractionResult) -> str:
|
|
18
|
+
"""Format extraction result as Markdown.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
result: Extraction result.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Markdown string.
|
|
25
|
+
"""
|
|
26
|
+
lines: list[str] = []
|
|
27
|
+
|
|
28
|
+
lines.append("# Extraction Result")
|
|
29
|
+
lines.append("")
|
|
30
|
+
|
|
31
|
+
if result.success:
|
|
32
|
+
lines.append("**Status:** ✅ Success")
|
|
33
|
+
lines.append("")
|
|
34
|
+
lines.append("| Metric | Value |")
|
|
35
|
+
lines.append("|--------|-------|")
|
|
36
|
+
lines.append(f"| Entities | {result.total_entities} |")
|
|
37
|
+
lines.append(f"| Strings | {result.total_strings} |")
|
|
38
|
+
lines.append(f"| Skipped | {result.skipped_entities} |")
|
|
39
|
+
|
|
40
|
+
if result.translation_file:
|
|
41
|
+
tf = result.translation_file
|
|
42
|
+
lines.append("")
|
|
43
|
+
lines.append("## Metadata")
|
|
44
|
+
lines.append("")
|
|
45
|
+
lines.append(f"- **Source file:** `{tf.metadata.source_file}`")
|
|
46
|
+
lines.append(f"- **Source language:** {tf.metadata.source_language}")
|
|
47
|
+
lines.append(f"- **Target language:** {tf.metadata.target_language}")
|
|
48
|
+
else:
|
|
49
|
+
lines.append("**Status:** ❌ Failed")
|
|
50
|
+
lines.append("")
|
|
51
|
+
lines.append(f"**Error:** {result.error}")
|
|
52
|
+
|
|
53
|
+
return "\n".join(lines)
|
|
54
|
+
|
|
55
|
+
def format_merge_result(self, result: MergeResult) -> str:
|
|
56
|
+
"""Format merge result as Markdown.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
result: Merge result.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Markdown string.
|
|
63
|
+
"""
|
|
64
|
+
lines: list[str] = []
|
|
65
|
+
|
|
66
|
+
lines.append("# Merge Result")
|
|
67
|
+
lines.append("")
|
|
68
|
+
|
|
69
|
+
if result.success:
|
|
70
|
+
lines.append("**Status:** ✅ Success")
|
|
71
|
+
lines.append("")
|
|
72
|
+
|
|
73
|
+
stats = result.stats
|
|
74
|
+
lines.append("| Metric | Count |")
|
|
75
|
+
lines.append("|--------|-------|")
|
|
76
|
+
lines.append(f"| Added | {stats.added} |")
|
|
77
|
+
lines.append(f"| Updated | {stats.updated} |")
|
|
78
|
+
lines.append(f"| Skipped (status) | {stats.skipped_status} |")
|
|
79
|
+
lines.append(f"| Skipped (existing) | {stats.skipped_existing} |")
|
|
80
|
+
lines.append(f"| Errors | {stats.errors} |")
|
|
81
|
+
|
|
82
|
+
if result.warnings:
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.append("## Warnings")
|
|
85
|
+
lines.append("")
|
|
86
|
+
for warning in result.warnings:
|
|
87
|
+
lines.append(f"- {warning}")
|
|
88
|
+
else:
|
|
89
|
+
lines.append("**Status:** ❌ Failed")
|
|
90
|
+
lines.append("")
|
|
91
|
+
lines.append(f"**Error:** {result.error}")
|
|
92
|
+
|
|
93
|
+
return "\n".join(lines)
|
|
94
|
+
|
|
95
|
+
def format_coverage_report(
|
|
96
|
+
self,
|
|
97
|
+
report: CoverageReport,
|
|
98
|
+
verbose: bool = False,
|
|
99
|
+
) -> str:
|
|
100
|
+
"""Format coverage report as Markdown.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
report: Coverage report.
|
|
104
|
+
verbose: Include detailed missing entity list.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Markdown string.
|
|
108
|
+
"""
|
|
109
|
+
lines: list[str] = []
|
|
110
|
+
|
|
111
|
+
# Header
|
|
112
|
+
lines.append("# Translation Coverage Report")
|
|
113
|
+
lines.append("")
|
|
114
|
+
lines.append(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
|
115
|
+
lines.append("")
|
|
116
|
+
|
|
117
|
+
# Summary
|
|
118
|
+
lines.append("## Summary")
|
|
119
|
+
lines.append("")
|
|
120
|
+
lines.append(f"- **Source file:** `{report.source_file}`")
|
|
121
|
+
lines.append(f"- **Source language:** {report.source_language}")
|
|
122
|
+
lines.append(f"- **Total entities:** {report.total_entities}")
|
|
123
|
+
lines.append(f"- **Properties checked:** {', '.join(report.properties)}")
|
|
124
|
+
lines.append("")
|
|
125
|
+
|
|
126
|
+
# Coverage table
|
|
127
|
+
lines.append("## Coverage by Language")
|
|
128
|
+
lines.append("")
|
|
129
|
+
|
|
130
|
+
# Build header
|
|
131
|
+
header = ["Language"]
|
|
132
|
+
header.extend(report.properties)
|
|
133
|
+
header.append("Overall")
|
|
134
|
+
header.append("Status")
|
|
135
|
+
lines.append("| " + " | ".join(header) + " |")
|
|
136
|
+
lines.append("| " + " | ".join(["---"] * len(header)) + " |")
|
|
137
|
+
|
|
138
|
+
# Data rows
|
|
139
|
+
for lang, coverage in report.languages.items():
|
|
140
|
+
row = []
|
|
141
|
+
|
|
142
|
+
# Language
|
|
143
|
+
if coverage.is_source:
|
|
144
|
+
row.append(f"**{lang}** (base)")
|
|
145
|
+
else:
|
|
146
|
+
row.append(lang)
|
|
147
|
+
|
|
148
|
+
# Property coverages
|
|
149
|
+
for prop in report.properties:
|
|
150
|
+
prop_cov = coverage.by_property.get(prop)
|
|
151
|
+
if prop_cov:
|
|
152
|
+
pct = f"{prop_cov.coverage:.0f}%"
|
|
153
|
+
else:
|
|
154
|
+
pct = "-"
|
|
155
|
+
row.append(pct)
|
|
156
|
+
|
|
157
|
+
# Overall
|
|
158
|
+
row.append(f"**{coverage.coverage:.0f}%**")
|
|
159
|
+
|
|
160
|
+
# Status
|
|
161
|
+
if coverage.coverage == 100:
|
|
162
|
+
row.append("✅ Complete")
|
|
163
|
+
elif coverage.coverage >= 75:
|
|
164
|
+
row.append(f"⚠️ {coverage.pending} pending")
|
|
165
|
+
elif coverage.coverage > 0:
|
|
166
|
+
row.append(f"❌ {coverage.pending} pending")
|
|
167
|
+
else:
|
|
168
|
+
row.append("❌ Not started")
|
|
169
|
+
|
|
170
|
+
lines.append("| " + " | ".join(row) + " |")
|
|
171
|
+
|
|
172
|
+
# Missing translations section
|
|
173
|
+
if verbose:
|
|
174
|
+
has_missing = False
|
|
175
|
+
for lang, coverage in report.languages.items():
|
|
176
|
+
if coverage.missing_entities and not coverage.is_source:
|
|
177
|
+
if not has_missing:
|
|
178
|
+
lines.append("")
|
|
179
|
+
lines.append("## Missing Translations")
|
|
180
|
+
has_missing = True
|
|
181
|
+
|
|
182
|
+
lines.append("")
|
|
183
|
+
lines.append(f"### {lang.upper()}")
|
|
184
|
+
lines.append("")
|
|
185
|
+
|
|
186
|
+
# Group by entity type based on URI pattern
|
|
187
|
+
lines.append("<details>")
|
|
188
|
+
lines.append(f"<summary>{len(coverage.missing_entities)} entities missing translations</summary>")
|
|
189
|
+
lines.append("")
|
|
190
|
+
for uri in coverage.missing_entities:
|
|
191
|
+
short_uri = self._shorten_uri(uri)
|
|
192
|
+
lines.append(f"- `{short_uri}`")
|
|
193
|
+
lines.append("")
|
|
194
|
+
lines.append("</details>")
|
|
195
|
+
|
|
196
|
+
# Footer
|
|
197
|
+
lines.append("")
|
|
198
|
+
lines.append("---")
|
|
199
|
+
lines.append("*Generated by rdf-construct localise*")
|
|
200
|
+
|
|
201
|
+
return "\n".join(lines)
|
|
202
|
+
|
|
203
|
+
def _shorten_uri(self, uri: str) -> str:
|
|
204
|
+
"""Shorten a URI for display.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
uri: Full URI.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Shortened version.
|
|
211
|
+
"""
|
|
212
|
+
prefixes = {
|
|
213
|
+
"http://www.w3.org/2000/01/rdf-schema#": "rdfs:",
|
|
214
|
+
"http://www.w3.org/2004/02/skos/core#": "skos:",
|
|
215
|
+
"http://www.w3.org/2002/07/owl#": "owl:",
|
|
216
|
+
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf:",
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for namespace, prefix in prefixes.items():
|
|
220
|
+
if uri.startswith(namespace):
|
|
221
|
+
return prefix + uri[len(namespace) :]
|
|
222
|
+
|
|
223
|
+
# If no known prefix, just show local name
|
|
224
|
+
if "#" in uri:
|
|
225
|
+
return uri.split("#")[-1]
|
|
226
|
+
elif "/" in uri:
|
|
227
|
+
return uri.split("/")[-1]
|
|
228
|
+
|
|
229
|
+
return uri
|