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
rdf_construct/cli.py
ADDED
|
@@ -0,0 +1,3429 @@
|
|
|
1
|
+
"""Command-line interface for rdf-construct."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rdflib import Graph, RDF, URIRef
|
|
8
|
+
from rdflib.namespace import OWL
|
|
9
|
+
|
|
10
|
+
from rdf_construct.core import (
|
|
11
|
+
OrderingConfig,
|
|
12
|
+
build_section_graph,
|
|
13
|
+
extract_prefix_map,
|
|
14
|
+
rebind_prefixes,
|
|
15
|
+
select_subjects,
|
|
16
|
+
serialise_turtle,
|
|
17
|
+
sort_subjects,
|
|
18
|
+
expand_curie,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from rdf_construct.uml import (
|
|
22
|
+
load_uml_config,
|
|
23
|
+
collect_diagram_entities,
|
|
24
|
+
render_plantuml,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from rdf_construct.uml.uml_style import load_style_config
|
|
28
|
+
from rdf_construct.uml.uml_layout import load_layout_config
|
|
29
|
+
from rdf_construct.uml.odm_renderer import render_odm_plantuml
|
|
30
|
+
|
|
31
|
+
from rdf_construct.lint import (
|
|
32
|
+
LintEngine,
|
|
33
|
+
LintConfig,
|
|
34
|
+
load_lint_config,
|
|
35
|
+
find_config_file,
|
|
36
|
+
get_formatter,
|
|
37
|
+
list_rules,
|
|
38
|
+
get_all_rules,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
LINT_LEVELS = ["strict", "standard", "relaxed"]
|
|
42
|
+
LINT_FORMATS = ["text", "json"]
|
|
43
|
+
|
|
44
|
+
from rdf_construct.diff import compare_files, format_diff, filter_diff, parse_filter_string
|
|
45
|
+
|
|
46
|
+
from rdf_construct.puml2rdf import (
|
|
47
|
+
ConversionConfig,
|
|
48
|
+
PlantUMLParser,
|
|
49
|
+
PumlToRdfConverter,
|
|
50
|
+
load_import_config,
|
|
51
|
+
merge_with_existing,
|
|
52
|
+
validate_puml,
|
|
53
|
+
validate_rdf,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
from rdf_construct.cq import load_test_suite, CQTestRunner, format_results
|
|
57
|
+
|
|
58
|
+
from rdf_construct.stats import (
|
|
59
|
+
collect_stats,
|
|
60
|
+
compare_stats,
|
|
61
|
+
format_stats,
|
|
62
|
+
format_comparison,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
from rdf_construct.merge import (
|
|
66
|
+
MergeConfig,
|
|
67
|
+
SourceConfig,
|
|
68
|
+
OutputConfig,
|
|
69
|
+
ConflictConfig,
|
|
70
|
+
ConflictStrategy,
|
|
71
|
+
ImportsStrategy,
|
|
72
|
+
DataMigrationConfig,
|
|
73
|
+
OntologyMerger,
|
|
74
|
+
load_merge_config,
|
|
75
|
+
create_default_config,
|
|
76
|
+
get_formatter,
|
|
77
|
+
migrate_data_files,
|
|
78
|
+
# Split imports
|
|
79
|
+
OntologySplitter,
|
|
80
|
+
SplitConfig,
|
|
81
|
+
SplitResult,
|
|
82
|
+
ModuleDefinition,
|
|
83
|
+
split_by_namespace,
|
|
84
|
+
create_default_split_config,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
from rdf_construct.refactor import (
|
|
88
|
+
RenameConfig,
|
|
89
|
+
DeprecationSpec,
|
|
90
|
+
RefactorConfig,
|
|
91
|
+
OntologyRenamer,
|
|
92
|
+
OntologyDeprecator,
|
|
93
|
+
TextFormatter as RefactorTextFormatter,
|
|
94
|
+
load_refactor_config,
|
|
95
|
+
create_default_rename_config,
|
|
96
|
+
create_default_deprecation_config,
|
|
97
|
+
rename_file,
|
|
98
|
+
rename_files,
|
|
99
|
+
deprecate_file,
|
|
100
|
+
)
|
|
101
|
+
from rdf_construct.merge import DataMigrator
|
|
102
|
+
|
|
103
|
+
from rdf_construct.localise import (
|
|
104
|
+
StringExtractor,
|
|
105
|
+
TranslationMerger,
|
|
106
|
+
CoverageReporter,
|
|
107
|
+
ExtractConfig,
|
|
108
|
+
MergeConfig as LocaliseMergeConfig,
|
|
109
|
+
TranslationFile,
|
|
110
|
+
TranslationStatus,
|
|
111
|
+
ExistingStrategy,
|
|
112
|
+
create_default_config as create_default_localise_config,
|
|
113
|
+
load_localise_config,
|
|
114
|
+
get_formatter as get_localise_formatter,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Valid rendering modes
|
|
118
|
+
RENDERING_MODES = ["default", "odm"]
|
|
119
|
+
|
|
120
|
+
@click.group()
|
|
121
|
+
@click.version_option()
|
|
122
|
+
def cli():
|
|
123
|
+
"""rdf-construct: Semantic RDF manipulation toolkit.
|
|
124
|
+
|
|
125
|
+
Tools for working with RDF ontologies:
|
|
126
|
+
|
|
127
|
+
\b
|
|
128
|
+
- lint: Check ontology quality (structural issues, documentation, best practices)
|
|
129
|
+
- uml: Generate PlantUML class diagrams
|
|
130
|
+
- order: Reorder Turtle files with semantic awareness
|
|
131
|
+
|
|
132
|
+
Use COMMAND --help for detailed options.
|
|
133
|
+
"""
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@cli.command()
|
|
138
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
139
|
+
@click.argument("config", type=click.Path(exists=True, path_type=Path))
|
|
140
|
+
@click.option(
|
|
141
|
+
"--profile",
|
|
142
|
+
"-p",
|
|
143
|
+
multiple=True,
|
|
144
|
+
help="Profile(s) to generate (default: all profiles in config)",
|
|
145
|
+
)
|
|
146
|
+
@click.option(
|
|
147
|
+
"--outdir",
|
|
148
|
+
"-o",
|
|
149
|
+
type=click.Path(path_type=Path),
|
|
150
|
+
default="src/ontology",
|
|
151
|
+
help="Output directory (default: src/ontology)",
|
|
152
|
+
)
|
|
153
|
+
def order(source: Path, config: Path, profile: tuple[str, ...], outdir: Path):
|
|
154
|
+
"""Reorder RDF Turtle files according to semantic profiles.
|
|
155
|
+
|
|
156
|
+
SOURCE: Input RDF Turtle file (.ttl)
|
|
157
|
+
CONFIG: YAML configuration file defining ordering profiles
|
|
158
|
+
|
|
159
|
+
Examples:
|
|
160
|
+
|
|
161
|
+
# Generate all profiles defined in config
|
|
162
|
+
rdf-construct order ontology.ttl order.yml
|
|
163
|
+
|
|
164
|
+
# Generate only specific profiles
|
|
165
|
+
rdf-construct order ontology.ttl order.yml -p alpha -p logical_topo
|
|
166
|
+
|
|
167
|
+
# Custom output directory
|
|
168
|
+
rdf-construct order ontology.ttl order.yml -o output/
|
|
169
|
+
"""
|
|
170
|
+
# Load configuration
|
|
171
|
+
ordering_config = OrderingConfig(config)
|
|
172
|
+
|
|
173
|
+
# Determine which profiles to generate
|
|
174
|
+
if profile:
|
|
175
|
+
profiles_to_gen = list(profile)
|
|
176
|
+
else:
|
|
177
|
+
profiles_to_gen = ordering_config.list_profiles()
|
|
178
|
+
|
|
179
|
+
# Validate requested profiles exist
|
|
180
|
+
for prof_name in profiles_to_gen:
|
|
181
|
+
if prof_name not in ordering_config.profiles:
|
|
182
|
+
click.secho(
|
|
183
|
+
f"Error: Profile '{prof_name}' not found in config.", fg="red", err=True
|
|
184
|
+
)
|
|
185
|
+
available = ", ".join(ordering_config.list_profiles())
|
|
186
|
+
click.echo(f"Available profiles: {available}", err=True)
|
|
187
|
+
raise click.Abort()
|
|
188
|
+
|
|
189
|
+
# Create output directory
|
|
190
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
# Parse source RDF
|
|
193
|
+
click.echo(f"Loading {source}...")
|
|
194
|
+
graph = Graph()
|
|
195
|
+
graph.parse(source.as_posix(), format="turtle")
|
|
196
|
+
prefix_map = extract_prefix_map(graph)
|
|
197
|
+
|
|
198
|
+
# Generate each profile
|
|
199
|
+
for prof_name in profiles_to_gen:
|
|
200
|
+
click.echo(f"Constructing profile: {prof_name}")
|
|
201
|
+
prof = ordering_config.get_profile(prof_name)
|
|
202
|
+
|
|
203
|
+
ordered_subjects: list = []
|
|
204
|
+
seen: set = set()
|
|
205
|
+
|
|
206
|
+
# Process each section
|
|
207
|
+
for sec in prof.sections:
|
|
208
|
+
if not isinstance(sec, dict) or not sec:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
sec_name, sec_cfg = next(iter(sec.items()))
|
|
212
|
+
|
|
213
|
+
# Handle header section - ontology metadata
|
|
214
|
+
if sec_name == "header":
|
|
215
|
+
ontology_subjects = [
|
|
216
|
+
s for s in graph.subjects(RDF.type, OWL.Ontology) if s not in seen
|
|
217
|
+
]
|
|
218
|
+
for s in ontology_subjects:
|
|
219
|
+
ordered_subjects.append(s)
|
|
220
|
+
seen.add(s)
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
# Regular sections
|
|
224
|
+
sec_cfg = sec_cfg or {}
|
|
225
|
+
select_key = sec_cfg.get("select", sec_name)
|
|
226
|
+
sort_mode = sec_cfg.get("sort", "qname_alpha")
|
|
227
|
+
roots_cfg = sec_cfg.get("roots")
|
|
228
|
+
|
|
229
|
+
# Select and sort subjects
|
|
230
|
+
chosen = select_subjects(graph, select_key, ordering_config.selectors)
|
|
231
|
+
chosen = [s for s in chosen if s not in seen]
|
|
232
|
+
|
|
233
|
+
ordered = sort_subjects(graph, set(chosen), sort_mode, roots_cfg)
|
|
234
|
+
|
|
235
|
+
for s in ordered:
|
|
236
|
+
if s not in seen:
|
|
237
|
+
ordered_subjects.append(s)
|
|
238
|
+
seen.add(s)
|
|
239
|
+
|
|
240
|
+
# Build output graph
|
|
241
|
+
out_graph = build_section_graph(graph, ordered_subjects)
|
|
242
|
+
|
|
243
|
+
# Rebind prefixes if configured
|
|
244
|
+
if ordering_config.defaults.get("preserve_prefix_order", True):
|
|
245
|
+
if ordering_config.prefix_order:
|
|
246
|
+
rebind_prefixes(out_graph, ordering_config.prefix_order, prefix_map)
|
|
247
|
+
|
|
248
|
+
# Get predicate ordering for this profile
|
|
249
|
+
predicate_order = ordering_config.get_predicate_order(prof_name)
|
|
250
|
+
|
|
251
|
+
# Serialise with predicate ordering
|
|
252
|
+
out_file = outdir / f"{source.stem}-{prof_name}.ttl"
|
|
253
|
+
serialise_turtle(out_graph, ordered_subjects, out_file, predicate_order)
|
|
254
|
+
click.secho(f" ✓ {out_file}", fg="green")
|
|
255
|
+
|
|
256
|
+
click.secho(
|
|
257
|
+
f"\nConstructed {len(profiles_to_gen)} profile(s) in {outdir}/", fg="cyan"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@cli.command()
|
|
262
|
+
@click.argument("config", type=click.Path(exists=True, path_type=Path))
|
|
263
|
+
def profiles(config: Path):
|
|
264
|
+
"""List available profiles in a configuration file.
|
|
265
|
+
|
|
266
|
+
CONFIG: YAML configuration file to inspect
|
|
267
|
+
"""
|
|
268
|
+
ordering_config = OrderingConfig(config)
|
|
269
|
+
|
|
270
|
+
click.secho("Available profiles:", fg="cyan", bold=True)
|
|
271
|
+
click.echo()
|
|
272
|
+
|
|
273
|
+
for prof_name in ordering_config.list_profiles():
|
|
274
|
+
prof = ordering_config.get_profile(prof_name)
|
|
275
|
+
click.secho(f" {prof_name}", fg="green", bold=True)
|
|
276
|
+
if prof.description:
|
|
277
|
+
click.echo(f" {prof.description}")
|
|
278
|
+
click.echo(f" Sections: {len(prof.sections)}")
|
|
279
|
+
click.echo()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@cli.command()
|
|
283
|
+
@click.argument("sources", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
|
|
284
|
+
@click.option(
|
|
285
|
+
"--config",
|
|
286
|
+
"-C",
|
|
287
|
+
required=True,
|
|
288
|
+
type=click.Path(exists=True, path_type=Path),
|
|
289
|
+
help="YAML configuration file defining UML contexts",
|
|
290
|
+
)
|
|
291
|
+
@click.option(
|
|
292
|
+
"--context",
|
|
293
|
+
"-c",
|
|
294
|
+
multiple=True,
|
|
295
|
+
help="Context(s) to generate (default: all contexts in config)",
|
|
296
|
+
)
|
|
297
|
+
@click.option(
|
|
298
|
+
"--outdir",
|
|
299
|
+
"-o",
|
|
300
|
+
type=click.Path(path_type=Path),
|
|
301
|
+
default="diagrams",
|
|
302
|
+
help="Output directory (default: diagrams)",
|
|
303
|
+
)
|
|
304
|
+
@click.option(
|
|
305
|
+
"--style-config",
|
|
306
|
+
type=click.Path(exists=True, path_type=Path),
|
|
307
|
+
help="Path to style configuration YAML (e.g., examples/uml_styles.yml)"
|
|
308
|
+
)
|
|
309
|
+
@click.option(
|
|
310
|
+
"--style", "-s",
|
|
311
|
+
help="Style scheme name to use (e.g., 'default', 'ies_semantic')"
|
|
312
|
+
)
|
|
313
|
+
@click.option(
|
|
314
|
+
"--layout-config",
|
|
315
|
+
type=click.Path(exists=True, path_type=Path),
|
|
316
|
+
help="Path to layout configuration YAML (e.g., examples/uml_layouts.yml)"
|
|
317
|
+
)
|
|
318
|
+
@click.option(
|
|
319
|
+
"--layout", "-l",
|
|
320
|
+
help="Layout name to use (e.g., 'hierarchy', 'compact')"
|
|
321
|
+
)
|
|
322
|
+
@click.option(
|
|
323
|
+
"--rendering-mode", "-r",
|
|
324
|
+
type=click.Choice(RENDERING_MODES, case_sensitive=False),
|
|
325
|
+
default="default",
|
|
326
|
+
help="Rendering mode: 'default' (custom stereotypes) or 'odm' (OMG ODM RDF Profile compliant)"
|
|
327
|
+
)
|
|
328
|
+
def uml(sources, config, context, outdir, style_config, style, layout_config, layout, rendering_mode):
|
|
329
|
+
"""Generate UML class diagrams from RDF ontologies.
|
|
330
|
+
|
|
331
|
+
SOURCES: One or more RDF Turtle files (.ttl). The first file is the primary
|
|
332
|
+
source; additional files provide supporting definitions (e.g., imported
|
|
333
|
+
ontologies for complete class hierarchies).
|
|
334
|
+
|
|
335
|
+
Examples:
|
|
336
|
+
|
|
337
|
+
# Basic usage - single source
|
|
338
|
+
rdf-construct uml ontology.ttl -C contexts.yml
|
|
339
|
+
|
|
340
|
+
# Multiple sources - primary + supporting ontology
|
|
341
|
+
rdf-construct uml building.ttl ies4.ttl -C contexts.yml
|
|
342
|
+
|
|
343
|
+
# Multiple sources with styling (hierarchy inheritance works!)
|
|
344
|
+
rdf-construct uml building.ttl ies4.ttl -C contexts.yml \\
|
|
345
|
+
--style-config ies_colours.yml --style ies_full
|
|
346
|
+
|
|
347
|
+
# Generate specific context with ODM mode
|
|
348
|
+
rdf-construct uml building.ttl ies4.ttl -C contexts.yml -c core -r odm
|
|
349
|
+
"""
|
|
350
|
+
# Load style if provided
|
|
351
|
+
style_scheme = None
|
|
352
|
+
if style_config and style:
|
|
353
|
+
style_cfg = load_style_config(style_config)
|
|
354
|
+
try:
|
|
355
|
+
style_scheme = style_cfg.get_scheme(style)
|
|
356
|
+
click.echo(f"Using style: {style}")
|
|
357
|
+
except KeyError as e:
|
|
358
|
+
click.secho(str(e), fg="red", err=True)
|
|
359
|
+
click.echo(f"Available styles: {', '.join(style_cfg.list_schemes())}")
|
|
360
|
+
raise click.Abort()
|
|
361
|
+
|
|
362
|
+
# Load layout if provided
|
|
363
|
+
layout_cfg = None
|
|
364
|
+
if layout_config and layout:
|
|
365
|
+
layout_mgr = load_layout_config(layout_config)
|
|
366
|
+
try:
|
|
367
|
+
layout_cfg = layout_mgr.get_layout(layout)
|
|
368
|
+
click.echo(f"Using layout: {layout}")
|
|
369
|
+
except KeyError as e:
|
|
370
|
+
click.secho(str(e), fg="red", err=True)
|
|
371
|
+
click.echo(f"Available layouts: {', '.join(layout_mgr.list_layouts())}")
|
|
372
|
+
raise click.Abort()
|
|
373
|
+
|
|
374
|
+
# Display rendering mode
|
|
375
|
+
if rendering_mode == "odm":
|
|
376
|
+
click.echo("Using rendering mode: ODM RDF Profile (OMG compliant)")
|
|
377
|
+
else:
|
|
378
|
+
click.echo("Using rendering mode: default")
|
|
379
|
+
|
|
380
|
+
# Load UML configuration
|
|
381
|
+
uml_config = load_uml_config(config)
|
|
382
|
+
|
|
383
|
+
# Determine which contexts to generate
|
|
384
|
+
if context:
|
|
385
|
+
contexts_to_gen = list(context)
|
|
386
|
+
else:
|
|
387
|
+
contexts_to_gen = uml_config.list_contexts()
|
|
388
|
+
|
|
389
|
+
# Validate requested contexts exist
|
|
390
|
+
for ctx_name in contexts_to_gen:
|
|
391
|
+
if ctx_name not in uml_config.contexts:
|
|
392
|
+
click.secho(
|
|
393
|
+
f"Error: Context '{ctx_name}' not found in config.", fg="red", err=True
|
|
394
|
+
)
|
|
395
|
+
available = ", ".join(uml_config.list_contexts())
|
|
396
|
+
click.echo(f"Available contexts: {available}", err=True)
|
|
397
|
+
raise click.Abort()
|
|
398
|
+
|
|
399
|
+
# Create output directory
|
|
400
|
+
# ToDo - handle exceptions properly
|
|
401
|
+
outdir = Path(outdir)
|
|
402
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
403
|
+
|
|
404
|
+
# Parse source RDF files into a single graph
|
|
405
|
+
# The first source is considered the "primary" (used for output naming)
|
|
406
|
+
primary_source = sources[0]
|
|
407
|
+
graph = Graph()
|
|
408
|
+
|
|
409
|
+
for source in sources:
|
|
410
|
+
click.echo(f"Loading {source}...")
|
|
411
|
+
# Guess format from extension
|
|
412
|
+
suffix = source.suffix.lower()
|
|
413
|
+
if suffix in (".ttl", ".turtle"):
|
|
414
|
+
fmt = "turtle"
|
|
415
|
+
elif suffix in (".rdf", ".xml", ".owl"):
|
|
416
|
+
fmt = "xml"
|
|
417
|
+
elif suffix in (".nt", ".ntriples"):
|
|
418
|
+
fmt = "nt"
|
|
419
|
+
elif suffix in (".n3",):
|
|
420
|
+
fmt = "n3"
|
|
421
|
+
elif suffix in (".jsonld", ".json"):
|
|
422
|
+
fmt = "json-ld"
|
|
423
|
+
else:
|
|
424
|
+
fmt = "turtle" # Default to turtle
|
|
425
|
+
|
|
426
|
+
graph.parse(source.as_posix(), format=fmt)
|
|
427
|
+
|
|
428
|
+
if len(sources) > 1:
|
|
429
|
+
click.echo(f" Merged {len(sources)} source files ({len(graph)} triples total)")
|
|
430
|
+
|
|
431
|
+
# Get selectors from defaults (if any)
|
|
432
|
+
selectors = uml_config.defaults.get("selectors", {})
|
|
433
|
+
|
|
434
|
+
# Generate each context
|
|
435
|
+
for ctx_name in contexts_to_gen:
|
|
436
|
+
click.echo(f"Generating diagram: {ctx_name}")
|
|
437
|
+
ctx = uml_config.get_context(ctx_name)
|
|
438
|
+
|
|
439
|
+
# Select entities
|
|
440
|
+
entities = collect_diagram_entities(graph, ctx, selectors)
|
|
441
|
+
|
|
442
|
+
# Build output filename (include mode suffix for ODM)
|
|
443
|
+
if rendering_mode == "odm":
|
|
444
|
+
out_file = outdir / f"{primary_source.stem}-{ctx_name}-odm.puml"
|
|
445
|
+
else:
|
|
446
|
+
out_file = outdir / f"{primary_source.stem}-{ctx_name}.puml"
|
|
447
|
+
|
|
448
|
+
# Render with optional style and layout
|
|
449
|
+
if rendering_mode == "odm":
|
|
450
|
+
render_odm_plantuml(graph, entities, out_file, style_scheme, layout_cfg)
|
|
451
|
+
else:
|
|
452
|
+
render_plantuml(graph, entities, out_file, style_scheme, layout_cfg)
|
|
453
|
+
|
|
454
|
+
click.secho(f" ✓ {out_file}", fg="green")
|
|
455
|
+
click.echo(
|
|
456
|
+
f" Classes: {len(entities['classes'])}, "
|
|
457
|
+
f"Properties: {len(entities['object_properties']) + len(entities['datatype_properties'])}, "
|
|
458
|
+
f"Instances: {len(entities['instances'])}"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
click.secho(
|
|
462
|
+
f"\nGenerated {len(contexts_to_gen)} diagram(s) in {outdir}/", fg="cyan"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@cli.command()
|
|
467
|
+
@click.argument("config", type=click.Path(exists=True, path_type=Path))
|
|
468
|
+
def contexts(config: Path):
|
|
469
|
+
"""List available UML contexts in a configuration file.
|
|
470
|
+
|
|
471
|
+
CONFIG: YAML configuration file to inspect
|
|
472
|
+
"""
|
|
473
|
+
uml_config = load_uml_config(config)
|
|
474
|
+
|
|
475
|
+
click.secho("Available UML contexts:", fg="cyan", bold=True)
|
|
476
|
+
click.echo()
|
|
477
|
+
|
|
478
|
+
for ctx_name in uml_config.list_contexts():
|
|
479
|
+
ctx = uml_config.get_context(ctx_name)
|
|
480
|
+
click.secho(f" {ctx_name}", fg="green", bold=True)
|
|
481
|
+
if ctx.description:
|
|
482
|
+
click.echo(f" {ctx.description}")
|
|
483
|
+
|
|
484
|
+
# Show selection strategy
|
|
485
|
+
if ctx.root_classes:
|
|
486
|
+
click.echo(f" Roots: {', '.join(ctx.root_classes)}")
|
|
487
|
+
elif ctx.focus_classes:
|
|
488
|
+
click.echo(f" Focus: {', '.join(ctx.focus_classes)}")
|
|
489
|
+
elif ctx.selector:
|
|
490
|
+
click.echo(f" Selector: {ctx.selector}")
|
|
491
|
+
|
|
492
|
+
if ctx.include_descendants:
|
|
493
|
+
depth_str = f"depth={ctx.max_depth}" if ctx.max_depth else "unlimited"
|
|
494
|
+
click.echo(f" Includes descendants ({depth_str})")
|
|
495
|
+
|
|
496
|
+
click.echo(f" Properties: {ctx.property_mode}")
|
|
497
|
+
click.echo()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@cli.command()
|
|
501
|
+
@click.argument("sources", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
|
|
502
|
+
@click.option(
|
|
503
|
+
"--level",
|
|
504
|
+
"-l",
|
|
505
|
+
type=click.Choice(["strict", "standard", "relaxed"], case_sensitive=False),
|
|
506
|
+
default="standard",
|
|
507
|
+
help="Strictness level (default: standard)",
|
|
508
|
+
)
|
|
509
|
+
@click.option(
|
|
510
|
+
"--format",
|
|
511
|
+
"-f",
|
|
512
|
+
"output_format", # Renamed to avoid shadowing builtin
|
|
513
|
+
type=click.Choice(["text", "json"], case_sensitive=False),
|
|
514
|
+
default="text",
|
|
515
|
+
help="Output format (default: text)",
|
|
516
|
+
)
|
|
517
|
+
@click.option(
|
|
518
|
+
"--config",
|
|
519
|
+
"-c",
|
|
520
|
+
type=click.Path(exists=True, path_type=Path),
|
|
521
|
+
help="Path to .rdf-lint.yml configuration file",
|
|
522
|
+
)
|
|
523
|
+
@click.option(
|
|
524
|
+
"--enable",
|
|
525
|
+
"-e",
|
|
526
|
+
multiple=True,
|
|
527
|
+
help="Enable specific rules (can be used multiple times)",
|
|
528
|
+
)
|
|
529
|
+
@click.option(
|
|
530
|
+
"--disable",
|
|
531
|
+
"-d",
|
|
532
|
+
multiple=True,
|
|
533
|
+
help="Disable specific rules (can be used multiple times)",
|
|
534
|
+
)
|
|
535
|
+
@click.option(
|
|
536
|
+
"--no-colour",
|
|
537
|
+
"--no-color",
|
|
538
|
+
is_flag=True,
|
|
539
|
+
help="Disable coloured output",
|
|
540
|
+
)
|
|
541
|
+
@click.option(
|
|
542
|
+
"--list-rules",
|
|
543
|
+
"list_rules_flag",
|
|
544
|
+
is_flag=True,
|
|
545
|
+
help="List available rules and exit",
|
|
546
|
+
)
|
|
547
|
+
@click.option(
|
|
548
|
+
"--init",
|
|
549
|
+
"init_config",
|
|
550
|
+
is_flag=True,
|
|
551
|
+
help="Generate a default .rdf-lint.yml config file and exit",
|
|
552
|
+
)
|
|
553
|
+
def lint(
|
|
554
|
+
sources: tuple[Path, ...],
|
|
555
|
+
level: str,
|
|
556
|
+
output_format: str,
|
|
557
|
+
config: Path | None,
|
|
558
|
+
enable: tuple[str, ...],
|
|
559
|
+
disable: tuple[str, ...],
|
|
560
|
+
no_colour: bool,
|
|
561
|
+
list_rules_flag: bool, # Must match the name above
|
|
562
|
+
init_config: bool,
|
|
563
|
+
):
|
|
564
|
+
"""Check RDF ontologies for quality issues.
|
|
565
|
+
|
|
566
|
+
Performs static analysis to detect structural problems, missing
|
|
567
|
+
documentation, and best practice violations.
|
|
568
|
+
|
|
569
|
+
\b
|
|
570
|
+
SOURCES: One or more RDF files to check (.ttl, .rdf, .owl, etc.)
|
|
571
|
+
|
|
572
|
+
\b
|
|
573
|
+
Exit codes:
|
|
574
|
+
0 - No issues found
|
|
575
|
+
1 - Warnings found (no errors)
|
|
576
|
+
2 - Errors found
|
|
577
|
+
|
|
578
|
+
\b
|
|
579
|
+
Examples:
|
|
580
|
+
# Basic usage
|
|
581
|
+
rdf-construct lint ontology.ttl
|
|
582
|
+
|
|
583
|
+
# Multiple files
|
|
584
|
+
rdf-construct lint core.ttl domain.ttl
|
|
585
|
+
|
|
586
|
+
# Strict mode (warnings become errors)
|
|
587
|
+
rdf-construct lint ontology.ttl --level strict
|
|
588
|
+
|
|
589
|
+
# JSON output for CI
|
|
590
|
+
rdf-construct lint ontology.ttl --format json
|
|
591
|
+
|
|
592
|
+
# Use config file
|
|
593
|
+
rdf-construct lint ontology.ttl --config .rdf-lint.yml
|
|
594
|
+
|
|
595
|
+
# Enable/disable specific rules
|
|
596
|
+
rdf-construct lint ontology.ttl --enable orphan-class --disable missing-comment
|
|
597
|
+
|
|
598
|
+
# List available rules
|
|
599
|
+
rdf-construct lint --list-rules
|
|
600
|
+
"""
|
|
601
|
+
# Handle --init flag
|
|
602
|
+
if init_config:
|
|
603
|
+
from .lint import create_default_config
|
|
604
|
+
|
|
605
|
+
config_path = Path(".rdf-lint.yml")
|
|
606
|
+
if config_path.exists():
|
|
607
|
+
click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
|
|
608
|
+
raise click.Abort()
|
|
609
|
+
|
|
610
|
+
config_content = create_default_config()
|
|
611
|
+
config_path.write_text(config_content)
|
|
612
|
+
click.secho(f"Created {config_path}", fg="green")
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
# Handle --list-rules flag
|
|
616
|
+
if list_rules_flag:
|
|
617
|
+
from .lint import get_all_rules
|
|
618
|
+
|
|
619
|
+
rules = get_all_rules()
|
|
620
|
+
click.secho("Available lint rules:", fg="cyan", bold=True)
|
|
621
|
+
click.echo()
|
|
622
|
+
|
|
623
|
+
# Group by category
|
|
624
|
+
categories: dict[str, list] = {}
|
|
625
|
+
for rule_id, spec in sorted(rules.items()):
|
|
626
|
+
cat = spec.category
|
|
627
|
+
if cat not in categories:
|
|
628
|
+
categories[cat] = []
|
|
629
|
+
categories[cat].append(spec)
|
|
630
|
+
|
|
631
|
+
for category, specs in sorted(categories.items()):
|
|
632
|
+
click.secho(f" {category.title()}", fg="yellow", bold=True)
|
|
633
|
+
for spec in specs:
|
|
634
|
+
severity_color = {
|
|
635
|
+
"error": "red",
|
|
636
|
+
"warning": "yellow",
|
|
637
|
+
"info": "blue",
|
|
638
|
+
}[spec.default_severity.value]
|
|
639
|
+
click.echo(
|
|
640
|
+
f" {spec.rule_id}: "
|
|
641
|
+
f"{click.style(spec.default_severity.value, fg=severity_color)} - "
|
|
642
|
+
f"{spec.description}"
|
|
643
|
+
)
|
|
644
|
+
click.echo()
|
|
645
|
+
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
# Validate we have sources for actual linting
|
|
649
|
+
if not sources:
|
|
650
|
+
click.secho("Error: No source files specified.", fg="red", err=True)
|
|
651
|
+
raise click.Abort()
|
|
652
|
+
|
|
653
|
+
lint_config: LintConfig
|
|
654
|
+
|
|
655
|
+
if config:
|
|
656
|
+
# Load from specified config file
|
|
657
|
+
try:
|
|
658
|
+
lint_config = load_lint_config(config)
|
|
659
|
+
click.echo(f"Using config: {config}")
|
|
660
|
+
except (FileNotFoundError, ValueError) as e:
|
|
661
|
+
click.secho(f"Error loading config: {e}", fg="red", err=True)
|
|
662
|
+
raise click.Abort()
|
|
663
|
+
else:
|
|
664
|
+
# Try to find config file automatically
|
|
665
|
+
found_config = find_config_file()
|
|
666
|
+
if found_config:
|
|
667
|
+
try:
|
|
668
|
+
lint_config = load_lint_config(found_config)
|
|
669
|
+
click.echo(f"Using config: {found_config}")
|
|
670
|
+
except (FileNotFoundError, ValueError) as e:
|
|
671
|
+
click.secho(f"Error loading config: {e}", fg="red", err=True)
|
|
672
|
+
raise click.Abort()
|
|
673
|
+
else:
|
|
674
|
+
lint_config = LintConfig()
|
|
675
|
+
|
|
676
|
+
# Apply CLI overrides
|
|
677
|
+
lint_config.level = level
|
|
678
|
+
|
|
679
|
+
if enable:
|
|
680
|
+
lint_config.enabled_rules = set(enable)
|
|
681
|
+
if disable:
|
|
682
|
+
lint_config.disabled_rules.update(disable)
|
|
683
|
+
|
|
684
|
+
# Create engine and run
|
|
685
|
+
engine = LintEngine(lint_config)
|
|
686
|
+
|
|
687
|
+
click.echo(f"Scanning {len(sources)} file(s)...")
|
|
688
|
+
click.echo()
|
|
689
|
+
|
|
690
|
+
summary = engine.lint_files(list(sources))
|
|
691
|
+
|
|
692
|
+
# Format and output results
|
|
693
|
+
use_colour = not no_colour and output_format == "text"
|
|
694
|
+
formatter = get_formatter(output_format, use_colour=use_colour)
|
|
695
|
+
|
|
696
|
+
output = formatter.format_summary(summary)
|
|
697
|
+
click.echo(output)
|
|
698
|
+
|
|
699
|
+
# Exit with appropriate code
|
|
700
|
+
raise SystemExit(summary.exit_code)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@cli.command()
|
|
704
|
+
@click.argument("old_file", type=click.Path(exists=True, path_type=Path))
|
|
705
|
+
@click.argument("new_file", type=click.Path(exists=True, path_type=Path))
|
|
706
|
+
@click.option(
|
|
707
|
+
"--output",
|
|
708
|
+
"-o",
|
|
709
|
+
type=click.Path(path_type=Path),
|
|
710
|
+
help="Write output to file instead of stdout",
|
|
711
|
+
)
|
|
712
|
+
@click.option(
|
|
713
|
+
"--format",
|
|
714
|
+
"-f",
|
|
715
|
+
"output_format",
|
|
716
|
+
type=click.Choice(["text", "markdown", "md", "json"], case_sensitive=False),
|
|
717
|
+
default="text",
|
|
718
|
+
help="Output format (default: text)",
|
|
719
|
+
)
|
|
720
|
+
@click.option(
|
|
721
|
+
"--show",
|
|
722
|
+
type=str,
|
|
723
|
+
help="Show only these change types (comma-separated: added,removed,modified)",
|
|
724
|
+
)
|
|
725
|
+
@click.option(
|
|
726
|
+
"--hide",
|
|
727
|
+
type=str,
|
|
728
|
+
help="Hide these change types (comma-separated: added,removed,modified)",
|
|
729
|
+
)
|
|
730
|
+
@click.option(
|
|
731
|
+
"--entities",
|
|
732
|
+
type=str,
|
|
733
|
+
help="Show only these entity types (comma-separated: classes,properties,instances)",
|
|
734
|
+
)
|
|
735
|
+
@click.option(
|
|
736
|
+
"--ignore-predicates",
|
|
737
|
+
type=str,
|
|
738
|
+
help="Ignore these predicates in comparison (comma-separated CURIEs)",
|
|
739
|
+
)
|
|
740
|
+
def diff(
|
|
741
|
+
old_file: Path,
|
|
742
|
+
new_file: Path,
|
|
743
|
+
output: Path | None,
|
|
744
|
+
output_format: str,
|
|
745
|
+
show: str | None,
|
|
746
|
+
hide: str | None,
|
|
747
|
+
entities: str | None,
|
|
748
|
+
ignore_predicates: str | None,
|
|
749
|
+
):
|
|
750
|
+
"""Compare two RDF files and show semantic differences.
|
|
751
|
+
|
|
752
|
+
Compares OLD_FILE to NEW_FILE and reports changes, ignoring cosmetic
|
|
753
|
+
differences like statement order, prefix bindings, and whitespace.
|
|
754
|
+
|
|
755
|
+
\b
|
|
756
|
+
Examples:
|
|
757
|
+
rdf-construct diff v1.0.ttl v1.1.ttl
|
|
758
|
+
rdf-construct diff v1.0.ttl v1.1.ttl --format markdown -o CHANGELOG.md
|
|
759
|
+
rdf-construct diff old.ttl new.ttl --show added,removed
|
|
760
|
+
rdf-construct diff old.ttl new.ttl --entities classes
|
|
761
|
+
|
|
762
|
+
\b
|
|
763
|
+
Exit codes:
|
|
764
|
+
0 - Graphs are semantically identical
|
|
765
|
+
1 - Differences were found
|
|
766
|
+
2 - Error occurred
|
|
767
|
+
"""
|
|
768
|
+
|
|
769
|
+
try:
|
|
770
|
+
# Parse ignored predicates
|
|
771
|
+
ignore_preds: set[URIRef] | None = None
|
|
772
|
+
if ignore_predicates:
|
|
773
|
+
temp_graph = Graph()
|
|
774
|
+
temp_graph.parse(str(old_file), format="turtle")
|
|
775
|
+
|
|
776
|
+
ignore_preds = set()
|
|
777
|
+
for pred_str in ignore_predicates.split(","):
|
|
778
|
+
pred_str = pred_str.strip()
|
|
779
|
+
uri = expand_curie(temp_graph, pred_str)
|
|
780
|
+
if uri:
|
|
781
|
+
ignore_preds.add(uri)
|
|
782
|
+
else:
|
|
783
|
+
click.secho(
|
|
784
|
+
f"Warning: Could not expand predicate '{pred_str}'",
|
|
785
|
+
fg="yellow",
|
|
786
|
+
err=True,
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
# Perform comparison
|
|
790
|
+
click.echo(f"Comparing {old_file.name} → {new_file.name}...", err=True)
|
|
791
|
+
diff_result = compare_files(old_file, new_file, ignore_predicates=ignore_preds)
|
|
792
|
+
|
|
793
|
+
# Apply filters
|
|
794
|
+
if show or hide or entities:
|
|
795
|
+
show_types = parse_filter_string(show) if show else None
|
|
796
|
+
hide_types = parse_filter_string(hide) if hide else None
|
|
797
|
+
entity_types = parse_filter_string(entities) if entities else None
|
|
798
|
+
|
|
799
|
+
diff_result = filter_diff(
|
|
800
|
+
diff_result,
|
|
801
|
+
show_types=show_types,
|
|
802
|
+
hide_types=hide_types,
|
|
803
|
+
entity_types=entity_types,
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
# Load graph for CURIE formatting
|
|
807
|
+
graph_for_format = None
|
|
808
|
+
if output_format in ("text", "markdown", "md"):
|
|
809
|
+
graph_for_format = Graph()
|
|
810
|
+
graph_for_format.parse(str(new_file), format="turtle")
|
|
811
|
+
|
|
812
|
+
# Format output
|
|
813
|
+
formatted = format_diff(diff_result, format_name=output_format, graph=graph_for_format)
|
|
814
|
+
|
|
815
|
+
# Write output
|
|
816
|
+
if output:
|
|
817
|
+
output.write_text(formatted)
|
|
818
|
+
click.secho(f"✓ Wrote diff to {output}", fg="green", err=True)
|
|
819
|
+
else:
|
|
820
|
+
click.echo(formatted)
|
|
821
|
+
|
|
822
|
+
# Exit code: 0 if identical, 1 if different
|
|
823
|
+
if diff_result.is_identical:
|
|
824
|
+
click.secho("Graphs are semantically identical.", fg="green", err=True)
|
|
825
|
+
sys.exit(0)
|
|
826
|
+
else:
|
|
827
|
+
sys.exit(1)
|
|
828
|
+
|
|
829
|
+
except FileNotFoundError as e:
|
|
830
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
831
|
+
sys.exit(2)
|
|
832
|
+
except ValueError as e:
|
|
833
|
+
click.secho(f"Error parsing RDF: {e}", fg="red", err=True)
|
|
834
|
+
sys.exit(2)
|
|
835
|
+
except Exception as e:
|
|
836
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
837
|
+
sys.exit(2)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
@cli.command()
|
|
841
|
+
@click.argument("sources", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
|
|
842
|
+
@click.option(
|
|
843
|
+
"--output",
|
|
844
|
+
"-o",
|
|
845
|
+
type=click.Path(path_type=Path),
|
|
846
|
+
default="docs",
|
|
847
|
+
help="Output directory (default: docs)",
|
|
848
|
+
)
|
|
849
|
+
@click.option(
|
|
850
|
+
"--format",
|
|
851
|
+
"-f",
|
|
852
|
+
"output_format",
|
|
853
|
+
type=click.Choice(["html", "markdown", "md", "json"], case_sensitive=False),
|
|
854
|
+
default="html",
|
|
855
|
+
help="Output format (default: html)",
|
|
856
|
+
)
|
|
857
|
+
@click.option(
|
|
858
|
+
"--config",
|
|
859
|
+
"-C",
|
|
860
|
+
type=click.Path(exists=True, path_type=Path),
|
|
861
|
+
help="Path to configuration YAML file",
|
|
862
|
+
)
|
|
863
|
+
@click.option(
|
|
864
|
+
"--template",
|
|
865
|
+
"-t",
|
|
866
|
+
type=click.Path(exists=True, path_type=Path),
|
|
867
|
+
help="Path to custom template directory",
|
|
868
|
+
)
|
|
869
|
+
@click.option(
|
|
870
|
+
"--single-page",
|
|
871
|
+
is_flag=True,
|
|
872
|
+
help="Generate single-page documentation",
|
|
873
|
+
)
|
|
874
|
+
@click.option(
|
|
875
|
+
"--title",
|
|
876
|
+
help="Override ontology title",
|
|
877
|
+
)
|
|
878
|
+
@click.option(
|
|
879
|
+
"--no-search",
|
|
880
|
+
is_flag=True,
|
|
881
|
+
help="Disable search index generation (HTML only)",
|
|
882
|
+
)
|
|
883
|
+
@click.option(
|
|
884
|
+
"--no-instances",
|
|
885
|
+
is_flag=True,
|
|
886
|
+
help="Exclude instances from documentation",
|
|
887
|
+
)
|
|
888
|
+
@click.option(
|
|
889
|
+
"--include",
|
|
890
|
+
type=str,
|
|
891
|
+
help="Include only these entity types (comma-separated: classes,properties,instances)",
|
|
892
|
+
)
|
|
893
|
+
@click.option(
|
|
894
|
+
"--exclude",
|
|
895
|
+
type=str,
|
|
896
|
+
help="Exclude these entity types (comma-separated: classes,properties,instances)",
|
|
897
|
+
)
|
|
898
|
+
def docs(
|
|
899
|
+
sources: tuple[Path, ...],
|
|
900
|
+
output: Path,
|
|
901
|
+
output_format: str,
|
|
902
|
+
config: Path | None,
|
|
903
|
+
template: Path | None,
|
|
904
|
+
single_page: bool,
|
|
905
|
+
title: str | None,
|
|
906
|
+
no_search: bool,
|
|
907
|
+
no_instances: bool,
|
|
908
|
+
include: str | None,
|
|
909
|
+
exclude: str | None,
|
|
910
|
+
):
|
|
911
|
+
"""Generate documentation from RDF ontologies.
|
|
912
|
+
|
|
913
|
+
SOURCES: One or more RDF files to generate documentation from.
|
|
914
|
+
|
|
915
|
+
\b
|
|
916
|
+
Examples:
|
|
917
|
+
# Basic HTML documentation
|
|
918
|
+
rdf-construct docs ontology.ttl
|
|
919
|
+
|
|
920
|
+
# Markdown output to custom directory
|
|
921
|
+
rdf-construct docs ontology.ttl --format markdown -o api-docs/
|
|
922
|
+
|
|
923
|
+
# Single-page HTML with custom title
|
|
924
|
+
rdf-construct docs ontology.ttl --single-page --title "My Ontology"
|
|
925
|
+
|
|
926
|
+
# JSON output for custom rendering
|
|
927
|
+
rdf-construct docs ontology.ttl --format json
|
|
928
|
+
|
|
929
|
+
# Use custom templates
|
|
930
|
+
rdf-construct docs ontology.ttl --template my-templates/
|
|
931
|
+
|
|
932
|
+
# Generate from multiple sources (merged)
|
|
933
|
+
rdf-construct docs domain.ttl foundation.ttl -o docs/
|
|
934
|
+
|
|
935
|
+
\b
|
|
936
|
+
Output formats:
|
|
937
|
+
html - Navigable HTML pages with search (default)
|
|
938
|
+
markdown - GitHub/GitLab compatible Markdown
|
|
939
|
+
json - Structured JSON for custom rendering
|
|
940
|
+
"""
|
|
941
|
+
from rdflib import Graph
|
|
942
|
+
|
|
943
|
+
from rdf_construct.docs import DocsConfig, DocsGenerator, load_docs_config
|
|
944
|
+
|
|
945
|
+
# Load or create configuration
|
|
946
|
+
if config:
|
|
947
|
+
doc_config = load_docs_config(config)
|
|
948
|
+
else:
|
|
949
|
+
doc_config = DocsConfig()
|
|
950
|
+
|
|
951
|
+
# Apply CLI overrides
|
|
952
|
+
doc_config.output_dir = output
|
|
953
|
+
doc_config.format = "markdown" if output_format == "md" else output_format
|
|
954
|
+
doc_config.single_page = single_page
|
|
955
|
+
doc_config.include_search = not no_search
|
|
956
|
+
doc_config.include_instances = not no_instances
|
|
957
|
+
|
|
958
|
+
if template:
|
|
959
|
+
doc_config.template_dir = template
|
|
960
|
+
if title:
|
|
961
|
+
doc_config.title = title
|
|
962
|
+
|
|
963
|
+
# Parse include/exclude filters
|
|
964
|
+
if include:
|
|
965
|
+
types = [t.strip().lower() for t in include.split(",")]
|
|
966
|
+
doc_config.include_classes = "classes" in types
|
|
967
|
+
doc_config.include_object_properties = "properties" in types or "object_properties" in types
|
|
968
|
+
doc_config.include_datatype_properties = "properties" in types or "datatype_properties" in types
|
|
969
|
+
doc_config.include_annotation_properties = "properties" in types or "annotation_properties" in types
|
|
970
|
+
doc_config.include_instances = "instances" in types
|
|
971
|
+
|
|
972
|
+
if exclude:
|
|
973
|
+
types = [t.strip().lower() for t in exclude.split(",")]
|
|
974
|
+
if "classes" in types:
|
|
975
|
+
doc_config.include_classes = False
|
|
976
|
+
if "properties" in types:
|
|
977
|
+
doc_config.include_object_properties = False
|
|
978
|
+
doc_config.include_datatype_properties = False
|
|
979
|
+
doc_config.include_annotation_properties = False
|
|
980
|
+
if "instances" in types:
|
|
981
|
+
doc_config.include_instances = False
|
|
982
|
+
|
|
983
|
+
# Load RDF sources
|
|
984
|
+
click.echo(f"Loading {len(sources)} source file(s)...")
|
|
985
|
+
graph = Graph()
|
|
986
|
+
|
|
987
|
+
for source in sources:
|
|
988
|
+
click.echo(f" Parsing {source.name}...")
|
|
989
|
+
|
|
990
|
+
# Determine format from extension
|
|
991
|
+
suffix = source.suffix.lower()
|
|
992
|
+
format_map = {
|
|
993
|
+
".ttl": "turtle",
|
|
994
|
+
".turtle": "turtle",
|
|
995
|
+
".rdf": "xml",
|
|
996
|
+
".xml": "xml",
|
|
997
|
+
".owl": "xml",
|
|
998
|
+
".nt": "nt",
|
|
999
|
+
".ntriples": "nt",
|
|
1000
|
+
".n3": "n3",
|
|
1001
|
+
".jsonld": "json-ld",
|
|
1002
|
+
".json": "json-ld",
|
|
1003
|
+
}
|
|
1004
|
+
rdf_format = format_map.get(suffix, "turtle")
|
|
1005
|
+
|
|
1006
|
+
graph.parse(str(source), format=rdf_format)
|
|
1007
|
+
|
|
1008
|
+
click.echo(f" Total: {len(graph)} triples")
|
|
1009
|
+
click.echo()
|
|
1010
|
+
|
|
1011
|
+
# Generate documentation
|
|
1012
|
+
click.echo(f"Generating {doc_config.format} documentation...")
|
|
1013
|
+
|
|
1014
|
+
generator = DocsGenerator(doc_config)
|
|
1015
|
+
result = generator.generate(graph)
|
|
1016
|
+
|
|
1017
|
+
# Summary
|
|
1018
|
+
click.echo()
|
|
1019
|
+
click.secho(f"✓ Generated {result.total_pages} files to {result.output_dir}/", fg="green")
|
|
1020
|
+
click.echo(f" Classes: {result.classes_count}")
|
|
1021
|
+
click.echo(f" Properties: {result.properties_count}")
|
|
1022
|
+
click.echo(f" Instances: {result.instances_count}")
|
|
1023
|
+
|
|
1024
|
+
# Show entry point
|
|
1025
|
+
if doc_config.format == "html":
|
|
1026
|
+
index_path = result.output_dir / "index.html"
|
|
1027
|
+
click.echo()
|
|
1028
|
+
click.secho(f"Open {index_path} in your browser to view the documentation.", fg="cyan")
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
@cli.command("shacl-gen")
|
|
1032
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
1033
|
+
@click.option(
|
|
1034
|
+
"--output",
|
|
1035
|
+
"-o",
|
|
1036
|
+
type=click.Path(path_type=Path),
|
|
1037
|
+
help="Output file path (default: <source>-shapes.ttl)",
|
|
1038
|
+
)
|
|
1039
|
+
@click.option(
|
|
1040
|
+
"--format",
|
|
1041
|
+
"-f",
|
|
1042
|
+
"output_format",
|
|
1043
|
+
type=click.Choice(["turtle", "ttl", "json-ld", "jsonld"], case_sensitive=False),
|
|
1044
|
+
default="turtle",
|
|
1045
|
+
help="Output format (default: turtle)",
|
|
1046
|
+
)
|
|
1047
|
+
@click.option(
|
|
1048
|
+
"--level",
|
|
1049
|
+
"-l",
|
|
1050
|
+
type=click.Choice(["minimal", "standard", "strict"], case_sensitive=False),
|
|
1051
|
+
default="standard",
|
|
1052
|
+
help="Strictness level for constraint generation (default: standard)",
|
|
1053
|
+
)
|
|
1054
|
+
@click.option(
|
|
1055
|
+
"--config",
|
|
1056
|
+
"-C",
|
|
1057
|
+
type=click.Path(exists=True, path_type=Path),
|
|
1058
|
+
help="YAML configuration file",
|
|
1059
|
+
)
|
|
1060
|
+
@click.option(
|
|
1061
|
+
"--classes",
|
|
1062
|
+
type=str,
|
|
1063
|
+
help="Comma-separated list of classes to generate shapes for",
|
|
1064
|
+
)
|
|
1065
|
+
@click.option(
|
|
1066
|
+
"--closed",
|
|
1067
|
+
is_flag=True,
|
|
1068
|
+
help="Generate closed shapes (no extra properties allowed)",
|
|
1069
|
+
)
|
|
1070
|
+
@click.option(
|
|
1071
|
+
"--default-severity",
|
|
1072
|
+
type=click.Choice(["violation", "warning", "info"], case_sensitive=False),
|
|
1073
|
+
default="violation",
|
|
1074
|
+
help="Default severity for generated constraints",
|
|
1075
|
+
)
|
|
1076
|
+
@click.option(
|
|
1077
|
+
"--no-labels",
|
|
1078
|
+
is_flag=True,
|
|
1079
|
+
help="Don't include rdfs:label as sh:name",
|
|
1080
|
+
)
|
|
1081
|
+
@click.option(
|
|
1082
|
+
"--no-descriptions",
|
|
1083
|
+
is_flag=True,
|
|
1084
|
+
help="Don't include rdfs:comment as sh:description",
|
|
1085
|
+
)
|
|
1086
|
+
@click.option(
|
|
1087
|
+
"--no-inherit",
|
|
1088
|
+
is_flag=True,
|
|
1089
|
+
help="Don't inherit constraints from superclasses",
|
|
1090
|
+
)
|
|
1091
|
+
def shacl_gen(
|
|
1092
|
+
source: Path,
|
|
1093
|
+
output: Path | None,
|
|
1094
|
+
output_format: str,
|
|
1095
|
+
level: str,
|
|
1096
|
+
config: Path | None,
|
|
1097
|
+
classes: str | None,
|
|
1098
|
+
closed: bool,
|
|
1099
|
+
default_severity: str,
|
|
1100
|
+
no_labels: bool,
|
|
1101
|
+
no_descriptions: bool,
|
|
1102
|
+
no_inherit: bool,
|
|
1103
|
+
):
|
|
1104
|
+
"""Generate SHACL validation shapes from OWL ontology.
|
|
1105
|
+
|
|
1106
|
+
Converts OWL class definitions to SHACL NodeShapes, extracting
|
|
1107
|
+
constraints from domain/range declarations, cardinality restrictions,
|
|
1108
|
+
functional properties, and other OWL patterns.
|
|
1109
|
+
|
|
1110
|
+
SOURCE: Input RDF ontology file (.ttl, .rdf, .owl, etc.)
|
|
1111
|
+
|
|
1112
|
+
\b
|
|
1113
|
+
Strictness levels:
|
|
1114
|
+
minimal - Basic type constraints only (sh:class, sh:datatype)
|
|
1115
|
+
standard - Adds cardinality and functional property constraints
|
|
1116
|
+
strict - Maximum constraints including sh:closed, enumerations
|
|
1117
|
+
|
|
1118
|
+
\b
|
|
1119
|
+
Examples:
|
|
1120
|
+
# Basic generation
|
|
1121
|
+
rdf-construct shacl-gen ontology.ttl
|
|
1122
|
+
|
|
1123
|
+
# Generate with strict constraints
|
|
1124
|
+
rdf-construct shacl-gen ontology.ttl --level strict --closed
|
|
1125
|
+
|
|
1126
|
+
# Custom output path and format
|
|
1127
|
+
rdf-construct shacl-gen ontology.ttl -o shapes.ttl --format turtle
|
|
1128
|
+
|
|
1129
|
+
# Focus on specific classes
|
|
1130
|
+
rdf-construct shacl-gen ontology.ttl --classes "ex:Building,ex:Floor"
|
|
1131
|
+
|
|
1132
|
+
# Use configuration file
|
|
1133
|
+
rdf-construct shacl-gen ontology.ttl --config shacl-config.yml
|
|
1134
|
+
|
|
1135
|
+
# Generate warnings instead of violations
|
|
1136
|
+
rdf-construct shacl-gen ontology.ttl --default-severity warning
|
|
1137
|
+
"""
|
|
1138
|
+
from rdf_construct.shacl import (
|
|
1139
|
+
generate_shapes_to_file,
|
|
1140
|
+
load_shacl_config,
|
|
1141
|
+
ShaclConfig,
|
|
1142
|
+
StrictnessLevel,
|
|
1143
|
+
Severity,
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
# Determine output path
|
|
1147
|
+
if output is None:
|
|
1148
|
+
suffix = ".json" if "json" in output_format.lower() else ".ttl"
|
|
1149
|
+
output = source.with_stem(f"{source.stem}-shapes").with_suffix(suffix)
|
|
1150
|
+
|
|
1151
|
+
# Normalise format string
|
|
1152
|
+
if output_format.lower() in ("ttl", "turtle"):
|
|
1153
|
+
output_format = "turtle"
|
|
1154
|
+
elif output_format.lower() in ("json-ld", "jsonld"):
|
|
1155
|
+
output_format = "json-ld"
|
|
1156
|
+
|
|
1157
|
+
try:
|
|
1158
|
+
# Load configuration from file or build from CLI options
|
|
1159
|
+
if config:
|
|
1160
|
+
shacl_config = load_shacl_config(config)
|
|
1161
|
+
click.echo(f"Loaded configuration from {config}")
|
|
1162
|
+
else:
|
|
1163
|
+
shacl_config = ShaclConfig()
|
|
1164
|
+
|
|
1165
|
+
# Apply CLI overrides
|
|
1166
|
+
shacl_config.level = StrictnessLevel(level.lower())
|
|
1167
|
+
|
|
1168
|
+
if classes:
|
|
1169
|
+
shacl_config.target_classes = [c.strip() for c in classes.split(",")]
|
|
1170
|
+
|
|
1171
|
+
if closed:
|
|
1172
|
+
shacl_config.closed = True
|
|
1173
|
+
|
|
1174
|
+
shacl_config.default_severity = Severity(default_severity.lower())
|
|
1175
|
+
|
|
1176
|
+
if no_labels:
|
|
1177
|
+
shacl_config.include_labels = False
|
|
1178
|
+
|
|
1179
|
+
if no_descriptions:
|
|
1180
|
+
shacl_config.include_descriptions = False
|
|
1181
|
+
|
|
1182
|
+
if no_inherit:
|
|
1183
|
+
shacl_config.inherit_constraints = False
|
|
1184
|
+
|
|
1185
|
+
# Generate shapes
|
|
1186
|
+
click.echo(f"Generating SHACL shapes from {source}...")
|
|
1187
|
+
click.echo(f" Level: {shacl_config.level.value}")
|
|
1188
|
+
|
|
1189
|
+
if shacl_config.target_classes:
|
|
1190
|
+
click.echo(f" Target classes: {', '.join(shacl_config.target_classes)}")
|
|
1191
|
+
|
|
1192
|
+
shapes_graph = generate_shapes_to_file(
|
|
1193
|
+
source,
|
|
1194
|
+
output,
|
|
1195
|
+
shacl_config,
|
|
1196
|
+
output_format,
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
# Count generated shapes
|
|
1200
|
+
from rdf_construct.shacl import SH
|
|
1201
|
+
num_shapes = len(list(shapes_graph.subjects(
|
|
1202
|
+
predicate=None, object=SH.NodeShape
|
|
1203
|
+
)))
|
|
1204
|
+
|
|
1205
|
+
click.secho(f"✓ Generated {num_shapes} shape(s) to {output}", fg="green")
|
|
1206
|
+
|
|
1207
|
+
if shacl_config.closed:
|
|
1208
|
+
click.echo(" (closed shapes enabled)")
|
|
1209
|
+
|
|
1210
|
+
except FileNotFoundError as e:
|
|
1211
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
1212
|
+
raise SystemExit(1)
|
|
1213
|
+
except ValueError as e:
|
|
1214
|
+
click.secho(f"Configuration error: {e}", fg="red", err=True)
|
|
1215
|
+
raise SystemExit(1)
|
|
1216
|
+
except Exception as e:
|
|
1217
|
+
click.secho(f"Error generating shapes: {e}", fg="red", err=True)
|
|
1218
|
+
raise SystemExit(1)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
# Output format choices
|
|
1222
|
+
OUTPUT_FORMATS = ["turtle", "ttl", "xml", "rdfxml", "jsonld", "json-ld", "nt", "ntriples"]
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
@cli.command()
|
|
1226
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
1227
|
+
@click.option(
|
|
1228
|
+
"--output",
|
|
1229
|
+
"-o",
|
|
1230
|
+
type=click.Path(path_type=Path),
|
|
1231
|
+
help="Output file path (default: source name with .ttl extension)",
|
|
1232
|
+
)
|
|
1233
|
+
@click.option(
|
|
1234
|
+
"--format",
|
|
1235
|
+
"-f",
|
|
1236
|
+
"output_format",
|
|
1237
|
+
type=click.Choice(OUTPUT_FORMATS, case_sensitive=False),
|
|
1238
|
+
default="turtle",
|
|
1239
|
+
help="Output RDF format (default: turtle)",
|
|
1240
|
+
)
|
|
1241
|
+
@click.option(
|
|
1242
|
+
"--namespace",
|
|
1243
|
+
"-n",
|
|
1244
|
+
help="Default namespace URI for the ontology",
|
|
1245
|
+
)
|
|
1246
|
+
@click.option(
|
|
1247
|
+
"--config",
|
|
1248
|
+
"-C",
|
|
1249
|
+
type=click.Path(exists=True, path_type=Path),
|
|
1250
|
+
help="Path to YAML configuration file",
|
|
1251
|
+
)
|
|
1252
|
+
@click.option(
|
|
1253
|
+
"--merge",
|
|
1254
|
+
"-m",
|
|
1255
|
+
type=click.Path(exists=True, path_type=Path),
|
|
1256
|
+
help="Existing ontology file to merge with",
|
|
1257
|
+
)
|
|
1258
|
+
@click.option(
|
|
1259
|
+
"--validate",
|
|
1260
|
+
"-v",
|
|
1261
|
+
is_flag=True,
|
|
1262
|
+
help="Validate only, don't generate output",
|
|
1263
|
+
)
|
|
1264
|
+
@click.option(
|
|
1265
|
+
"--strict",
|
|
1266
|
+
is_flag=True,
|
|
1267
|
+
help="Treat warnings as errors",
|
|
1268
|
+
)
|
|
1269
|
+
@click.option(
|
|
1270
|
+
"--language",
|
|
1271
|
+
"-l",
|
|
1272
|
+
default="en",
|
|
1273
|
+
help="Language tag for labels/comments (default: en)",
|
|
1274
|
+
)
|
|
1275
|
+
@click.option(
|
|
1276
|
+
"--no-labels",
|
|
1277
|
+
is_flag=True,
|
|
1278
|
+
help="Don't auto-generate rdfs:label triples",
|
|
1279
|
+
)
|
|
1280
|
+
def puml2rdf(
|
|
1281
|
+
source: Path,
|
|
1282
|
+
output: Path | None,
|
|
1283
|
+
output_format: str,
|
|
1284
|
+
namespace: str | None,
|
|
1285
|
+
config: Path | None,
|
|
1286
|
+
merge: Path | None,
|
|
1287
|
+
validate: bool,
|
|
1288
|
+
strict: bool,
|
|
1289
|
+
language: str,
|
|
1290
|
+
no_labels: bool,
|
|
1291
|
+
):
|
|
1292
|
+
"""Convert PlantUML class diagram to RDF ontology.
|
|
1293
|
+
|
|
1294
|
+
Parses a PlantUML file and generates an RDF/OWL ontology.
|
|
1295
|
+
Supports classes, attributes, inheritance, and associations.
|
|
1296
|
+
|
|
1297
|
+
SOURCE: PlantUML file (.puml or .plantuml)
|
|
1298
|
+
|
|
1299
|
+
\b
|
|
1300
|
+
Examples:
|
|
1301
|
+
# Basic conversion
|
|
1302
|
+
rdf-construct puml2rdf design.puml
|
|
1303
|
+
|
|
1304
|
+
# Custom output and namespace
|
|
1305
|
+
rdf-construct puml2rdf design.puml -o ontology.ttl -n http://example.org/ont#
|
|
1306
|
+
|
|
1307
|
+
# Validate without generating
|
|
1308
|
+
rdf-construct puml2rdf design.puml --validate
|
|
1309
|
+
|
|
1310
|
+
# Merge with existing ontology
|
|
1311
|
+
rdf-construct puml2rdf design.puml --merge existing.ttl
|
|
1312
|
+
|
|
1313
|
+
# Use configuration file
|
|
1314
|
+
rdf-construct puml2rdf design.puml -C import-config.yml
|
|
1315
|
+
|
|
1316
|
+
\b
|
|
1317
|
+
Exit codes:
|
|
1318
|
+
0 - Success
|
|
1319
|
+
1 - Validation warnings (with --strict)
|
|
1320
|
+
2 - Parse or validation errors
|
|
1321
|
+
"""
|
|
1322
|
+
# Normalise output format
|
|
1323
|
+
format_map = {
|
|
1324
|
+
"ttl": "turtle",
|
|
1325
|
+
"rdfxml": "xml",
|
|
1326
|
+
"json-ld": "json-ld",
|
|
1327
|
+
"jsonld": "json-ld",
|
|
1328
|
+
"ntriples": "nt",
|
|
1329
|
+
}
|
|
1330
|
+
rdf_format = format_map.get(output_format.lower(), output_format.lower())
|
|
1331
|
+
|
|
1332
|
+
# Determine output path
|
|
1333
|
+
if output is None and not validate:
|
|
1334
|
+
ext_map = {"turtle": ".ttl", "xml": ".rdf", "json-ld": ".jsonld", "nt": ".nt"}
|
|
1335
|
+
ext = ext_map.get(rdf_format, ".ttl")
|
|
1336
|
+
output = source.with_suffix(ext)
|
|
1337
|
+
|
|
1338
|
+
# Load configuration if provided
|
|
1339
|
+
if config:
|
|
1340
|
+
try:
|
|
1341
|
+
import_config = load_import_config(config)
|
|
1342
|
+
conversion_config = import_config.to_conversion_config()
|
|
1343
|
+
except Exception as e:
|
|
1344
|
+
click.secho(f"Error loading config: {e}", fg="red", err=True)
|
|
1345
|
+
sys.exit(2)
|
|
1346
|
+
else:
|
|
1347
|
+
conversion_config = ConversionConfig()
|
|
1348
|
+
|
|
1349
|
+
# Override config with CLI options
|
|
1350
|
+
if namespace:
|
|
1351
|
+
conversion_config.default_namespace = namespace
|
|
1352
|
+
if language:
|
|
1353
|
+
conversion_config.language = language
|
|
1354
|
+
if no_labels:
|
|
1355
|
+
conversion_config.generate_labels = False
|
|
1356
|
+
|
|
1357
|
+
# Parse PlantUML file
|
|
1358
|
+
click.echo(f"Parsing {source.name}...")
|
|
1359
|
+
parser = PlantUMLParser()
|
|
1360
|
+
|
|
1361
|
+
try:
|
|
1362
|
+
parse_result = parser.parse_file(source)
|
|
1363
|
+
except Exception as e:
|
|
1364
|
+
click.secho(f"Error reading file: {e}", fg="red", err=True)
|
|
1365
|
+
sys.exit(2)
|
|
1366
|
+
|
|
1367
|
+
# Report parse errors
|
|
1368
|
+
if parse_result.errors:
|
|
1369
|
+
click.secho("Parse errors:", fg="red", err=True)
|
|
1370
|
+
for error in parse_result.errors:
|
|
1371
|
+
click.echo(f" Line {error.line_number}: {error.message}", err=True)
|
|
1372
|
+
sys.exit(2)
|
|
1373
|
+
|
|
1374
|
+
# Report parse warnings
|
|
1375
|
+
if parse_result.warnings:
|
|
1376
|
+
click.secho("Parse warnings:", fg="yellow", err=True)
|
|
1377
|
+
for warning in parse_result.warnings:
|
|
1378
|
+
click.echo(f" {warning}", err=True)
|
|
1379
|
+
|
|
1380
|
+
model = parse_result.model
|
|
1381
|
+
click.echo(
|
|
1382
|
+
f" Found: {len(model.classes)} classes, "
|
|
1383
|
+
f"{len(model.relationships)} relationships"
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
# Validate model
|
|
1387
|
+
model_validation = validate_puml(model)
|
|
1388
|
+
|
|
1389
|
+
if model_validation.has_errors:
|
|
1390
|
+
click.secho("Model validation errors:", fg="red", err=True)
|
|
1391
|
+
for issue in model_validation.errors():
|
|
1392
|
+
click.echo(f" {issue}", err=True)
|
|
1393
|
+
sys.exit(2)
|
|
1394
|
+
|
|
1395
|
+
if model_validation.has_warnings:
|
|
1396
|
+
click.secho("Model validation warnings:", fg="yellow", err=True)
|
|
1397
|
+
for issue in model_validation.warnings():
|
|
1398
|
+
click.echo(f" {issue}", err=True)
|
|
1399
|
+
if strict:
|
|
1400
|
+
click.secho("Aborting due to --strict mode", fg="red", err=True)
|
|
1401
|
+
sys.exit(1)
|
|
1402
|
+
|
|
1403
|
+
# If validate-only mode, stop here
|
|
1404
|
+
if validate:
|
|
1405
|
+
if model_validation.has_warnings:
|
|
1406
|
+
click.secho(
|
|
1407
|
+
f"Validation complete: {model_validation.warning_count} warnings",
|
|
1408
|
+
fg="yellow",
|
|
1409
|
+
)
|
|
1410
|
+
else:
|
|
1411
|
+
click.secho("Validation complete: no issues found", fg="green")
|
|
1412
|
+
sys.exit(0)
|
|
1413
|
+
|
|
1414
|
+
# Convert to RDF
|
|
1415
|
+
click.echo("Converting to RDF...")
|
|
1416
|
+
converter = PumlToRdfConverter(conversion_config)
|
|
1417
|
+
conversion_result = converter.convert(model)
|
|
1418
|
+
|
|
1419
|
+
if conversion_result.warnings:
|
|
1420
|
+
click.secho("Conversion warnings:", fg="yellow", err=True)
|
|
1421
|
+
for warning in conversion_result.warnings:
|
|
1422
|
+
click.echo(f" {warning}", err=True)
|
|
1423
|
+
|
|
1424
|
+
graph = conversion_result.graph
|
|
1425
|
+
click.echo(f" Generated: {len(graph)} triples")
|
|
1426
|
+
|
|
1427
|
+
# Validate generated RDF
|
|
1428
|
+
rdf_validation = validate_rdf(graph)
|
|
1429
|
+
if rdf_validation.has_warnings:
|
|
1430
|
+
click.secho("RDF validation warnings:", fg="yellow", err=True)
|
|
1431
|
+
for issue in rdf_validation.warnings():
|
|
1432
|
+
click.echo(f" {issue}", err=True)
|
|
1433
|
+
|
|
1434
|
+
# Merge with existing if requested
|
|
1435
|
+
if merge:
|
|
1436
|
+
click.echo(f"Merging with {merge.name}...")
|
|
1437
|
+
try:
|
|
1438
|
+
merge_result = merge_with_existing(graph, merge)
|
|
1439
|
+
graph = merge_result.graph
|
|
1440
|
+
click.echo(
|
|
1441
|
+
f" Added: {merge_result.added_count}, "
|
|
1442
|
+
f"Preserved: {merge_result.preserved_count}"
|
|
1443
|
+
)
|
|
1444
|
+
if merge_result.conflicts:
|
|
1445
|
+
click.secho("Merge conflicts:", fg="yellow", err=True)
|
|
1446
|
+
for conflict in merge_result.conflicts[:5]: # Limit output
|
|
1447
|
+
click.echo(f" {conflict}", err=True)
|
|
1448
|
+
if len(merge_result.conflicts) > 5:
|
|
1449
|
+
click.echo(
|
|
1450
|
+
f" ... and {len(merge_result.conflicts) - 5} more",
|
|
1451
|
+
err=True,
|
|
1452
|
+
)
|
|
1453
|
+
except Exception as e:
|
|
1454
|
+
click.secho(f"Error merging: {e}", fg="red", err=True)
|
|
1455
|
+
sys.exit(2)
|
|
1456
|
+
|
|
1457
|
+
# Serialise output
|
|
1458
|
+
try:
|
|
1459
|
+
graph.serialize(str(output), format=rdf_format)
|
|
1460
|
+
click.secho(f"✓ Wrote {output}", fg="green")
|
|
1461
|
+
click.echo(
|
|
1462
|
+
f" Classes: {len(conversion_result.class_uris)}, "
|
|
1463
|
+
f"Properties: {len(conversion_result.property_uris)}"
|
|
1464
|
+
)
|
|
1465
|
+
except Exception as e:
|
|
1466
|
+
click.secho(f"Error writing output: {e}", fg="red", err=True)
|
|
1467
|
+
sys.exit(2)
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
@cli.command("cq-test")
|
|
1471
|
+
@click.argument("ontology", type=click.Path(exists=True, path_type=Path))
|
|
1472
|
+
@click.argument("test_file", type=click.Path(exists=True, path_type=Path))
|
|
1473
|
+
@click.option(
|
|
1474
|
+
"--data",
|
|
1475
|
+
"-d",
|
|
1476
|
+
multiple=True,
|
|
1477
|
+
type=click.Path(exists=True, path_type=Path),
|
|
1478
|
+
help="Additional data file(s) to load alongside the ontology",
|
|
1479
|
+
)
|
|
1480
|
+
@click.option(
|
|
1481
|
+
"--tag",
|
|
1482
|
+
"-t",
|
|
1483
|
+
multiple=True,
|
|
1484
|
+
help="Only run tests with these tags (can specify multiple)",
|
|
1485
|
+
)
|
|
1486
|
+
@click.option(
|
|
1487
|
+
"--exclude-tag",
|
|
1488
|
+
"-x",
|
|
1489
|
+
multiple=True,
|
|
1490
|
+
help="Exclude tests with these tags (can specify multiple)",
|
|
1491
|
+
)
|
|
1492
|
+
@click.option(
|
|
1493
|
+
"--format",
|
|
1494
|
+
"-f",
|
|
1495
|
+
"output_format",
|
|
1496
|
+
type=click.Choice(["text", "json", "junit"], case_sensitive=False),
|
|
1497
|
+
default="text",
|
|
1498
|
+
help="Output format (default: text)",
|
|
1499
|
+
)
|
|
1500
|
+
@click.option(
|
|
1501
|
+
"--output",
|
|
1502
|
+
"-o",
|
|
1503
|
+
type=click.Path(path_type=Path),
|
|
1504
|
+
help="Write output to file instead of stdout",
|
|
1505
|
+
)
|
|
1506
|
+
@click.option(
|
|
1507
|
+
"--verbose",
|
|
1508
|
+
"-v",
|
|
1509
|
+
is_flag=True,
|
|
1510
|
+
help="Show verbose output (query text, timing details)",
|
|
1511
|
+
)
|
|
1512
|
+
@click.option(
|
|
1513
|
+
"--fail-fast",
|
|
1514
|
+
is_flag=True,
|
|
1515
|
+
help="Stop on first failure",
|
|
1516
|
+
)
|
|
1517
|
+
def cq_test(
|
|
1518
|
+
ontology: Path,
|
|
1519
|
+
test_file: Path,
|
|
1520
|
+
data: tuple[Path, ...],
|
|
1521
|
+
tag: tuple[str, ...],
|
|
1522
|
+
exclude_tag: tuple[str, ...],
|
|
1523
|
+
output_format: str,
|
|
1524
|
+
output: Path | None,
|
|
1525
|
+
verbose: bool,
|
|
1526
|
+
fail_fast: bool,
|
|
1527
|
+
):
|
|
1528
|
+
"""Run competency question tests against an ontology.
|
|
1529
|
+
|
|
1530
|
+
Validates whether an ontology can answer competency questions expressed
|
|
1531
|
+
as SPARQL queries with expected results.
|
|
1532
|
+
|
|
1533
|
+
ONTOLOGY: RDF file containing the ontology to test
|
|
1534
|
+
TEST_FILE: YAML file containing competency question tests
|
|
1535
|
+
|
|
1536
|
+
\b
|
|
1537
|
+
Examples:
|
|
1538
|
+
# Run all tests
|
|
1539
|
+
rdf-construct cq-test ontology.ttl cq-tests.yml
|
|
1540
|
+
|
|
1541
|
+
# Run with additional sample data
|
|
1542
|
+
rdf-construct cq-test ontology.ttl cq-tests.yml --data sample-data.ttl
|
|
1543
|
+
|
|
1544
|
+
# Run only tests tagged 'core'
|
|
1545
|
+
rdf-construct cq-test ontology.ttl cq-tests.yml --tag core
|
|
1546
|
+
|
|
1547
|
+
# Generate JUnit XML for CI
|
|
1548
|
+
rdf-construct cq-test ontology.ttl cq-tests.yml --format junit -o results.xml
|
|
1549
|
+
|
|
1550
|
+
# Verbose output with timing
|
|
1551
|
+
rdf-construct cq-test ontology.ttl cq-tests.yml --verbose
|
|
1552
|
+
|
|
1553
|
+
\b
|
|
1554
|
+
Exit codes:
|
|
1555
|
+
0 - All tests passed
|
|
1556
|
+
1 - One or more tests failed
|
|
1557
|
+
2 - Error occurred (invalid file, parse error, etc.)
|
|
1558
|
+
"""
|
|
1559
|
+
try:
|
|
1560
|
+
# Load ontology
|
|
1561
|
+
click.echo(f"Loading ontology: {ontology.name}...", err=True)
|
|
1562
|
+
graph = Graph()
|
|
1563
|
+
graph.parse(str(ontology), format=_infer_format(ontology))
|
|
1564
|
+
|
|
1565
|
+
# Load additional data files
|
|
1566
|
+
if data:
|
|
1567
|
+
for data_file in data:
|
|
1568
|
+
click.echo(f"Loading data: {data_file.name}...", err=True)
|
|
1569
|
+
graph.parse(str(data_file), format=_infer_format(data_file))
|
|
1570
|
+
|
|
1571
|
+
# Load test suite
|
|
1572
|
+
click.echo(f"Loading tests: {test_file.name}...", err=True)
|
|
1573
|
+
suite = load_test_suite(test_file)
|
|
1574
|
+
|
|
1575
|
+
# Filter by tags
|
|
1576
|
+
if tag or exclude_tag:
|
|
1577
|
+
include_tags = set(tag) if tag else None
|
|
1578
|
+
exclude_tags = set(exclude_tag) if exclude_tag else None
|
|
1579
|
+
suite = suite.filter_by_tags(include_tags, exclude_tags)
|
|
1580
|
+
|
|
1581
|
+
if not suite.questions:
|
|
1582
|
+
click.secho("No tests to run (check tag filters)", fg="yellow", err=True)
|
|
1583
|
+
sys.exit(0)
|
|
1584
|
+
|
|
1585
|
+
# Run tests
|
|
1586
|
+
click.echo(f"Running {len(suite.questions)} test(s)...", err=True)
|
|
1587
|
+
click.echo("", err=True)
|
|
1588
|
+
|
|
1589
|
+
runner = CQTestRunner(fail_fast=fail_fast, verbose=verbose)
|
|
1590
|
+
results = runner.run(graph, suite, ontology_file=ontology)
|
|
1591
|
+
|
|
1592
|
+
# Format output
|
|
1593
|
+
formatted = format_results(results, format_name=output_format, verbose=verbose)
|
|
1594
|
+
|
|
1595
|
+
# Write output
|
|
1596
|
+
if output:
|
|
1597
|
+
output.write_text(formatted)
|
|
1598
|
+
click.secho(f"✓ Results written to {output}", fg="green", err=True)
|
|
1599
|
+
else:
|
|
1600
|
+
click.echo(formatted)
|
|
1601
|
+
|
|
1602
|
+
# Exit code based on results
|
|
1603
|
+
if results.has_errors:
|
|
1604
|
+
sys.exit(2)
|
|
1605
|
+
elif results.has_failures:
|
|
1606
|
+
sys.exit(1)
|
|
1607
|
+
else:
|
|
1608
|
+
sys.exit(0)
|
|
1609
|
+
|
|
1610
|
+
except FileNotFoundError as e:
|
|
1611
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
1612
|
+
sys.exit(2)
|
|
1613
|
+
except ValueError as e:
|
|
1614
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
1615
|
+
sys.exit(2)
|
|
1616
|
+
except Exception as e:
|
|
1617
|
+
click.secho(f"Error: {type(e).__name__}: {e}", fg="red", err=True)
|
|
1618
|
+
sys.exit(2)
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def _infer_format(path: Path) -> str:
|
|
1622
|
+
"""Infer RDF format from file extension."""
|
|
1623
|
+
suffix = path.suffix.lower()
|
|
1624
|
+
format_map = {
|
|
1625
|
+
".ttl": "turtle",
|
|
1626
|
+
".turtle": "turtle",
|
|
1627
|
+
".rdf": "xml",
|
|
1628
|
+
".xml": "xml",
|
|
1629
|
+
".owl": "xml",
|
|
1630
|
+
".nt": "nt",
|
|
1631
|
+
".ntriples": "nt",
|
|
1632
|
+
".n3": "n3",
|
|
1633
|
+
".jsonld": "json-ld",
|
|
1634
|
+
".json": "json-ld",
|
|
1635
|
+
}
|
|
1636
|
+
return format_map.get(suffix, "turtle")
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
@cli.command()
|
|
1640
|
+
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
|
|
1641
|
+
@click.option(
|
|
1642
|
+
"--output",
|
|
1643
|
+
"-o",
|
|
1644
|
+
type=click.Path(path_type=Path),
|
|
1645
|
+
help="Write output to file instead of stdout",
|
|
1646
|
+
)
|
|
1647
|
+
@click.option(
|
|
1648
|
+
"--format",
|
|
1649
|
+
"-f",
|
|
1650
|
+
"output_format",
|
|
1651
|
+
type=click.Choice(["text", "json", "markdown", "md"], case_sensitive=False),
|
|
1652
|
+
default="text",
|
|
1653
|
+
help="Output format (default: text)",
|
|
1654
|
+
)
|
|
1655
|
+
@click.option(
|
|
1656
|
+
"--compare",
|
|
1657
|
+
is_flag=True,
|
|
1658
|
+
help="Compare two ontology files (requires exactly 2 files)",
|
|
1659
|
+
)
|
|
1660
|
+
@click.option(
|
|
1661
|
+
"--include",
|
|
1662
|
+
type=str,
|
|
1663
|
+
help="Include only these metric categories (comma-separated: basic,hierarchy,properties,documentation,complexity,connectivity)",
|
|
1664
|
+
)
|
|
1665
|
+
@click.option(
|
|
1666
|
+
"--exclude",
|
|
1667
|
+
type=str,
|
|
1668
|
+
help="Exclude these metric categories (comma-separated)",
|
|
1669
|
+
)
|
|
1670
|
+
def stats(
|
|
1671
|
+
files: tuple[Path, ...],
|
|
1672
|
+
output: Path | None,
|
|
1673
|
+
output_format: str,
|
|
1674
|
+
compare: bool,
|
|
1675
|
+
include: str | None,
|
|
1676
|
+
exclude: str | None,
|
|
1677
|
+
):
|
|
1678
|
+
"""Compute and display ontology statistics.
|
|
1679
|
+
|
|
1680
|
+
Analyses one or more RDF ontology files and displays comprehensive metrics
|
|
1681
|
+
about structure, complexity, and documentation coverage.
|
|
1682
|
+
|
|
1683
|
+
\b
|
|
1684
|
+
Examples:
|
|
1685
|
+
# Basic statistics
|
|
1686
|
+
rdf-construct stats ontology.ttl
|
|
1687
|
+
|
|
1688
|
+
# JSON output for programmatic use
|
|
1689
|
+
rdf-construct stats ontology.ttl --format json -o stats.json
|
|
1690
|
+
|
|
1691
|
+
# Markdown for documentation
|
|
1692
|
+
rdf-construct stats ontology.ttl --format markdown >> README.md
|
|
1693
|
+
|
|
1694
|
+
# Compare two versions
|
|
1695
|
+
rdf-construct stats v1.ttl v2.ttl --compare
|
|
1696
|
+
|
|
1697
|
+
# Only show specific categories
|
|
1698
|
+
rdf-construct stats ontology.ttl --include basic,documentation
|
|
1699
|
+
|
|
1700
|
+
# Exclude some categories
|
|
1701
|
+
rdf-construct stats ontology.ttl --exclude connectivity,complexity
|
|
1702
|
+
|
|
1703
|
+
\b
|
|
1704
|
+
Metric Categories:
|
|
1705
|
+
basic - Counts (triples, classes, properties, individuals)
|
|
1706
|
+
hierarchy - Structure (depth, branching, orphans)
|
|
1707
|
+
properties - Coverage (domain, range, functional, symmetric)
|
|
1708
|
+
documentation - Labels and comments
|
|
1709
|
+
complexity - Multiple inheritance, OWL axioms
|
|
1710
|
+
connectivity - Most connected class, isolated classes
|
|
1711
|
+
|
|
1712
|
+
\b
|
|
1713
|
+
Exit codes:
|
|
1714
|
+
0 - Success
|
|
1715
|
+
1 - Error occurred
|
|
1716
|
+
"""
|
|
1717
|
+
try:
|
|
1718
|
+
# Validate file count for compare mode
|
|
1719
|
+
if compare:
|
|
1720
|
+
if len(files) != 2:
|
|
1721
|
+
click.secho(
|
|
1722
|
+
"Error: --compare requires exactly 2 files",
|
|
1723
|
+
fg="red",
|
|
1724
|
+
err=True,
|
|
1725
|
+
)
|
|
1726
|
+
sys.exit(1)
|
|
1727
|
+
|
|
1728
|
+
# Parse include/exclude categories
|
|
1729
|
+
include_set: set[str] | None = None
|
|
1730
|
+
exclude_set: set[str] | None = None
|
|
1731
|
+
|
|
1732
|
+
if include:
|
|
1733
|
+
include_set = {cat.strip().lower() for cat in include.split(",")}
|
|
1734
|
+
if exclude:
|
|
1735
|
+
exclude_set = {cat.strip().lower() for cat in exclude.split(",")}
|
|
1736
|
+
|
|
1737
|
+
# Load graphs
|
|
1738
|
+
graphs: list[tuple[Graph, Path]] = []
|
|
1739
|
+
for filepath in files:
|
|
1740
|
+
click.echo(f"Loading {filepath}...", err=True)
|
|
1741
|
+
graph = Graph()
|
|
1742
|
+
graph.parse(str(filepath), format="turtle")
|
|
1743
|
+
graphs.append((graph, filepath))
|
|
1744
|
+
click.echo(f" Loaded {len(graph)} triples", err=True)
|
|
1745
|
+
|
|
1746
|
+
if compare:
|
|
1747
|
+
# Comparison mode
|
|
1748
|
+
old_graph, old_path = graphs[0]
|
|
1749
|
+
new_graph, new_path = graphs[1]
|
|
1750
|
+
|
|
1751
|
+
click.echo("Collecting statistics...", err=True)
|
|
1752
|
+
old_stats = collect_stats(
|
|
1753
|
+
old_graph,
|
|
1754
|
+
source=str(old_path),
|
|
1755
|
+
include=include_set,
|
|
1756
|
+
exclude=exclude_set,
|
|
1757
|
+
)
|
|
1758
|
+
new_stats = collect_stats(
|
|
1759
|
+
new_graph,
|
|
1760
|
+
source=str(new_path),
|
|
1761
|
+
include=include_set,
|
|
1762
|
+
exclude=exclude_set,
|
|
1763
|
+
)
|
|
1764
|
+
|
|
1765
|
+
click.echo("Comparing versions...", err=True)
|
|
1766
|
+
comparison = compare_stats(old_stats, new_stats)
|
|
1767
|
+
|
|
1768
|
+
# Format output
|
|
1769
|
+
formatted = format_comparison(
|
|
1770
|
+
comparison,
|
|
1771
|
+
format_name=output_format,
|
|
1772
|
+
graph=new_graph,
|
|
1773
|
+
)
|
|
1774
|
+
else:
|
|
1775
|
+
# Single file or multiple files (show stats for first)
|
|
1776
|
+
graph, filepath = graphs[0]
|
|
1777
|
+
|
|
1778
|
+
click.echo("Collecting statistics...", err=True)
|
|
1779
|
+
ontology_stats = collect_stats(
|
|
1780
|
+
graph,
|
|
1781
|
+
source=str(filepath),
|
|
1782
|
+
include=include_set,
|
|
1783
|
+
exclude=exclude_set,
|
|
1784
|
+
)
|
|
1785
|
+
|
|
1786
|
+
# Format output
|
|
1787
|
+
formatted = format_stats(
|
|
1788
|
+
ontology_stats,
|
|
1789
|
+
format_name=output_format,
|
|
1790
|
+
graph=graph,
|
|
1791
|
+
)
|
|
1792
|
+
|
|
1793
|
+
# Write output
|
|
1794
|
+
if output:
|
|
1795
|
+
output.write_text(formatted)
|
|
1796
|
+
click.secho(f"✓ Wrote stats to {output}", fg="green", err=True)
|
|
1797
|
+
else:
|
|
1798
|
+
click.echo(formatted)
|
|
1799
|
+
|
|
1800
|
+
sys.exit(0)
|
|
1801
|
+
|
|
1802
|
+
except ValueError as e:
|
|
1803
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
1804
|
+
sys.exit(1)
|
|
1805
|
+
except FileNotFoundError as e:
|
|
1806
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
1807
|
+
sys.exit(1)
|
|
1808
|
+
except Exception as e:
|
|
1809
|
+
click.secho(f"Error: {e}", fg="red", err=True)
|
|
1810
|
+
sys.exit(1)
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
@cli.command()
|
|
1814
|
+
@click.argument("sources", nargs=-1, type=click.Path(exists=True, path_type=Path))
|
|
1815
|
+
@click.option(
|
|
1816
|
+
"--output",
|
|
1817
|
+
"-o",
|
|
1818
|
+
type=click.Path(path_type=Path),
|
|
1819
|
+
required=True,
|
|
1820
|
+
help="Output file for merged ontology",
|
|
1821
|
+
)
|
|
1822
|
+
@click.option(
|
|
1823
|
+
"--config",
|
|
1824
|
+
"-c",
|
|
1825
|
+
"config_file",
|
|
1826
|
+
type=click.Path(exists=True, path_type=Path),
|
|
1827
|
+
help="YAML configuration file",
|
|
1828
|
+
)
|
|
1829
|
+
@click.option(
|
|
1830
|
+
"--priority",
|
|
1831
|
+
"-p",
|
|
1832
|
+
multiple=True,
|
|
1833
|
+
type=int,
|
|
1834
|
+
help="Priority for each source (order matches sources)",
|
|
1835
|
+
)
|
|
1836
|
+
@click.option(
|
|
1837
|
+
"--strategy",
|
|
1838
|
+
type=click.Choice(["priority", "first", "last", "mark_all"], case_sensitive=False),
|
|
1839
|
+
default="priority",
|
|
1840
|
+
help="Conflict resolution strategy (default: priority)",
|
|
1841
|
+
)
|
|
1842
|
+
@click.option(
|
|
1843
|
+
"--report",
|
|
1844
|
+
"-r",
|
|
1845
|
+
type=click.Path(path_type=Path),
|
|
1846
|
+
help="Write conflict report to file",
|
|
1847
|
+
)
|
|
1848
|
+
@click.option(
|
|
1849
|
+
"--report-format",
|
|
1850
|
+
type=click.Choice(["text", "markdown", "md"], case_sensitive=False),
|
|
1851
|
+
default="markdown",
|
|
1852
|
+
help="Format for conflict report (default: markdown)",
|
|
1853
|
+
)
|
|
1854
|
+
@click.option(
|
|
1855
|
+
"--imports",
|
|
1856
|
+
type=click.Choice(["preserve", "remove", "merge"], case_sensitive=False),
|
|
1857
|
+
default="preserve",
|
|
1858
|
+
help="How to handle owl:imports (default: preserve)",
|
|
1859
|
+
)
|
|
1860
|
+
@click.option(
|
|
1861
|
+
"--migrate-data",
|
|
1862
|
+
multiple=True,
|
|
1863
|
+
type=click.Path(exists=True, path_type=Path),
|
|
1864
|
+
help="Data file(s) to migrate",
|
|
1865
|
+
)
|
|
1866
|
+
@click.option(
|
|
1867
|
+
"--migration-rules",
|
|
1868
|
+
type=click.Path(exists=True, path_type=Path),
|
|
1869
|
+
help="YAML file with migration rules",
|
|
1870
|
+
)
|
|
1871
|
+
@click.option(
|
|
1872
|
+
"--data-output",
|
|
1873
|
+
type=click.Path(path_type=Path),
|
|
1874
|
+
help="Output path for migrated data",
|
|
1875
|
+
)
|
|
1876
|
+
@click.option(
|
|
1877
|
+
"--dry-run",
|
|
1878
|
+
is_flag=True,
|
|
1879
|
+
help="Show what would happen without writing files",
|
|
1880
|
+
)
|
|
1881
|
+
@click.option(
|
|
1882
|
+
"--no-colour",
|
|
1883
|
+
is_flag=True,
|
|
1884
|
+
help="Disable coloured output",
|
|
1885
|
+
)
|
|
1886
|
+
@click.option(
|
|
1887
|
+
"--init",
|
|
1888
|
+
"init_config",
|
|
1889
|
+
is_flag=True,
|
|
1890
|
+
help="Generate a default merge configuration file",
|
|
1891
|
+
)
|
|
1892
|
+
def merge(
|
|
1893
|
+
sources: tuple[Path, ...],
|
|
1894
|
+
output: Path,
|
|
1895
|
+
config_file: Path | None,
|
|
1896
|
+
priority: tuple[int, ...],
|
|
1897
|
+
strategy: str,
|
|
1898
|
+
report: Path | None,
|
|
1899
|
+
report_format: str,
|
|
1900
|
+
imports: str,
|
|
1901
|
+
migrate_data: tuple[Path, ...],
|
|
1902
|
+
migration_rules: Path | None,
|
|
1903
|
+
data_output: Path | None,
|
|
1904
|
+
dry_run: bool,
|
|
1905
|
+
no_colour: bool,
|
|
1906
|
+
init_config: bool,
|
|
1907
|
+
):
|
|
1908
|
+
"""Merge multiple RDF ontology files.
|
|
1909
|
+
|
|
1910
|
+
Combines SOURCES into a single output ontology, detecting and handling
|
|
1911
|
+
conflicts between definitions.
|
|
1912
|
+
|
|
1913
|
+
\b
|
|
1914
|
+
SOURCES: One or more RDF files to merge (.ttl, .rdf, .owl)
|
|
1915
|
+
|
|
1916
|
+
\b
|
|
1917
|
+
Exit codes:
|
|
1918
|
+
0 - Merge successful, no unresolved conflicts
|
|
1919
|
+
1 - Merge successful, but unresolved conflicts marked in output
|
|
1920
|
+
2 - Error (file not found, parse error, etc.)
|
|
1921
|
+
|
|
1922
|
+
\b
|
|
1923
|
+
Examples:
|
|
1924
|
+
# Basic merge of two files
|
|
1925
|
+
rdf-construct merge core.ttl ext.ttl -o merged.ttl
|
|
1926
|
+
|
|
1927
|
+
# With priorities (higher wins conflicts)
|
|
1928
|
+
rdf-construct merge core.ttl ext.ttl -o merged.ttl -p 1 -p 2
|
|
1929
|
+
|
|
1930
|
+
# Generate conflict report
|
|
1931
|
+
rdf-construct merge core.ttl ext.ttl -o merged.ttl --report conflicts.md
|
|
1932
|
+
|
|
1933
|
+
# Mark all conflicts for manual review
|
|
1934
|
+
rdf-construct merge core.ttl ext.ttl -o merged.ttl --strategy mark_all
|
|
1935
|
+
|
|
1936
|
+
# With data migration
|
|
1937
|
+
rdf-construct merge core.ttl ext.ttl -o merged.ttl \\
|
|
1938
|
+
--migrate-data split_instances.ttl --data-output migrated.ttl
|
|
1939
|
+
|
|
1940
|
+
# Use configuration file
|
|
1941
|
+
rdf-construct merge --config merge.yml -o merged.ttl
|
|
1942
|
+
|
|
1943
|
+
# Generate default config file
|
|
1944
|
+
rdf-construct merge --init
|
|
1945
|
+
"""
|
|
1946
|
+
# Handle --init flag
|
|
1947
|
+
if init_config:
|
|
1948
|
+
config_path = Path("merge.yml")
|
|
1949
|
+
if config_path.exists():
|
|
1950
|
+
click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
|
|
1951
|
+
raise click.Abort()
|
|
1952
|
+
|
|
1953
|
+
config_content = create_default_config()
|
|
1954
|
+
config_path.write_text(config_content)
|
|
1955
|
+
click.secho(f"Created {config_path}", fg="green")
|
|
1956
|
+
click.echo("Edit this file to configure your merge, then run:")
|
|
1957
|
+
click.echo(f" rdf-construct merge --config {config_path} -o merged.ttl")
|
|
1958
|
+
return
|
|
1959
|
+
|
|
1960
|
+
# Validate we have sources
|
|
1961
|
+
if not sources and not config_file:
|
|
1962
|
+
click.secho("Error: No source files specified.", fg="red", err=True)
|
|
1963
|
+
click.echo("Provide source files or use --config with a configuration file.", err=True)
|
|
1964
|
+
raise click.Abort()
|
|
1965
|
+
|
|
1966
|
+
# Build configuration
|
|
1967
|
+
if config_file:
|
|
1968
|
+
try:
|
|
1969
|
+
config = load_merge_config(config_file)
|
|
1970
|
+
click.echo(f"Using config: {config_file}")
|
|
1971
|
+
|
|
1972
|
+
# Override output if provided on CLI
|
|
1973
|
+
if output:
|
|
1974
|
+
config.output = OutputConfig(path=output)
|
|
1975
|
+
except (FileNotFoundError, ValueError) as e:
|
|
1976
|
+
click.secho(f"Error loading config: {e}", fg="red", err=True)
|
|
1977
|
+
raise click.Abort()
|
|
1978
|
+
else:
|
|
1979
|
+
# Build config from CLI arguments
|
|
1980
|
+
priorities_list = list(priority) if priority else list(range(1, len(sources) + 1))
|
|
1981
|
+
|
|
1982
|
+
# Pad priorities if needed
|
|
1983
|
+
while len(priorities_list) < len(sources):
|
|
1984
|
+
priorities_list.append(len(priorities_list) + 1)
|
|
1985
|
+
|
|
1986
|
+
source_configs = [
|
|
1987
|
+
SourceConfig(path=p, priority=pri)
|
|
1988
|
+
for p, pri in zip(sources, priorities_list)
|
|
1989
|
+
]
|
|
1990
|
+
|
|
1991
|
+
conflict_strategy = ConflictStrategy[strategy.upper()]
|
|
1992
|
+
imports_strategy = ImportsStrategy[imports.upper()]
|
|
1993
|
+
|
|
1994
|
+
# Data migration config
|
|
1995
|
+
data_migration = None
|
|
1996
|
+
if migrate_data:
|
|
1997
|
+
data_migration = DataMigrationConfig(
|
|
1998
|
+
data_sources=list(migrate_data),
|
|
1999
|
+
output_path=data_output,
|
|
2000
|
+
)
|
|
2001
|
+
|
|
2002
|
+
config = MergeConfig(
|
|
2003
|
+
sources=source_configs,
|
|
2004
|
+
output=OutputConfig(path=output),
|
|
2005
|
+
conflicts=ConflictConfig(
|
|
2006
|
+
strategy=conflict_strategy,
|
|
2007
|
+
report_path=report,
|
|
2008
|
+
),
|
|
2009
|
+
imports=imports_strategy,
|
|
2010
|
+
migrate_data=data_migration,
|
|
2011
|
+
dry_run=dry_run,
|
|
2012
|
+
)
|
|
2013
|
+
|
|
2014
|
+
# Execute merge
|
|
2015
|
+
click.echo("Merging ontologies...")
|
|
2016
|
+
|
|
2017
|
+
merger = OntologyMerger(config)
|
|
2018
|
+
result = merger.merge()
|
|
2019
|
+
|
|
2020
|
+
if not result.success:
|
|
2021
|
+
click.secho(f"✗ Merge failed: {result.error}", fg="red", err=True)
|
|
2022
|
+
raise SystemExit(2)
|
|
2023
|
+
|
|
2024
|
+
# Display results
|
|
2025
|
+
use_colour = not no_colour
|
|
2026
|
+
text_formatter = get_formatter("text", use_colour=use_colour)
|
|
2027
|
+
click.echo(text_formatter.format_merge_result(result, result.merged_graph))
|
|
2028
|
+
|
|
2029
|
+
# Write output (unless dry run)
|
|
2030
|
+
if not dry_run and result.merged_graph and config.output:
|
|
2031
|
+
merger.write_output(result, config.output.path)
|
|
2032
|
+
click.echo()
|
|
2033
|
+
click.secho(f"✓ Wrote {config.output.path}", fg="green")
|
|
2034
|
+
|
|
2035
|
+
# Generate conflict report if requested
|
|
2036
|
+
if report and result.conflicts:
|
|
2037
|
+
report_formatter = get_formatter(report_format)
|
|
2038
|
+
report_content = report_formatter.format_conflict_report(
|
|
2039
|
+
result.conflicts, result.merged_graph
|
|
2040
|
+
)
|
|
2041
|
+
report.parent.mkdir(parents=True, exist_ok=True)
|
|
2042
|
+
report.write_text(report_content)
|
|
2043
|
+
click.echo(f" Conflict report: {report}")
|
|
2044
|
+
|
|
2045
|
+
# Handle data migration
|
|
2046
|
+
if config.migrate_data and config.migrate_data.data_sources:
|
|
2047
|
+
click.echo()
|
|
2048
|
+
click.echo("Migrating data...")
|
|
2049
|
+
|
|
2050
|
+
# Build URI map from any namespace remappings
|
|
2051
|
+
from rdf_construct.merge import DataMigrator
|
|
2052
|
+
|
|
2053
|
+
migrator = DataMigrator()
|
|
2054
|
+
uri_map: dict[URIRef, URIRef] = {}
|
|
2055
|
+
|
|
2056
|
+
# Collect namespace remaps from all sources
|
|
2057
|
+
for src in config.sources:
|
|
2058
|
+
if src.namespace_remap:
|
|
2059
|
+
for old_ns, new_ns in src.namespace_remap.items():
|
|
2060
|
+
# We'd need to scan data files to build complete map
|
|
2061
|
+
# For now, this is a placeholder
|
|
2062
|
+
pass
|
|
2063
|
+
|
|
2064
|
+
# Apply migration
|
|
2065
|
+
migration_result = migrate_data_files(
|
|
2066
|
+
data_paths=config.migrate_data.data_sources,
|
|
2067
|
+
uri_map=uri_map if uri_map else None,
|
|
2068
|
+
rules=config.migrate_data.rules if config.migrate_data.rules else None,
|
|
2069
|
+
output_path=config.migrate_data.output_path if not dry_run else None,
|
|
2070
|
+
)
|
|
2071
|
+
|
|
2072
|
+
if migration_result.success:
|
|
2073
|
+
click.echo(text_formatter.format_migration_result(migration_result))
|
|
2074
|
+
if config.migrate_data.output_path and not dry_run:
|
|
2075
|
+
click.secho(
|
|
2076
|
+
f"✓ Wrote migrated data to {config.migrate_data.output_path}",
|
|
2077
|
+
fg="green",
|
|
2078
|
+
)
|
|
2079
|
+
else:
|
|
2080
|
+
click.secho(
|
|
2081
|
+
f"✗ Data migration failed: {migration_result.error}",
|
|
2082
|
+
fg="red",
|
|
2083
|
+
err=True,
|
|
2084
|
+
)
|
|
2085
|
+
|
|
2086
|
+
# Exit code based on unresolved conflicts
|
|
2087
|
+
if result.unresolved_conflicts:
|
|
2088
|
+
click.echo()
|
|
2089
|
+
click.secho(
|
|
2090
|
+
f"⚠ {len(result.unresolved_conflicts)} unresolved conflict(s) "
|
|
2091
|
+
"marked in output",
|
|
2092
|
+
fg="yellow",
|
|
2093
|
+
)
|
|
2094
|
+
raise SystemExit(1)
|
|
2095
|
+
else:
|
|
2096
|
+
raise SystemExit(0)
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
@cli.command()
|
|
2100
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
2101
|
+
@click.option(
|
|
2102
|
+
"--output",
|
|
2103
|
+
"-o",
|
|
2104
|
+
"output_dir",
|
|
2105
|
+
type=click.Path(path_type=Path),
|
|
2106
|
+
default=Path("modules"),
|
|
2107
|
+
help="Output directory for split modules (default: modules/)",
|
|
2108
|
+
)
|
|
2109
|
+
@click.option(
|
|
2110
|
+
"--config",
|
|
2111
|
+
"-c",
|
|
2112
|
+
"config_file",
|
|
2113
|
+
type=click.Path(exists=True, path_type=Path),
|
|
2114
|
+
help="YAML configuration file for split",
|
|
2115
|
+
)
|
|
2116
|
+
@click.option(
|
|
2117
|
+
"--by-namespace",
|
|
2118
|
+
is_flag=True,
|
|
2119
|
+
help="Automatically split by namespace (auto-detect modules)",
|
|
2120
|
+
)
|
|
2121
|
+
@click.option(
|
|
2122
|
+
"--migrate-data",
|
|
2123
|
+
multiple=True,
|
|
2124
|
+
type=click.Path(exists=True, path_type=Path),
|
|
2125
|
+
help="Data file(s) to split by instance type",
|
|
2126
|
+
)
|
|
2127
|
+
@click.option(
|
|
2128
|
+
"--data-output",
|
|
2129
|
+
type=click.Path(path_type=Path),
|
|
2130
|
+
help="Output directory for split data files",
|
|
2131
|
+
)
|
|
2132
|
+
@click.option(
|
|
2133
|
+
"--unmatched",
|
|
2134
|
+
type=click.Choice(["common", "error"], case_sensitive=False),
|
|
2135
|
+
default="common",
|
|
2136
|
+
help="Strategy for unmatched entities (default: common)",
|
|
2137
|
+
)
|
|
2138
|
+
@click.option(
|
|
2139
|
+
"--common-name",
|
|
2140
|
+
default="common",
|
|
2141
|
+
help="Name for common module (default: common)",
|
|
2142
|
+
)
|
|
2143
|
+
@click.option(
|
|
2144
|
+
"--no-manifest",
|
|
2145
|
+
is_flag=True,
|
|
2146
|
+
help="Don't generate manifest.yml",
|
|
2147
|
+
)
|
|
2148
|
+
@click.option(
|
|
2149
|
+
"--dry-run",
|
|
2150
|
+
is_flag=True,
|
|
2151
|
+
help="Show what would happen without writing files",
|
|
2152
|
+
)
|
|
2153
|
+
@click.option(
|
|
2154
|
+
"--no-colour",
|
|
2155
|
+
is_flag=True,
|
|
2156
|
+
help="Disable coloured output",
|
|
2157
|
+
)
|
|
2158
|
+
@click.option(
|
|
2159
|
+
"--init",
|
|
2160
|
+
"init_config",
|
|
2161
|
+
is_flag=True,
|
|
2162
|
+
help="Generate a default split configuration file",
|
|
2163
|
+
)
|
|
2164
|
+
def split(
|
|
2165
|
+
source: Path,
|
|
2166
|
+
output_dir: Path,
|
|
2167
|
+
config_file: Path | None,
|
|
2168
|
+
by_namespace: bool,
|
|
2169
|
+
migrate_data: tuple[Path, ...],
|
|
2170
|
+
data_output: Path | None,
|
|
2171
|
+
unmatched: str,
|
|
2172
|
+
common_name: str,
|
|
2173
|
+
no_manifest: bool,
|
|
2174
|
+
dry_run: bool,
|
|
2175
|
+
no_colour: bool,
|
|
2176
|
+
init_config: bool,
|
|
2177
|
+
):
|
|
2178
|
+
"""Split a monolithic ontology into multiple modules.
|
|
2179
|
+
|
|
2180
|
+
SOURCE: RDF ontology file to split (.ttl, .rdf, .owl)
|
|
2181
|
+
|
|
2182
|
+
\b
|
|
2183
|
+
Exit codes:
|
|
2184
|
+
0 - Split successful
|
|
2185
|
+
1 - Split successful with unmatched entities in common module
|
|
2186
|
+
2 - Error (file not found, config invalid, etc.)
|
|
2187
|
+
|
|
2188
|
+
\b
|
|
2189
|
+
Examples:
|
|
2190
|
+
# Split by namespace (auto-detect modules)
|
|
2191
|
+
rdf-construct split large.ttl -o modules/ --by-namespace
|
|
2192
|
+
|
|
2193
|
+
# Split using configuration file
|
|
2194
|
+
rdf-construct split large.ttl -o modules/ -c split.yml
|
|
2195
|
+
|
|
2196
|
+
# With data migration
|
|
2197
|
+
rdf-construct split large.ttl -o modules/ -c split.yml \\
|
|
2198
|
+
--migrate-data split_instances.ttl --data-output data/
|
|
2199
|
+
|
|
2200
|
+
# Dry run - show what would be created
|
|
2201
|
+
rdf-construct split large.ttl -o modules/ --by-namespace --dry-run
|
|
2202
|
+
|
|
2203
|
+
# Generate default config file
|
|
2204
|
+
rdf-construct split --init
|
|
2205
|
+
"""
|
|
2206
|
+
# Handle --init flag
|
|
2207
|
+
if init_config:
|
|
2208
|
+
config_path = Path("split.yml")
|
|
2209
|
+
if config_path.exists():
|
|
2210
|
+
click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
|
|
2211
|
+
raise click.Abort()
|
|
2212
|
+
|
|
2213
|
+
config_content = create_default_split_config()
|
|
2214
|
+
config_path.write_text(config_content)
|
|
2215
|
+
click.secho(f"Created {config_path}", fg="green")
|
|
2216
|
+
click.echo("Edit this file to configure your split, then run:")
|
|
2217
|
+
click.echo(f" rdf-construct split your-ontology.ttl -c {config_path}")
|
|
2218
|
+
return
|
|
2219
|
+
|
|
2220
|
+
# Validate we have a source
|
|
2221
|
+
if not source:
|
|
2222
|
+
click.secho("Error: SOURCE is required.", fg="red", err=True)
|
|
2223
|
+
raise click.Abort()
|
|
2224
|
+
|
|
2225
|
+
# Handle --by-namespace mode
|
|
2226
|
+
if by_namespace:
|
|
2227
|
+
click.echo(f"Splitting {source.name} by namespace...")
|
|
2228
|
+
|
|
2229
|
+
result = split_by_namespace(source, output_dir, dry_run=dry_run)
|
|
2230
|
+
|
|
2231
|
+
if not result.success:
|
|
2232
|
+
click.secho(f"✗ Split failed: {result.error}", fg="red", err=True)
|
|
2233
|
+
raise SystemExit(2)
|
|
2234
|
+
|
|
2235
|
+
_display_split_result(result, output_dir, dry_run, not no_colour)
|
|
2236
|
+
raise SystemExit(0 if not result.unmatched_entities else 1)
|
|
2237
|
+
|
|
2238
|
+
# Build configuration from file or CLI
|
|
2239
|
+
if config_file:
|
|
2240
|
+
try:
|
|
2241
|
+
config = SplitConfig.from_yaml(config_file)
|
|
2242
|
+
# Override source and output_dir if provided
|
|
2243
|
+
config.source = source
|
|
2244
|
+
config.output_dir = output_dir
|
|
2245
|
+
config.dry_run = dry_run
|
|
2246
|
+
config.generate_manifest = not no_manifest
|
|
2247
|
+
click.echo(f"Using config: {config_file}")
|
|
2248
|
+
except (FileNotFoundError, ValueError) as e:
|
|
2249
|
+
click.secho(f"Error loading config: {e}", fg="red", err=True)
|
|
2250
|
+
raise click.Abort()
|
|
2251
|
+
else:
|
|
2252
|
+
# Need either --by-namespace or --config
|
|
2253
|
+
if not by_namespace:
|
|
2254
|
+
click.secho(
|
|
2255
|
+
"Error: Specify either --by-namespace or --config.",
|
|
2256
|
+
fg="red",
|
|
2257
|
+
err=True,
|
|
2258
|
+
)
|
|
2259
|
+
click.echo("Use --by-namespace for auto-detection or -c for a config file.")
|
|
2260
|
+
click.echo("Run 'rdf-construct split --init' to generate a config template.")
|
|
2261
|
+
raise click.Abort()
|
|
2262
|
+
|
|
2263
|
+
# Build minimal config
|
|
2264
|
+
config = SplitConfig(
|
|
2265
|
+
source=source,
|
|
2266
|
+
output_dir=output_dir,
|
|
2267
|
+
modules=[],
|
|
2268
|
+
unmatched=UnmatchedStrategy(
|
|
2269
|
+
strategy=unmatched,
|
|
2270
|
+
common_module=common_name,
|
|
2271
|
+
common_output=f"{common_name}.ttl",
|
|
2272
|
+
),
|
|
2273
|
+
generate_manifest=not no_manifest,
|
|
2274
|
+
dry_run=dry_run,
|
|
2275
|
+
)
|
|
2276
|
+
|
|
2277
|
+
# Add data migration config if specified
|
|
2278
|
+
if migrate_data:
|
|
2279
|
+
config.split_data = SplitDataConfig(
|
|
2280
|
+
sources=list(migrate_data),
|
|
2281
|
+
output_dir=data_output if data_output else output_dir,
|
|
2282
|
+
prefix="data_",
|
|
2283
|
+
)
|
|
2284
|
+
|
|
2285
|
+
# Override unmatched strategy if specified on CLI
|
|
2286
|
+
if unmatched:
|
|
2287
|
+
config.unmatched = UnmatchedStrategy(
|
|
2288
|
+
strategy=unmatched,
|
|
2289
|
+
common_module=common_name,
|
|
2290
|
+
common_output=f"{common_name}.ttl",
|
|
2291
|
+
)
|
|
2292
|
+
|
|
2293
|
+
# Execute split
|
|
2294
|
+
click.echo(f"Splitting {source.name}...")
|
|
2295
|
+
|
|
2296
|
+
splitter = OntologySplitter(config)
|
|
2297
|
+
result = splitter.split()
|
|
2298
|
+
|
|
2299
|
+
if not result.success:
|
|
2300
|
+
click.secho(f"✗ Split failed: {result.error}", fg="red", err=True)
|
|
2301
|
+
raise SystemExit(2)
|
|
2302
|
+
|
|
2303
|
+
# Write output (unless dry run)
|
|
2304
|
+
if not dry_run:
|
|
2305
|
+
splitter.write_modules(result)
|
|
2306
|
+
if config.generate_manifest:
|
|
2307
|
+
splitter.write_manifest(result)
|
|
2308
|
+
|
|
2309
|
+
_display_split_result(result, output_dir, dry_run, not no_colour)
|
|
2310
|
+
|
|
2311
|
+
# Exit code based on unmatched entities
|
|
2312
|
+
if result.unmatched_entities and config.unmatched.strategy == "common":
|
|
2313
|
+
click.echo()
|
|
2314
|
+
click.secho(
|
|
2315
|
+
f"⚠ {len(result.unmatched_entities)} unmatched entities placed in "
|
|
2316
|
+
f"{config.unmatched.common_module} module",
|
|
2317
|
+
fg="yellow",
|
|
2318
|
+
)
|
|
2319
|
+
raise SystemExit(1)
|
|
2320
|
+
else:
|
|
2321
|
+
raise SystemExit(0)
|
|
2322
|
+
|
|
2323
|
+
|
|
2324
|
+
def _display_split_result(
|
|
2325
|
+
result: "SplitResult",
|
|
2326
|
+
output_dir: Path,
|
|
2327
|
+
dry_run: bool,
|
|
2328
|
+
use_colour: bool,
|
|
2329
|
+
) -> None:
|
|
2330
|
+
"""Display split results to console.
|
|
2331
|
+
|
|
2332
|
+
Args:
|
|
2333
|
+
result: SplitResult from split operation.
|
|
2334
|
+
output_dir: Output directory.
|
|
2335
|
+
dry_run: Whether this was a dry run.
|
|
2336
|
+
use_colour: Whether to use coloured output.
|
|
2337
|
+
"""
|
|
2338
|
+
# Header
|
|
2339
|
+
if dry_run:
|
|
2340
|
+
click.echo("\n[DRY RUN] Would create:")
|
|
2341
|
+
else:
|
|
2342
|
+
click.echo("\nSplit complete:")
|
|
2343
|
+
|
|
2344
|
+
# Module summary
|
|
2345
|
+
click.echo(f"\n Modules: {result.total_modules}")
|
|
2346
|
+
click.echo(f" Total triples: {result.total_triples}")
|
|
2347
|
+
|
|
2348
|
+
# Module details
|
|
2349
|
+
if result.module_stats:
|
|
2350
|
+
click.echo("\n Module breakdown:")
|
|
2351
|
+
for stats in result.module_stats:
|
|
2352
|
+
deps_str = ""
|
|
2353
|
+
if stats.dependencies:
|
|
2354
|
+
deps_str = f" (deps: {', '.join(stats.dependencies)})"
|
|
2355
|
+
click.echo(
|
|
2356
|
+
f" {stats.file}: {stats.classes} classes, "
|
|
2357
|
+
f"{stats.properties} properties, {stats.triples} triples{deps_str}"
|
|
2358
|
+
)
|
|
2359
|
+
|
|
2360
|
+
# Unmatched entities
|
|
2361
|
+
if result.unmatched_entities:
|
|
2362
|
+
click.echo(f"\n Unmatched entities: {len(result.unmatched_entities)}")
|
|
2363
|
+
# Show first few
|
|
2364
|
+
sample = list(result.unmatched_entities)[:5]
|
|
2365
|
+
for uri in sample:
|
|
2366
|
+
click.echo(f" - {uri}")
|
|
2367
|
+
if len(result.unmatched_entities) > 5:
|
|
2368
|
+
click.echo(f" ... and {len(result.unmatched_entities) - 5} more")
|
|
2369
|
+
|
|
2370
|
+
# Output location
|
|
2371
|
+
if not dry_run:
|
|
2372
|
+
click.echo()
|
|
2373
|
+
if use_colour:
|
|
2374
|
+
click.secho(f"✓ Wrote modules to {output_dir}/", fg="green")
|
|
2375
|
+
else:
|
|
2376
|
+
click.echo(f"✓ Wrote modules to {output_dir}/")
|
|
2377
|
+
|
|
2378
|
+
|
|
2379
|
+
# Refactor command group
|
|
2380
|
+
@cli.group()
|
|
2381
|
+
def refactor():
|
|
2382
|
+
"""Refactor ontologies: rename URIs and deprecate entities.
|
|
2383
|
+
|
|
2384
|
+
\b
|
|
2385
|
+
Subcommands:
|
|
2386
|
+
rename Rename URIs (single entity or bulk namespace)
|
|
2387
|
+
deprecate Mark entities as deprecated
|
|
2388
|
+
|
|
2389
|
+
\b
|
|
2390
|
+
Examples:
|
|
2391
|
+
# Fix a typo
|
|
2392
|
+
rdf-construct refactor rename ont.ttl --from ex:Buiding --to ex:Building -o fixed.ttl
|
|
2393
|
+
|
|
2394
|
+
# Bulk namespace change
|
|
2395
|
+
rdf-construct refactor rename ont.ttl \\
|
|
2396
|
+
--from-namespace http://old/ --to-namespace http://new/ -o migrated.ttl
|
|
2397
|
+
|
|
2398
|
+
# Deprecate entity with replacement
|
|
2399
|
+
rdf-construct refactor deprecate ont.ttl \\
|
|
2400
|
+
--entity ex:OldClass --replaced-by ex:NewClass \\
|
|
2401
|
+
--message "Use NewClass instead." -o updated.ttl
|
|
2402
|
+
"""
|
|
2403
|
+
pass
|
|
2404
|
+
|
|
2405
|
+
|
|
2406
|
+
@refactor.command("rename")
|
|
2407
|
+
@click.argument("sources", nargs=-1, type=click.Path(exists=True, path_type=Path))
|
|
2408
|
+
@click.option(
|
|
2409
|
+
"-o", "--output",
|
|
2410
|
+
type=click.Path(path_type=Path),
|
|
2411
|
+
help="Output file (for single source) or directory (for multiple sources).",
|
|
2412
|
+
)
|
|
2413
|
+
@click.option(
|
|
2414
|
+
"--from", "from_uri",
|
|
2415
|
+
help="Single URI to rename (use with --to).",
|
|
2416
|
+
)
|
|
2417
|
+
@click.option(
|
|
2418
|
+
"--to", "to_uri",
|
|
2419
|
+
help="New URI for single rename (use with --from).",
|
|
2420
|
+
)
|
|
2421
|
+
@click.option(
|
|
2422
|
+
"--from-namespace",
|
|
2423
|
+
help="Old namespace prefix for bulk rename.",
|
|
2424
|
+
)
|
|
2425
|
+
@click.option(
|
|
2426
|
+
"--to-namespace",
|
|
2427
|
+
help="New namespace prefix for bulk rename.",
|
|
2428
|
+
)
|
|
2429
|
+
@click.option(
|
|
2430
|
+
"-c", "--config",
|
|
2431
|
+
"config_file",
|
|
2432
|
+
type=click.Path(exists=True, path_type=Path),
|
|
2433
|
+
help="YAML configuration file with rename mappings.",
|
|
2434
|
+
)
|
|
2435
|
+
@click.option(
|
|
2436
|
+
"--migrate-data",
|
|
2437
|
+
multiple=True,
|
|
2438
|
+
type=click.Path(exists=True, path_type=Path),
|
|
2439
|
+
help="Data files to migrate (can be repeated).",
|
|
2440
|
+
)
|
|
2441
|
+
@click.option(
|
|
2442
|
+
"--data-output",
|
|
2443
|
+
type=click.Path(path_type=Path),
|
|
2444
|
+
help="Output path for migrated data.",
|
|
2445
|
+
)
|
|
2446
|
+
@click.option(
|
|
2447
|
+
"--dry-run",
|
|
2448
|
+
is_flag=True,
|
|
2449
|
+
help="Preview changes without writing files.",
|
|
2450
|
+
)
|
|
2451
|
+
@click.option(
|
|
2452
|
+
"--no-colour", "--no-color",
|
|
2453
|
+
is_flag=True,
|
|
2454
|
+
help="Disable coloured output.",
|
|
2455
|
+
)
|
|
2456
|
+
@click.option(
|
|
2457
|
+
"--init",
|
|
2458
|
+
"init_config",
|
|
2459
|
+
is_flag=True,
|
|
2460
|
+
help="Generate a template rename configuration file.",
|
|
2461
|
+
)
|
|
2462
|
+
def refactor_rename(
|
|
2463
|
+
sources: tuple[Path, ...],
|
|
2464
|
+
output: Path | None,
|
|
2465
|
+
from_uri: str | None,
|
|
2466
|
+
to_uri: str | None,
|
|
2467
|
+
from_namespace: str | None,
|
|
2468
|
+
to_namespace: str | None,
|
|
2469
|
+
config_file: Path | None,
|
|
2470
|
+
migrate_data: tuple[Path, ...],
|
|
2471
|
+
data_output: Path | None,
|
|
2472
|
+
dry_run: bool,
|
|
2473
|
+
no_colour: bool,
|
|
2474
|
+
init_config: bool,
|
|
2475
|
+
):
|
|
2476
|
+
"""Rename URIs in ontology files.
|
|
2477
|
+
|
|
2478
|
+
Supports single entity renames (fixing typos) and bulk namespace changes
|
|
2479
|
+
(project migrations). The renamer updates subject, predicate, and object
|
|
2480
|
+
positions but intentionally leaves literal values unchanged.
|
|
2481
|
+
|
|
2482
|
+
\b
|
|
2483
|
+
SOURCES: One or more RDF files to process (.ttl, .rdf, .owl)
|
|
2484
|
+
|
|
2485
|
+
\b
|
|
2486
|
+
Exit codes:
|
|
2487
|
+
0 - Success
|
|
2488
|
+
1 - Success with warnings (some URIs not found)
|
|
2489
|
+
2 - Error (file not found, parse error, etc.)
|
|
2490
|
+
|
|
2491
|
+
\b
|
|
2492
|
+
Examples:
|
|
2493
|
+
# Fix a single typo
|
|
2494
|
+
rdf-construct refactor rename ontology.ttl \\
|
|
2495
|
+
--from "http://example.org/ont#Buiding" \\
|
|
2496
|
+
--to "http://example.org/ont#Building" \\
|
|
2497
|
+
-o fixed.ttl
|
|
2498
|
+
|
|
2499
|
+
# Bulk namespace change
|
|
2500
|
+
rdf-construct refactor rename ontology.ttl \\
|
|
2501
|
+
--from-namespace "http://old.example.org/" \\
|
|
2502
|
+
--to-namespace "http://new.example.org/" \\
|
|
2503
|
+
-o migrated.ttl
|
|
2504
|
+
|
|
2505
|
+
# With data migration
|
|
2506
|
+
rdf-construct refactor rename ontology.ttl \\
|
|
2507
|
+
--from "ex:OldClass" --to "ex:NewClass" \\
|
|
2508
|
+
--migrate-data instances.ttl \\
|
|
2509
|
+
--data-output updated-instances.ttl
|
|
2510
|
+
|
|
2511
|
+
# From configuration file
|
|
2512
|
+
rdf-construct refactor rename --config renames.yml
|
|
2513
|
+
|
|
2514
|
+
# Preview changes (dry run)
|
|
2515
|
+
rdf-construct refactor rename ontology.ttl \\
|
|
2516
|
+
--from "ex:Old" --to "ex:New" --dry-run
|
|
2517
|
+
|
|
2518
|
+
# Process multiple files
|
|
2519
|
+
rdf-construct refactor rename modules/*.ttl \\
|
|
2520
|
+
--from-namespace "http://old/" --to-namespace "http://new/" \\
|
|
2521
|
+
-o migrated/
|
|
2522
|
+
|
|
2523
|
+
# Generate template config
|
|
2524
|
+
rdf-construct refactor rename --init
|
|
2525
|
+
"""
|
|
2526
|
+
# Handle --init flag
|
|
2527
|
+
if init_config:
|
|
2528
|
+
config_path = Path("refactor_rename.yml")
|
|
2529
|
+
if config_path.exists():
|
|
2530
|
+
click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
|
|
2531
|
+
raise click.Abort()
|
|
2532
|
+
|
|
2533
|
+
config_content = create_default_rename_config()
|
|
2534
|
+
config_path.write_text(config_content)
|
|
2535
|
+
click.secho(f"Created {config_path}", fg="green")
|
|
2536
|
+
click.echo("Edit this file to configure your renames, then run:")
|
|
2537
|
+
click.echo(f" rdf-construct refactor rename --config {config_path}")
|
|
2538
|
+
return
|
|
2539
|
+
|
|
2540
|
+
# Validate input options
|
|
2541
|
+
if not sources and not config_file:
|
|
2542
|
+
click.secho("Error: No source files specified.", fg="red", err=True)
|
|
2543
|
+
click.echo("Provide source files or use --config with a configuration file.", err=True)
|
|
2544
|
+
raise click.Abort()
|
|
2545
|
+
|
|
2546
|
+
# Validate rename options
|
|
2547
|
+
if from_uri and not to_uri:
|
|
2548
|
+
click.secho("Error: --from requires --to", fg="red", err=True)
|
|
2549
|
+
raise click.Abort()
|
|
2550
|
+
if to_uri and not from_uri:
|
|
2551
|
+
click.secho("Error: --to requires --from", fg="red", err=True)
|
|
2552
|
+
raise click.Abort()
|
|
2553
|
+
if from_namespace and not to_namespace:
|
|
2554
|
+
click.secho("Error: --from-namespace requires --to-namespace", fg="red", err=True)
|
|
2555
|
+
raise click.Abort()
|
|
2556
|
+
if to_namespace and not from_namespace:
|
|
2557
|
+
click.secho("Error: --to-namespace requires --from-namespace", fg="red", err=True)
|
|
2558
|
+
raise click.Abort()
|
|
2559
|
+
|
|
2560
|
+
# Build configuration
|
|
2561
|
+
if config_file:
|
|
2562
|
+
try:
|
|
2563
|
+
config = load_refactor_config(config_file)
|
|
2564
|
+
click.echo(f"Using config: {config_file}")
|
|
2565
|
+
|
|
2566
|
+
# Override output if provided on CLI
|
|
2567
|
+
if output:
|
|
2568
|
+
if len(sources) > 1 or (config.source_files and len(config.source_files) > 1):
|
|
2569
|
+
config.output_dir = output
|
|
2570
|
+
else:
|
|
2571
|
+
config.output = output
|
|
2572
|
+
|
|
2573
|
+
# Override sources if provided on CLI
|
|
2574
|
+
if sources:
|
|
2575
|
+
config.source_files = list(sources)
|
|
2576
|
+
except (FileNotFoundError, ValueError) as e:
|
|
2577
|
+
click.secho(f"Error loading config: {e}", fg="red", err=True)
|
|
2578
|
+
raise click.Abort()
|
|
2579
|
+
else:
|
|
2580
|
+
# Build config from CLI arguments
|
|
2581
|
+
rename_config = RenameConfig()
|
|
2582
|
+
|
|
2583
|
+
if from_namespace and to_namespace:
|
|
2584
|
+
rename_config.namespaces[from_namespace] = to_namespace
|
|
2585
|
+
|
|
2586
|
+
if from_uri and to_uri:
|
|
2587
|
+
# Expand CURIEs if needed
|
|
2588
|
+
rename_config.entities[from_uri] = to_uri
|
|
2589
|
+
|
|
2590
|
+
config = RefactorConfig(
|
|
2591
|
+
rename=rename_config,
|
|
2592
|
+
source_files=list(sources),
|
|
2593
|
+
output=output if len(sources) == 1 else None,
|
|
2594
|
+
output_dir=output if len(sources) > 1 else None,
|
|
2595
|
+
dry_run=dry_run,
|
|
2596
|
+
)
|
|
2597
|
+
|
|
2598
|
+
# Validate we have something to rename
|
|
2599
|
+
if config.rename is None or (not config.rename.namespaces and not config.rename.entities):
|
|
2600
|
+
click.secho(
|
|
2601
|
+
"Error: No renames specified. Use --from/--to, --from-namespace/--to-namespace, "
|
|
2602
|
+
"or provide a config file.",
|
|
2603
|
+
fg="red",
|
|
2604
|
+
err=True,
|
|
2605
|
+
)
|
|
2606
|
+
raise click.Abort()
|
|
2607
|
+
|
|
2608
|
+
# Execute rename
|
|
2609
|
+
formatter = RefactorTextFormatter(use_colour=not no_colour)
|
|
2610
|
+
renamer = OntologyRenamer()
|
|
2611
|
+
|
|
2612
|
+
for source_path in config.source_files:
|
|
2613
|
+
click.echo(f"\nProcessing: {source_path}")
|
|
2614
|
+
|
|
2615
|
+
# Load source graph
|
|
2616
|
+
graph = Graph()
|
|
2617
|
+
try:
|
|
2618
|
+
graph.parse(source_path.as_posix())
|
|
2619
|
+
except Exception as e:
|
|
2620
|
+
click.secho(f"✗ Failed to parse: {e}", fg="red", err=True)
|
|
2621
|
+
raise SystemExit(2)
|
|
2622
|
+
|
|
2623
|
+
# Build mappings for preview
|
|
2624
|
+
mappings = config.rename.build_mappings(graph)
|
|
2625
|
+
|
|
2626
|
+
if dry_run:
|
|
2627
|
+
# Show preview
|
|
2628
|
+
click.echo()
|
|
2629
|
+
click.echo(
|
|
2630
|
+
formatter.format_rename_preview(
|
|
2631
|
+
mappings=mappings,
|
|
2632
|
+
source_file=source_path.name,
|
|
2633
|
+
source_triples=len(graph),
|
|
2634
|
+
)
|
|
2635
|
+
)
|
|
2636
|
+
else:
|
|
2637
|
+
# Perform rename
|
|
2638
|
+
result = renamer.rename(graph, config.rename)
|
|
2639
|
+
|
|
2640
|
+
if not result.success:
|
|
2641
|
+
click.secho(f"✗ Rename failed: {result.error}", fg="red", err=True)
|
|
2642
|
+
raise SystemExit(2)
|
|
2643
|
+
|
|
2644
|
+
# Show result
|
|
2645
|
+
click.echo(formatter.format_rename_result(result))
|
|
2646
|
+
|
|
2647
|
+
# Write output
|
|
2648
|
+
if result.renamed_graph:
|
|
2649
|
+
out_path = config.output or (config.output_dir / source_path.name if config.output_dir else None)
|
|
2650
|
+
if out_path:
|
|
2651
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2652
|
+
result.renamed_graph.serialize(destination=out_path.as_posix(), format="turtle")
|
|
2653
|
+
click.secho(f"✓ Wrote {out_path}", fg="green")
|
|
2654
|
+
|
|
2655
|
+
# Handle data migration
|
|
2656
|
+
if migrate_data and not dry_run:
|
|
2657
|
+
click.echo("\nMigrating data...")
|
|
2658
|
+
|
|
2659
|
+
# Build URI map from rename config
|
|
2660
|
+
combined_graph = Graph()
|
|
2661
|
+
for source_path in config.source_files:
|
|
2662
|
+
combined_graph.parse(source_path.as_posix())
|
|
2663
|
+
|
|
2664
|
+
uri_map = {}
|
|
2665
|
+
for mapping in config.rename.build_mappings(combined_graph):
|
|
2666
|
+
uri_map[mapping.from_uri] = mapping.to_uri
|
|
2667
|
+
|
|
2668
|
+
if uri_map:
|
|
2669
|
+
migrator = DataMigrator()
|
|
2670
|
+
for data_path in migrate_data:
|
|
2671
|
+
data_graph = Graph()
|
|
2672
|
+
try:
|
|
2673
|
+
data_graph.parse(data_path.as_posix())
|
|
2674
|
+
except Exception as e:
|
|
2675
|
+
click.secho(f"✗ Failed to parse data file {data_path}: {e}", fg="red", err=True)
|
|
2676
|
+
continue
|
|
2677
|
+
|
|
2678
|
+
migration_result = migrator.migrate(data_graph, uri_map=uri_map)
|
|
2679
|
+
|
|
2680
|
+
if migration_result.success and migration_result.migrated_graph:
|
|
2681
|
+
# Determine output path
|
|
2682
|
+
if data_output and len(migrate_data) == 1:
|
|
2683
|
+
out_path = data_output
|
|
2684
|
+
elif data_output:
|
|
2685
|
+
out_path = data_output / data_path.name
|
|
2686
|
+
else:
|
|
2687
|
+
out_path = data_path.parent / f"migrated_{data_path.name}"
|
|
2688
|
+
|
|
2689
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2690
|
+
migration_result.migrated_graph.serialize(
|
|
2691
|
+
destination=out_path.as_posix(), format="turtle"
|
|
2692
|
+
)
|
|
2693
|
+
click.echo(f" Migrated {data_path.name}: {migration_result.stats.total_changes} changes")
|
|
2694
|
+
click.secho(f" ✓ Wrote {out_path}", fg="green")
|
|
2695
|
+
|
|
2696
|
+
raise SystemExit(0)
|
|
2697
|
+
|
|
2698
|
+
|
|
2699
|
+
@refactor.command("deprecate")
|
|
2700
|
+
@click.argument("sources", nargs=-1, type=click.Path(exists=True, path_type=Path))
|
|
2701
|
+
@click.option(
|
|
2702
|
+
"-o", "--output",
|
|
2703
|
+
type=click.Path(path_type=Path),
|
|
2704
|
+
help="Output file.",
|
|
2705
|
+
)
|
|
2706
|
+
@click.option(
|
|
2707
|
+
"--entity",
|
|
2708
|
+
help="URI of entity to deprecate.",
|
|
2709
|
+
)
|
|
2710
|
+
@click.option(
|
|
2711
|
+
"--replaced-by",
|
|
2712
|
+
help="URI of replacement entity (adds dcterms:isReplacedBy).",
|
|
2713
|
+
)
|
|
2714
|
+
@click.option(
|
|
2715
|
+
"--message", "-m",
|
|
2716
|
+
help="Deprecation message (added to rdfs:comment).",
|
|
2717
|
+
)
|
|
2718
|
+
@click.option(
|
|
2719
|
+
"--version",
|
|
2720
|
+
help="Version when deprecated (included in message).",
|
|
2721
|
+
)
|
|
2722
|
+
@click.option(
|
|
2723
|
+
"-c", "--config",
|
|
2724
|
+
"config_file",
|
|
2725
|
+
type=click.Path(exists=True, path_type=Path),
|
|
2726
|
+
help="YAML configuration file with deprecation specs.",
|
|
2727
|
+
)
|
|
2728
|
+
@click.option(
|
|
2729
|
+
"--dry-run",
|
|
2730
|
+
is_flag=True,
|
|
2731
|
+
help="Preview changes without writing files.",
|
|
2732
|
+
)
|
|
2733
|
+
@click.option(
|
|
2734
|
+
"--no-colour", "--no-color",
|
|
2735
|
+
is_flag=True,
|
|
2736
|
+
help="Disable coloured output.",
|
|
2737
|
+
)
|
|
2738
|
+
@click.option(
|
|
2739
|
+
"--init",
|
|
2740
|
+
"init_config",
|
|
2741
|
+
is_flag=True,
|
|
2742
|
+
help="Generate a template deprecation configuration file.",
|
|
2743
|
+
)
|
|
2744
|
+
def refactor_deprecate(
|
|
2745
|
+
sources: tuple[Path, ...],
|
|
2746
|
+
output: Path | None,
|
|
2747
|
+
entity: str | None,
|
|
2748
|
+
replaced_by: str | None,
|
|
2749
|
+
message: str | None,
|
|
2750
|
+
version: str | None,
|
|
2751
|
+
config_file: Path | None,
|
|
2752
|
+
dry_run: bool,
|
|
2753
|
+
no_colour: bool,
|
|
2754
|
+
init_config: bool,
|
|
2755
|
+
):
|
|
2756
|
+
"""Mark ontology entities as deprecated.
|
|
2757
|
+
|
|
2758
|
+
Adds standard deprecation annotations:
|
|
2759
|
+
- owl:deprecated true
|
|
2760
|
+
- dcterms:isReplacedBy (if replacement specified)
|
|
2761
|
+
- rdfs:comment with "DEPRECATED: ..." message
|
|
2762
|
+
|
|
2763
|
+
Deprecation marks entities but does NOT rename or migrate references.
|
|
2764
|
+
Use 'refactor rename' to actually migrate references after deprecation.
|
|
2765
|
+
|
|
2766
|
+
\b
|
|
2767
|
+
SOURCES: One or more RDF files to process (.ttl, .rdf, .owl)
|
|
2768
|
+
|
|
2769
|
+
\b
|
|
2770
|
+
Exit codes:
|
|
2771
|
+
0 - Success
|
|
2772
|
+
1 - Success with warnings (some entities not found)
|
|
2773
|
+
2 - Error (file not found, parse error, etc.)
|
|
2774
|
+
|
|
2775
|
+
\b
|
|
2776
|
+
Examples:
|
|
2777
|
+
# Deprecate with replacement
|
|
2778
|
+
rdf-construct refactor deprecate ontology.ttl \\
|
|
2779
|
+
--entity "http://example.org/ont#LegacyTerm" \\
|
|
2780
|
+
--replaced-by "http://example.org/ont#NewTerm" \\
|
|
2781
|
+
--message "Use NewTerm instead. Will be removed in v3.0." \\
|
|
2782
|
+
-o updated.ttl
|
|
2783
|
+
|
|
2784
|
+
# Deprecate without replacement
|
|
2785
|
+
rdf-construct refactor deprecate ontology.ttl \\
|
|
2786
|
+
--entity "ex:ObsoleteThing" \\
|
|
2787
|
+
--message "No longer needed. Will be removed in v3.0." \\
|
|
2788
|
+
-o updated.ttl
|
|
2789
|
+
|
|
2790
|
+
# Bulk deprecation from config
|
|
2791
|
+
rdf-construct refactor deprecate ontology.ttl \\
|
|
2792
|
+
-c deprecations.yml \\
|
|
2793
|
+
-o updated.ttl
|
|
2794
|
+
|
|
2795
|
+
# Preview changes (dry run)
|
|
2796
|
+
rdf-construct refactor deprecate ontology.ttl \\
|
|
2797
|
+
--entity "ex:Legacy" --replaced-by "ex:Modern" --dry-run
|
|
2798
|
+
|
|
2799
|
+
# Generate template config
|
|
2800
|
+
rdf-construct refactor deprecate --init
|
|
2801
|
+
"""
|
|
2802
|
+
# Handle --init flag
|
|
2803
|
+
if init_config:
|
|
2804
|
+
config_path = Path("refactor_deprecate.yml")
|
|
2805
|
+
if config_path.exists():
|
|
2806
|
+
click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
|
|
2807
|
+
raise click.Abort()
|
|
2808
|
+
|
|
2809
|
+
config_content = create_default_deprecation_config()
|
|
2810
|
+
config_path.write_text(config_content)
|
|
2811
|
+
click.secho(f"Created {config_path}", fg="green")
|
|
2812
|
+
click.echo("Edit this file to configure your deprecations, then run:")
|
|
2813
|
+
click.echo(f" rdf-construct refactor deprecate --config {config_path}")
|
|
2814
|
+
return
|
|
2815
|
+
|
|
2816
|
+
# Validate input options
|
|
2817
|
+
if not sources and not config_file:
|
|
2818
|
+
click.secho("Error: No source files specified.", fg="red", err=True)
|
|
2819
|
+
click.echo("Provide source files or use --config with a configuration file.", err=True)
|
|
2820
|
+
raise click.Abort()
|
|
2821
|
+
|
|
2822
|
+
# Build configuration
|
|
2823
|
+
if config_file:
|
|
2824
|
+
try:
|
|
2825
|
+
config = load_refactor_config(config_file)
|
|
2826
|
+
click.echo(f"Using config: {config_file}")
|
|
2827
|
+
|
|
2828
|
+
# Override output if provided on CLI
|
|
2829
|
+
if output:
|
|
2830
|
+
config.output = output
|
|
2831
|
+
|
|
2832
|
+
# Override sources if provided on CLI
|
|
2833
|
+
if sources:
|
|
2834
|
+
config.source_files = list(sources)
|
|
2835
|
+
except (FileNotFoundError, ValueError) as e:
|
|
2836
|
+
click.secho(f"Error loading config: {e}", fg="red", err=True)
|
|
2837
|
+
raise click.Abort()
|
|
2838
|
+
else:
|
|
2839
|
+
# Build config from CLI arguments
|
|
2840
|
+
if not entity:
|
|
2841
|
+
click.secho(
|
|
2842
|
+
"Error: --entity is required when not using a config file.",
|
|
2843
|
+
fg="red",
|
|
2844
|
+
err=True,
|
|
2845
|
+
)
|
|
2846
|
+
raise click.Abort()
|
|
2847
|
+
|
|
2848
|
+
spec = DeprecationSpec(
|
|
2849
|
+
entity=entity,
|
|
2850
|
+
replaced_by=replaced_by,
|
|
2851
|
+
message=message,
|
|
2852
|
+
version=version,
|
|
2853
|
+
)
|
|
2854
|
+
|
|
2855
|
+
config = RefactorConfig(
|
|
2856
|
+
deprecations=[spec],
|
|
2857
|
+
source_files=list(sources),
|
|
2858
|
+
output=output,
|
|
2859
|
+
dry_run=dry_run,
|
|
2860
|
+
)
|
|
2861
|
+
|
|
2862
|
+
# Validate we have something to deprecate
|
|
2863
|
+
if not config.deprecations:
|
|
2864
|
+
click.secho(
|
|
2865
|
+
"Error: No deprecations specified. Use --entity or provide a config file.",
|
|
2866
|
+
fg="red",
|
|
2867
|
+
err=True,
|
|
2868
|
+
)
|
|
2869
|
+
raise click.Abort()
|
|
2870
|
+
|
|
2871
|
+
# Execute deprecation
|
|
2872
|
+
formatter = RefactorTextFormatter(use_colour=not no_colour)
|
|
2873
|
+
deprecator = OntologyDeprecator()
|
|
2874
|
+
|
|
2875
|
+
for source_path in config.source_files:
|
|
2876
|
+
click.echo(f"\nProcessing: {source_path}")
|
|
2877
|
+
|
|
2878
|
+
# Load source graph
|
|
2879
|
+
graph = Graph()
|
|
2880
|
+
try:
|
|
2881
|
+
graph.parse(source_path.as_posix())
|
|
2882
|
+
except Exception as e:
|
|
2883
|
+
click.secho(f"✗ Failed to parse: {e}", fg="red", err=True)
|
|
2884
|
+
raise SystemExit(2)
|
|
2885
|
+
|
|
2886
|
+
if dry_run:
|
|
2887
|
+
# Perform dry run to get entity info
|
|
2888
|
+
temp_graph = Graph()
|
|
2889
|
+
for triple in graph:
|
|
2890
|
+
temp_graph.add(triple)
|
|
2891
|
+
|
|
2892
|
+
result = deprecator.deprecate_bulk(temp_graph, config.deprecations)
|
|
2893
|
+
|
|
2894
|
+
# Show preview
|
|
2895
|
+
click.echo()
|
|
2896
|
+
click.echo(
|
|
2897
|
+
formatter.format_deprecation_preview(
|
|
2898
|
+
specs=config.deprecations,
|
|
2899
|
+
entity_info=result.entity_info,
|
|
2900
|
+
source_file=source_path.name,
|
|
2901
|
+
source_triples=len(graph),
|
|
2902
|
+
)
|
|
2903
|
+
)
|
|
2904
|
+
else:
|
|
2905
|
+
# Perform deprecation
|
|
2906
|
+
result = deprecator.deprecate_bulk(graph, config.deprecations)
|
|
2907
|
+
|
|
2908
|
+
if not result.success:
|
|
2909
|
+
click.secho(f"✗ Deprecation failed: {result.error}", fg="red", err=True)
|
|
2910
|
+
raise SystemExit(2)
|
|
2911
|
+
|
|
2912
|
+
# Show result
|
|
2913
|
+
click.echo(formatter.format_deprecation_result(result))
|
|
2914
|
+
|
|
2915
|
+
# Write output
|
|
2916
|
+
if result.deprecated_graph:
|
|
2917
|
+
out_path = config.output or source_path.with_stem(f"{source_path.stem}_deprecated")
|
|
2918
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2919
|
+
result.deprecated_graph.serialize(destination=out_path.as_posix(), format="turtle")
|
|
2920
|
+
click.secho(f"✓ Wrote {out_path}", fg="green")
|
|
2921
|
+
|
|
2922
|
+
# Warn about entities not found
|
|
2923
|
+
if result.stats.entities_not_found > 0:
|
|
2924
|
+
click.secho(
|
|
2925
|
+
f"\n⚠ {result.stats.entities_not_found} entity/entities not found in graph",
|
|
2926
|
+
fg="yellow",
|
|
2927
|
+
)
|
|
2928
|
+
raise SystemExit(1)
|
|
2929
|
+
|
|
2930
|
+
raise SystemExit(0)
|
|
2931
|
+
|
|
2932
|
+
|
|
2933
|
+
@cli.group()
|
|
2934
|
+
def localise():
|
|
2935
|
+
"""Multi-language translation management.
|
|
2936
|
+
|
|
2937
|
+
Extract translatable strings, merge translations, and track coverage.
|
|
2938
|
+
|
|
2939
|
+
\b
|
|
2940
|
+
Commands:
|
|
2941
|
+
extract Extract strings for translation
|
|
2942
|
+
merge Merge translations back into ontology
|
|
2943
|
+
report Generate translation coverage report
|
|
2944
|
+
init Create empty translation file for new language
|
|
2945
|
+
|
|
2946
|
+
\b
|
|
2947
|
+
Examples:
|
|
2948
|
+
# Extract strings for German translation
|
|
2949
|
+
rdf-construct localise extract ontology.ttl --language de -o translations/de.yml
|
|
2950
|
+
|
|
2951
|
+
# Merge completed translations
|
|
2952
|
+
rdf-construct localise merge ontology.ttl translations/de.yml -o localised.ttl
|
|
2953
|
+
|
|
2954
|
+
# Check translation coverage
|
|
2955
|
+
rdf-construct localise report ontology.ttl --languages en,de,fr
|
|
2956
|
+
"""
|
|
2957
|
+
pass
|
|
2958
|
+
|
|
2959
|
+
|
|
2960
|
+
@localise.command("extract")
|
|
2961
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
2962
|
+
@click.option(
|
|
2963
|
+
"--language",
|
|
2964
|
+
"-l",
|
|
2965
|
+
"target_language",
|
|
2966
|
+
required=True,
|
|
2967
|
+
help="Target language code (e.g., de, fr, es)",
|
|
2968
|
+
)
|
|
2969
|
+
@click.option(
|
|
2970
|
+
"--output",
|
|
2971
|
+
"-o",
|
|
2972
|
+
type=click.Path(path_type=Path),
|
|
2973
|
+
help="Output YAML file (default: {language}.yml)",
|
|
2974
|
+
)
|
|
2975
|
+
@click.option(
|
|
2976
|
+
"--source-language",
|
|
2977
|
+
default="en",
|
|
2978
|
+
help="Source language code (default: en)",
|
|
2979
|
+
)
|
|
2980
|
+
@click.option(
|
|
2981
|
+
"--properties",
|
|
2982
|
+
"-p",
|
|
2983
|
+
help="Comma-separated properties to extract (e.g., rdfs:label,rdfs:comment)",
|
|
2984
|
+
)
|
|
2985
|
+
@click.option(
|
|
2986
|
+
"--include-deprecated",
|
|
2987
|
+
is_flag=True,
|
|
2988
|
+
help="Include deprecated entities",
|
|
2989
|
+
)
|
|
2990
|
+
@click.option(
|
|
2991
|
+
"--missing-only",
|
|
2992
|
+
is_flag=True,
|
|
2993
|
+
help="Only extract strings missing in target language",
|
|
2994
|
+
)
|
|
2995
|
+
@click.option(
|
|
2996
|
+
"--config",
|
|
2997
|
+
"-c",
|
|
2998
|
+
"config_file",
|
|
2999
|
+
type=click.Path(exists=True, path_type=Path),
|
|
3000
|
+
help="YAML configuration file",
|
|
3001
|
+
)
|
|
3002
|
+
def localise_extract(
|
|
3003
|
+
source: Path,
|
|
3004
|
+
target_language: str,
|
|
3005
|
+
output: Path | None,
|
|
3006
|
+
source_language: str,
|
|
3007
|
+
properties: str | None,
|
|
3008
|
+
include_deprecated: bool,
|
|
3009
|
+
missing_only: bool,
|
|
3010
|
+
config_file: Path | None,
|
|
3011
|
+
):
|
|
3012
|
+
"""Extract translatable strings from an ontology.
|
|
3013
|
+
|
|
3014
|
+
Generates a YAML file with source text and empty translation fields,
|
|
3015
|
+
ready to be filled in by translators.
|
|
3016
|
+
|
|
3017
|
+
\b
|
|
3018
|
+
Examples:
|
|
3019
|
+
# Basic extraction
|
|
3020
|
+
rdf-construct localise extract ontology.ttl --language de -o de.yml
|
|
3021
|
+
|
|
3022
|
+
# Extract only labels
|
|
3023
|
+
rdf-construct localise extract ontology.ttl -l de -p rdfs:label
|
|
3024
|
+
|
|
3025
|
+
# Extract missing strings only (for updates)
|
|
3026
|
+
rdf-construct localise extract ontology.ttl -l de --missing-only -o de_update.yml
|
|
3027
|
+
"""
|
|
3028
|
+
from rdflib import Graph
|
|
3029
|
+
from rdf_construct.localise import (
|
|
3030
|
+
StringExtractor,
|
|
3031
|
+
ExtractConfig,
|
|
3032
|
+
get_formatter as get_localise_formatter,
|
|
3033
|
+
)
|
|
3034
|
+
|
|
3035
|
+
# Load config if provided
|
|
3036
|
+
if config_file:
|
|
3037
|
+
from rdf_construct.localise import load_localise_config
|
|
3038
|
+
config = load_localise_config(config_file)
|
|
3039
|
+
extract_config = config.extract
|
|
3040
|
+
extract_config.target_language = target_language
|
|
3041
|
+
else:
|
|
3042
|
+
# Build config from CLI args
|
|
3043
|
+
prop_list = None
|
|
3044
|
+
if properties:
|
|
3045
|
+
prop_list = [_expand_localise_property(p.strip()) for p in properties.split(",")]
|
|
3046
|
+
|
|
3047
|
+
extract_config = ExtractConfig(
|
|
3048
|
+
source_language=source_language,
|
|
3049
|
+
target_language=target_language,
|
|
3050
|
+
properties=prop_list or ExtractConfig().properties,
|
|
3051
|
+
include_deprecated=include_deprecated,
|
|
3052
|
+
missing_only=missing_only,
|
|
3053
|
+
)
|
|
3054
|
+
|
|
3055
|
+
# Load graph
|
|
3056
|
+
click.echo(f"Loading {source}...")
|
|
3057
|
+
graph = Graph()
|
|
3058
|
+
graph.parse(source)
|
|
3059
|
+
|
|
3060
|
+
# Extract
|
|
3061
|
+
click.echo(f"Extracting strings for {target_language}...")
|
|
3062
|
+
extractor = StringExtractor(extract_config)
|
|
3063
|
+
result = extractor.extract(graph, source, target_language)
|
|
3064
|
+
|
|
3065
|
+
# Display result
|
|
3066
|
+
formatter = get_localise_formatter("text")
|
|
3067
|
+
click.echo(formatter.format_extraction_result(result))
|
|
3068
|
+
|
|
3069
|
+
if not result.success:
|
|
3070
|
+
raise SystemExit(2)
|
|
3071
|
+
|
|
3072
|
+
# Save output
|
|
3073
|
+
output_path = output or Path(f"{target_language}.yml")
|
|
3074
|
+
if result.translation_file:
|
|
3075
|
+
result.translation_file.save(output_path)
|
|
3076
|
+
click.echo()
|
|
3077
|
+
click.secho(f"✓ Wrote {output_path}", fg="green")
|
|
3078
|
+
|
|
3079
|
+
raise SystemExit(0)
|
|
3080
|
+
|
|
3081
|
+
|
|
3082
|
+
@localise.command("merge")
|
|
3083
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
3084
|
+
@click.argument("translations", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
|
|
3085
|
+
@click.option(
|
|
3086
|
+
"--output",
|
|
3087
|
+
"-o",
|
|
3088
|
+
type=click.Path(path_type=Path),
|
|
3089
|
+
required=True,
|
|
3090
|
+
help="Output file for merged ontology",
|
|
3091
|
+
)
|
|
3092
|
+
@click.option(
|
|
3093
|
+
"--status",
|
|
3094
|
+
type=click.Choice(["pending", "needs_review", "translated", "approved"], case_sensitive=False),
|
|
3095
|
+
default="translated",
|
|
3096
|
+
help="Minimum status to include (default: translated)",
|
|
3097
|
+
)
|
|
3098
|
+
@click.option(
|
|
3099
|
+
"--existing",
|
|
3100
|
+
type=click.Choice(["preserve", "overwrite"], case_sensitive=False),
|
|
3101
|
+
default="preserve",
|
|
3102
|
+
help="How to handle existing translations (default: preserve)",
|
|
3103
|
+
)
|
|
3104
|
+
@click.option(
|
|
3105
|
+
"--dry-run",
|
|
3106
|
+
is_flag=True,
|
|
3107
|
+
help="Show what would happen without writing files",
|
|
3108
|
+
)
|
|
3109
|
+
@click.option(
|
|
3110
|
+
"--no-colour",
|
|
3111
|
+
is_flag=True,
|
|
3112
|
+
help="Disable coloured output",
|
|
3113
|
+
)
|
|
3114
|
+
def localise_merge(
|
|
3115
|
+
source: Path,
|
|
3116
|
+
translations: tuple[Path, ...],
|
|
3117
|
+
output: Path,
|
|
3118
|
+
status: str,
|
|
3119
|
+
existing: str,
|
|
3120
|
+
dry_run: bool,
|
|
3121
|
+
no_colour: bool,
|
|
3122
|
+
):
|
|
3123
|
+
"""Merge translation files back into an ontology.
|
|
3124
|
+
|
|
3125
|
+
Takes completed YAML translation files and adds the translations
|
|
3126
|
+
as new language-tagged literals to the ontology.
|
|
3127
|
+
|
|
3128
|
+
\b
|
|
3129
|
+
Examples:
|
|
3130
|
+
# Merge single translation file
|
|
3131
|
+
rdf-construct localise merge ontology.ttl de.yml -o localised.ttl
|
|
3132
|
+
|
|
3133
|
+
# Merge multiple languages
|
|
3134
|
+
rdf-construct localise merge ontology.ttl translations/*.yml -o multilingual.ttl
|
|
3135
|
+
|
|
3136
|
+
# Merge only approved translations
|
|
3137
|
+
rdf-construct localise merge ontology.ttl de.yml --status approved -o localised.ttl
|
|
3138
|
+
"""
|
|
3139
|
+
from rdflib import Graph
|
|
3140
|
+
from rdf_construct.localise import (
|
|
3141
|
+
TranslationMerger,
|
|
3142
|
+
TranslationFile,
|
|
3143
|
+
MergeConfig as LocaliseMergeConfig,
|
|
3144
|
+
TranslationStatus,
|
|
3145
|
+
ExistingStrategy,
|
|
3146
|
+
get_formatter as get_localise_formatter,
|
|
3147
|
+
)
|
|
3148
|
+
|
|
3149
|
+
# Load graph
|
|
3150
|
+
click.echo(f"Loading {source}...")
|
|
3151
|
+
graph = Graph()
|
|
3152
|
+
graph.parse(source)
|
|
3153
|
+
|
|
3154
|
+
# Load translation files
|
|
3155
|
+
click.echo(f"Loading {len(translations)} translation file(s)...")
|
|
3156
|
+
trans_files = [TranslationFile.from_yaml(p) for p in translations]
|
|
3157
|
+
|
|
3158
|
+
# Build config
|
|
3159
|
+
config = LocaliseMergeConfig(
|
|
3160
|
+
min_status=TranslationStatus(status),
|
|
3161
|
+
existing=ExistingStrategy(existing),
|
|
3162
|
+
)
|
|
3163
|
+
|
|
3164
|
+
# Merge
|
|
3165
|
+
click.echo("Merging translations...")
|
|
3166
|
+
merger = TranslationMerger(config)
|
|
3167
|
+
result = merger.merge_multiple(graph, trans_files)
|
|
3168
|
+
|
|
3169
|
+
# Display result
|
|
3170
|
+
formatter = get_localise_formatter("text", use_colour=not no_colour)
|
|
3171
|
+
click.echo(formatter.format_merge_result(result))
|
|
3172
|
+
|
|
3173
|
+
if not result.success:
|
|
3174
|
+
raise SystemExit(2)
|
|
3175
|
+
|
|
3176
|
+
# Save output (unless dry run)
|
|
3177
|
+
if not dry_run and result.merged_graph:
|
|
3178
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
3179
|
+
result.merged_graph.serialize(destination=output, format="turtle")
|
|
3180
|
+
click.echo()
|
|
3181
|
+
click.secho(f"✓ Wrote {output}", fg="green")
|
|
3182
|
+
|
|
3183
|
+
# Exit code based on warnings
|
|
3184
|
+
if result.stats.errors > 0:
|
|
3185
|
+
raise SystemExit(1)
|
|
3186
|
+
else:
|
|
3187
|
+
raise SystemExit(0)
|
|
3188
|
+
|
|
3189
|
+
|
|
3190
|
+
@localise.command("report")
|
|
3191
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
3192
|
+
@click.option(
|
|
3193
|
+
"--languages",
|
|
3194
|
+
"-l",
|
|
3195
|
+
required=True,
|
|
3196
|
+
help="Comma-separated language codes to check (e.g., en,de,fr)",
|
|
3197
|
+
)
|
|
3198
|
+
@click.option(
|
|
3199
|
+
"--source-language",
|
|
3200
|
+
default="en",
|
|
3201
|
+
help="Base language for translations (default: en)",
|
|
3202
|
+
)
|
|
3203
|
+
@click.option(
|
|
3204
|
+
"--properties",
|
|
3205
|
+
"-p",
|
|
3206
|
+
help="Comma-separated properties to check",
|
|
3207
|
+
)
|
|
3208
|
+
@click.option(
|
|
3209
|
+
"--output",
|
|
3210
|
+
"-o",
|
|
3211
|
+
type=click.Path(path_type=Path),
|
|
3212
|
+
help="Output file for report",
|
|
3213
|
+
)
|
|
3214
|
+
@click.option(
|
|
3215
|
+
"--format",
|
|
3216
|
+
"-f",
|
|
3217
|
+
"output_format",
|
|
3218
|
+
type=click.Choice(["text", "markdown", "md"], case_sensitive=False),
|
|
3219
|
+
default="text",
|
|
3220
|
+
help="Output format (default: text)",
|
|
3221
|
+
)
|
|
3222
|
+
@click.option(
|
|
3223
|
+
"--verbose",
|
|
3224
|
+
"-v",
|
|
3225
|
+
is_flag=True,
|
|
3226
|
+
help="Show detailed missing translation list",
|
|
3227
|
+
)
|
|
3228
|
+
@click.option(
|
|
3229
|
+
"--no-colour",
|
|
3230
|
+
is_flag=True,
|
|
3231
|
+
help="Disable coloured output",
|
|
3232
|
+
)
|
|
3233
|
+
def localise_report(
|
|
3234
|
+
source: Path,
|
|
3235
|
+
languages: str,
|
|
3236
|
+
source_language: str,
|
|
3237
|
+
properties: str | None,
|
|
3238
|
+
output: Path | None,
|
|
3239
|
+
output_format: str,
|
|
3240
|
+
verbose: bool,
|
|
3241
|
+
no_colour: bool,
|
|
3242
|
+
):
|
|
3243
|
+
"""Generate translation coverage report.
|
|
3244
|
+
|
|
3245
|
+
Analyses an ontology and reports what percentage of translatable
|
|
3246
|
+
content has been translated into each target language.
|
|
3247
|
+
|
|
3248
|
+
\b
|
|
3249
|
+
Examples:
|
|
3250
|
+
# Basic coverage report
|
|
3251
|
+
rdf-construct localise report ontology.ttl --languages en,de,fr
|
|
3252
|
+
|
|
3253
|
+
# Detailed report with missing entities
|
|
3254
|
+
rdf-construct localise report ontology.ttl -l en,de,fr --verbose
|
|
3255
|
+
|
|
3256
|
+
# Markdown report to file
|
|
3257
|
+
rdf-construct localise report ontology.ttl -l en,de,fr -f markdown -o coverage.md
|
|
3258
|
+
"""
|
|
3259
|
+
from rdflib import Graph
|
|
3260
|
+
from rdf_construct.localise import (
|
|
3261
|
+
CoverageReporter,
|
|
3262
|
+
get_formatter as get_localise_formatter,
|
|
3263
|
+
)
|
|
3264
|
+
|
|
3265
|
+
# Parse languages
|
|
3266
|
+
lang_list = [lang.strip() for lang in languages.split(",")]
|
|
3267
|
+
|
|
3268
|
+
# Parse properties
|
|
3269
|
+
prop_list = None
|
|
3270
|
+
if properties:
|
|
3271
|
+
prop_list = [_expand_localise_property(p.strip()) for p in properties.split(",")]
|
|
3272
|
+
|
|
3273
|
+
# Load graph
|
|
3274
|
+
click.echo(f"Loading {source}...")
|
|
3275
|
+
graph = Graph()
|
|
3276
|
+
graph.parse(source)
|
|
3277
|
+
|
|
3278
|
+
# Generate report
|
|
3279
|
+
click.echo("Analysing translation coverage...")
|
|
3280
|
+
reporter = CoverageReporter(
|
|
3281
|
+
source_language=source_language,
|
|
3282
|
+
properties=prop_list,
|
|
3283
|
+
)
|
|
3284
|
+
report = reporter.report(graph, lang_list, source)
|
|
3285
|
+
|
|
3286
|
+
# Format output
|
|
3287
|
+
formatter = get_localise_formatter(output_format, use_colour=not no_colour)
|
|
3288
|
+
report_text = formatter.format_coverage_report(report, verbose=verbose)
|
|
3289
|
+
|
|
3290
|
+
# Output
|
|
3291
|
+
if output:
|
|
3292
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
3293
|
+
output.write_text(report_text)
|
|
3294
|
+
click.secho(f"✓ Wrote {output}", fg="green")
|
|
3295
|
+
else:
|
|
3296
|
+
click.echo()
|
|
3297
|
+
click.echo(report_text)
|
|
3298
|
+
|
|
3299
|
+
raise SystemExit(0)
|
|
3300
|
+
|
|
3301
|
+
|
|
3302
|
+
@localise.command("init")
|
|
3303
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
3304
|
+
@click.option(
|
|
3305
|
+
"--language",
|
|
3306
|
+
"-l",
|
|
3307
|
+
"target_language",
|
|
3308
|
+
required=True,
|
|
3309
|
+
help="Target language code (e.g., de, fr, es)",
|
|
3310
|
+
)
|
|
3311
|
+
@click.option(
|
|
3312
|
+
"--output",
|
|
3313
|
+
"-o",
|
|
3314
|
+
type=click.Path(path_type=Path),
|
|
3315
|
+
help="Output YAML file (default: {language}.yml)",
|
|
3316
|
+
)
|
|
3317
|
+
@click.option(
|
|
3318
|
+
"--source-language",
|
|
3319
|
+
default="en",
|
|
3320
|
+
help="Source language code (default: en)",
|
|
3321
|
+
)
|
|
3322
|
+
def localise_init(
|
|
3323
|
+
source: Path,
|
|
3324
|
+
target_language: str,
|
|
3325
|
+
output: Path | None,
|
|
3326
|
+
source_language: str,
|
|
3327
|
+
):
|
|
3328
|
+
"""Create empty translation file for a new language.
|
|
3329
|
+
|
|
3330
|
+
Equivalent to 'extract' but explicitly for starting a new language.
|
|
3331
|
+
|
|
3332
|
+
\b
|
|
3333
|
+
Examples:
|
|
3334
|
+
rdf-construct localise init ontology.ttl --language ja -o translations/ja.yml
|
|
3335
|
+
"""
|
|
3336
|
+
from rdflib import Graph
|
|
3337
|
+
from rdf_construct.localise import (
|
|
3338
|
+
StringExtractor,
|
|
3339
|
+
ExtractConfig,
|
|
3340
|
+
get_formatter as get_localise_formatter,
|
|
3341
|
+
)
|
|
3342
|
+
|
|
3343
|
+
# Build config
|
|
3344
|
+
extract_config = ExtractConfig(
|
|
3345
|
+
source_language=source_language,
|
|
3346
|
+
target_language=target_language,
|
|
3347
|
+
)
|
|
3348
|
+
|
|
3349
|
+
# Load graph
|
|
3350
|
+
click.echo(f"Loading {source}...")
|
|
3351
|
+
graph = Graph()
|
|
3352
|
+
graph.parse(source)
|
|
3353
|
+
|
|
3354
|
+
# Extract
|
|
3355
|
+
click.echo(f"Initialising translation file for {target_language}...")
|
|
3356
|
+
extractor = StringExtractor(extract_config)
|
|
3357
|
+
result = extractor.extract(graph, source, target_language)
|
|
3358
|
+
|
|
3359
|
+
# Display result
|
|
3360
|
+
formatter = get_localise_formatter("text")
|
|
3361
|
+
click.echo(formatter.format_extraction_result(result))
|
|
3362
|
+
|
|
3363
|
+
if not result.success:
|
|
3364
|
+
raise SystemExit(2)
|
|
3365
|
+
|
|
3366
|
+
# Save output
|
|
3367
|
+
output_path = output or Path(f"{target_language}.yml")
|
|
3368
|
+
if result.translation_file:
|
|
3369
|
+
result.translation_file.save(output_path)
|
|
3370
|
+
click.echo()
|
|
3371
|
+
click.secho(f"✓ Created {output_path}", fg="green")
|
|
3372
|
+
click.echo(f" Fill in translations and run:")
|
|
3373
|
+
click.echo(f" rdf-construct localise merge {source} {output_path} -o localised.ttl")
|
|
3374
|
+
|
|
3375
|
+
raise SystemExit(0)
|
|
3376
|
+
|
|
3377
|
+
|
|
3378
|
+
@localise.command("config")
|
|
3379
|
+
@click.option(
|
|
3380
|
+
"--init",
|
|
3381
|
+
"init_config",
|
|
3382
|
+
is_flag=True,
|
|
3383
|
+
help="Generate a default localise configuration file",
|
|
3384
|
+
)
|
|
3385
|
+
def localise_config(init_config: bool):
|
|
3386
|
+
"""Generate or validate localise configuration.
|
|
3387
|
+
|
|
3388
|
+
\b
|
|
3389
|
+
Examples:
|
|
3390
|
+
rdf-construct localise config --init
|
|
3391
|
+
"""
|
|
3392
|
+
from rdf_construct.localise import create_default_config as create_default_localise_config
|
|
3393
|
+
|
|
3394
|
+
if init_config:
|
|
3395
|
+
config_path = Path("localise.yml")
|
|
3396
|
+
if config_path.exists():
|
|
3397
|
+
click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
|
|
3398
|
+
raise click.Abort()
|
|
3399
|
+
|
|
3400
|
+
config_content = create_default_localise_config()
|
|
3401
|
+
config_path.write_text(config_content)
|
|
3402
|
+
click.secho(f"Created {config_path}", fg="green")
|
|
3403
|
+
click.echo("Edit this file to configure your localisation workflow.")
|
|
3404
|
+
else:
|
|
3405
|
+
click.echo("Use --init to create a default configuration file.")
|
|
3406
|
+
|
|
3407
|
+
raise SystemExit(0)
|
|
3408
|
+
|
|
3409
|
+
|
|
3410
|
+
def _expand_localise_property(prop: str) -> str:
|
|
3411
|
+
"""Expand a CURIE to full URI for localise commands."""
|
|
3412
|
+
prefixes = {
|
|
3413
|
+
"rdfs:": "http://www.w3.org/2000/01/rdf-schema#",
|
|
3414
|
+
"skos:": "http://www.w3.org/2004/02/skos/core#",
|
|
3415
|
+
"owl:": "http://www.w3.org/2002/07/owl#",
|
|
3416
|
+
"rdf:": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
3417
|
+
"dc:": "http://purl.org/dc/elements/1.1/",
|
|
3418
|
+
"dcterms:": "http://purl.org/dc/terms/",
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
for prefix, namespace in prefixes.items():
|
|
3422
|
+
if prop.startswith(prefix):
|
|
3423
|
+
return namespace + prop[len(prefix):]
|
|
3424
|
+
|
|
3425
|
+
return prop
|
|
3426
|
+
|
|
3427
|
+
|
|
3428
|
+
if __name__ == "__main__":
|
|
3429
|
+
cli()
|