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.
Files changed (110) hide show
  1. rdf_construct/__init__.py +12 -0
  2. rdf_construct/__main__.py +0 -0
  3. rdf_construct/cli.py +3429 -0
  4. rdf_construct/core/__init__.py +33 -0
  5. rdf_construct/core/config.py +116 -0
  6. rdf_construct/core/ordering.py +219 -0
  7. rdf_construct/core/predicate_order.py +212 -0
  8. rdf_construct/core/profile.py +157 -0
  9. rdf_construct/core/selector.py +64 -0
  10. rdf_construct/core/serialiser.py +232 -0
  11. rdf_construct/core/utils.py +89 -0
  12. rdf_construct/cq/__init__.py +77 -0
  13. rdf_construct/cq/expectations.py +365 -0
  14. rdf_construct/cq/formatters/__init__.py +45 -0
  15. rdf_construct/cq/formatters/json.py +104 -0
  16. rdf_construct/cq/formatters/junit.py +104 -0
  17. rdf_construct/cq/formatters/text.py +146 -0
  18. rdf_construct/cq/loader.py +300 -0
  19. rdf_construct/cq/runner.py +321 -0
  20. rdf_construct/diff/__init__.py +59 -0
  21. rdf_construct/diff/change_types.py +214 -0
  22. rdf_construct/diff/comparator.py +338 -0
  23. rdf_construct/diff/filters.py +133 -0
  24. rdf_construct/diff/formatters/__init__.py +71 -0
  25. rdf_construct/diff/formatters/json.py +192 -0
  26. rdf_construct/diff/formatters/markdown.py +210 -0
  27. rdf_construct/diff/formatters/text.py +195 -0
  28. rdf_construct/docs/__init__.py +60 -0
  29. rdf_construct/docs/config.py +238 -0
  30. rdf_construct/docs/extractors.py +603 -0
  31. rdf_construct/docs/generator.py +360 -0
  32. rdf_construct/docs/renderers/__init__.py +7 -0
  33. rdf_construct/docs/renderers/html.py +803 -0
  34. rdf_construct/docs/renderers/json.py +390 -0
  35. rdf_construct/docs/renderers/markdown.py +628 -0
  36. rdf_construct/docs/search.py +278 -0
  37. rdf_construct/docs/templates/html/base.html.jinja +44 -0
  38. rdf_construct/docs/templates/html/class.html.jinja +152 -0
  39. rdf_construct/docs/templates/html/hierarchy.html.jinja +28 -0
  40. rdf_construct/docs/templates/html/index.html.jinja +110 -0
  41. rdf_construct/docs/templates/html/instance.html.jinja +90 -0
  42. rdf_construct/docs/templates/html/namespaces.html.jinja +37 -0
  43. rdf_construct/docs/templates/html/property.html.jinja +124 -0
  44. rdf_construct/docs/templates/html/single_page.html.jinja +169 -0
  45. rdf_construct/lint/__init__.py +75 -0
  46. rdf_construct/lint/config.py +214 -0
  47. rdf_construct/lint/engine.py +396 -0
  48. rdf_construct/lint/formatters.py +327 -0
  49. rdf_construct/lint/rules.py +692 -0
  50. rdf_construct/localise/__init__.py +114 -0
  51. rdf_construct/localise/config.py +508 -0
  52. rdf_construct/localise/extractor.py +427 -0
  53. rdf_construct/localise/formatters/__init__.py +36 -0
  54. rdf_construct/localise/formatters/markdown.py +229 -0
  55. rdf_construct/localise/formatters/text.py +224 -0
  56. rdf_construct/localise/merger.py +346 -0
  57. rdf_construct/localise/reporter.py +356 -0
  58. rdf_construct/main.py +6 -0
  59. rdf_construct/merge/__init__.py +165 -0
  60. rdf_construct/merge/config.py +354 -0
  61. rdf_construct/merge/conflicts.py +281 -0
  62. rdf_construct/merge/formatters.py +426 -0
  63. rdf_construct/merge/merger.py +425 -0
  64. rdf_construct/merge/migrator.py +339 -0
  65. rdf_construct/merge/rules.py +377 -0
  66. rdf_construct/merge/splitter.py +1102 -0
  67. rdf_construct/puml2rdf/__init__.py +103 -0
  68. rdf_construct/puml2rdf/config.py +230 -0
  69. rdf_construct/puml2rdf/converter.py +420 -0
  70. rdf_construct/puml2rdf/merger.py +200 -0
  71. rdf_construct/puml2rdf/model.py +202 -0
  72. rdf_construct/puml2rdf/parser.py +565 -0
  73. rdf_construct/puml2rdf/validators.py +451 -0
  74. rdf_construct/refactor/__init__.py +72 -0
  75. rdf_construct/refactor/config.py +362 -0
  76. rdf_construct/refactor/deprecator.py +328 -0
  77. rdf_construct/refactor/formatters/__init__.py +8 -0
  78. rdf_construct/refactor/formatters/text.py +311 -0
  79. rdf_construct/refactor/renamer.py +294 -0
  80. rdf_construct/shacl/__init__.py +56 -0
  81. rdf_construct/shacl/config.py +166 -0
  82. rdf_construct/shacl/converters.py +520 -0
  83. rdf_construct/shacl/generator.py +364 -0
  84. rdf_construct/shacl/namespaces.py +93 -0
  85. rdf_construct/stats/__init__.py +29 -0
  86. rdf_construct/stats/collector.py +178 -0
  87. rdf_construct/stats/comparator.py +298 -0
  88. rdf_construct/stats/formatters/__init__.py +83 -0
  89. rdf_construct/stats/formatters/json.py +38 -0
  90. rdf_construct/stats/formatters/markdown.py +153 -0
  91. rdf_construct/stats/formatters/text.py +186 -0
  92. rdf_construct/stats/metrics/__init__.py +26 -0
  93. rdf_construct/stats/metrics/basic.py +147 -0
  94. rdf_construct/stats/metrics/complexity.py +137 -0
  95. rdf_construct/stats/metrics/connectivity.py +130 -0
  96. rdf_construct/stats/metrics/documentation.py +128 -0
  97. rdf_construct/stats/metrics/hierarchy.py +207 -0
  98. rdf_construct/stats/metrics/properties.py +88 -0
  99. rdf_construct/uml/__init__.py +22 -0
  100. rdf_construct/uml/context.py +194 -0
  101. rdf_construct/uml/mapper.py +371 -0
  102. rdf_construct/uml/odm_renderer.py +789 -0
  103. rdf_construct/uml/renderer.py +684 -0
  104. rdf_construct/uml/uml_layout.py +393 -0
  105. rdf_construct/uml/uml_style.py +613 -0
  106. rdf_construct-0.3.0.dist-info/METADATA +496 -0
  107. rdf_construct-0.3.0.dist-info/RECORD +110 -0
  108. rdf_construct-0.3.0.dist-info/WHEEL +4 -0
  109. rdf_construct-0.3.0.dist-info/entry_points.txt +3 -0
  110. 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()