rdf-construct 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. rdf_construct/__init__.py +12 -0
  2. rdf_construct/__main__.py +0 -0
  3. rdf_construct/cli.py +1762 -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/main.py +6 -0
  51. rdf_construct/puml2rdf/__init__.py +103 -0
  52. rdf_construct/puml2rdf/config.py +230 -0
  53. rdf_construct/puml2rdf/converter.py +420 -0
  54. rdf_construct/puml2rdf/merger.py +200 -0
  55. rdf_construct/puml2rdf/model.py +202 -0
  56. rdf_construct/puml2rdf/parser.py +565 -0
  57. rdf_construct/puml2rdf/validators.py +451 -0
  58. rdf_construct/shacl/__init__.py +56 -0
  59. rdf_construct/shacl/config.py +166 -0
  60. rdf_construct/shacl/converters.py +520 -0
  61. rdf_construct/shacl/generator.py +364 -0
  62. rdf_construct/shacl/namespaces.py +93 -0
  63. rdf_construct/stats/__init__.py +29 -0
  64. rdf_construct/stats/collector.py +178 -0
  65. rdf_construct/stats/comparator.py +298 -0
  66. rdf_construct/stats/formatters/__init__.py +83 -0
  67. rdf_construct/stats/formatters/json.py +38 -0
  68. rdf_construct/stats/formatters/markdown.py +153 -0
  69. rdf_construct/stats/formatters/text.py +186 -0
  70. rdf_construct/stats/metrics/__init__.py +26 -0
  71. rdf_construct/stats/metrics/basic.py +147 -0
  72. rdf_construct/stats/metrics/complexity.py +137 -0
  73. rdf_construct/stats/metrics/connectivity.py +130 -0
  74. rdf_construct/stats/metrics/documentation.py +128 -0
  75. rdf_construct/stats/metrics/hierarchy.py +207 -0
  76. rdf_construct/stats/metrics/properties.py +88 -0
  77. rdf_construct/uml/__init__.py +22 -0
  78. rdf_construct/uml/context.py +194 -0
  79. rdf_construct/uml/mapper.py +371 -0
  80. rdf_construct/uml/odm_renderer.py +789 -0
  81. rdf_construct/uml/renderer.py +684 -0
  82. rdf_construct/uml/uml_layout.py +393 -0
  83. rdf_construct/uml/uml_style.py +613 -0
  84. rdf_construct-0.2.0.dist-info/METADATA +431 -0
  85. rdf_construct-0.2.0.dist-info/RECORD +88 -0
  86. rdf_construct-0.2.0.dist-info/WHEEL +4 -0
  87. rdf_construct-0.2.0.dist-info/entry_points.txt +3 -0
  88. rdf_construct-0.2.0.dist-info/licenses/LICENSE +21 -0
rdf_construct/cli.py ADDED
@@ -0,0 +1,1762 @@
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
+ # Valid rendering modes
66
+ RENDERING_MODES = ["default", "odm"]
67
+
68
+ @click.group()
69
+ @click.version_option()
70
+ def cli():
71
+ """rdf-construct: Semantic RDF manipulation toolkit.
72
+
73
+ Tools for working with RDF ontologies:
74
+
75
+ \b
76
+ - lint: Check ontology quality (structural issues, documentation, best practices)
77
+ - uml: Generate PlantUML class diagrams
78
+ - order: Reorder Turtle files with semantic awareness
79
+
80
+ Use COMMAND --help for detailed options.
81
+ """
82
+ pass
83
+
84
+
85
+ @cli.command()
86
+ @click.argument("source", type=click.Path(exists=True, path_type=Path))
87
+ @click.argument("config", type=click.Path(exists=True, path_type=Path))
88
+ @click.option(
89
+ "--profile",
90
+ "-p",
91
+ multiple=True,
92
+ help="Profile(s) to generate (default: all profiles in config)",
93
+ )
94
+ @click.option(
95
+ "--outdir",
96
+ "-o",
97
+ type=click.Path(path_type=Path),
98
+ default="src/ontology",
99
+ help="Output directory (default: src/ontology)",
100
+ )
101
+ def order(source: Path, config: Path, profile: tuple[str, ...], outdir: Path):
102
+ """Reorder RDF Turtle files according to semantic profiles.
103
+
104
+ SOURCE: Input RDF Turtle file (.ttl)
105
+ CONFIG: YAML configuration file defining ordering profiles
106
+
107
+ Examples:
108
+
109
+ # Generate all profiles defined in config
110
+ rdf-construct order ontology.ttl order.yml
111
+
112
+ # Generate only specific profiles
113
+ rdf-construct order ontology.ttl order.yml -p alpha -p logical_topo
114
+
115
+ # Custom output directory
116
+ rdf-construct order ontology.ttl order.yml -o output/
117
+ """
118
+ # Load configuration
119
+ ordering_config = OrderingConfig(config)
120
+
121
+ # Determine which profiles to generate
122
+ if profile:
123
+ profiles_to_gen = list(profile)
124
+ else:
125
+ profiles_to_gen = ordering_config.list_profiles()
126
+
127
+ # Validate requested profiles exist
128
+ for prof_name in profiles_to_gen:
129
+ if prof_name not in ordering_config.profiles:
130
+ click.secho(
131
+ f"Error: Profile '{prof_name}' not found in config.", fg="red", err=True
132
+ )
133
+ available = ", ".join(ordering_config.list_profiles())
134
+ click.echo(f"Available profiles: {available}", err=True)
135
+ raise click.Abort()
136
+
137
+ # Create output directory
138
+ outdir.mkdir(parents=True, exist_ok=True)
139
+
140
+ # Parse source RDF
141
+ click.echo(f"Loading {source}...")
142
+ graph = Graph()
143
+ graph.parse(source.as_posix(), format="turtle")
144
+ prefix_map = extract_prefix_map(graph)
145
+
146
+ # Generate each profile
147
+ for prof_name in profiles_to_gen:
148
+ click.echo(f"Constructing profile: {prof_name}")
149
+ prof = ordering_config.get_profile(prof_name)
150
+
151
+ ordered_subjects: list = []
152
+ seen: set = set()
153
+
154
+ # Process each section
155
+ for sec in prof.sections:
156
+ if not isinstance(sec, dict) or not sec:
157
+ continue
158
+
159
+ sec_name, sec_cfg = next(iter(sec.items()))
160
+
161
+ # Handle header section - ontology metadata
162
+ if sec_name == "header":
163
+ ontology_subjects = [
164
+ s for s in graph.subjects(RDF.type, OWL.Ontology) if s not in seen
165
+ ]
166
+ for s in ontology_subjects:
167
+ ordered_subjects.append(s)
168
+ seen.add(s)
169
+ continue
170
+
171
+ # Regular sections
172
+ sec_cfg = sec_cfg or {}
173
+ select_key = sec_cfg.get("select", sec_name)
174
+ sort_mode = sec_cfg.get("sort", "qname_alpha")
175
+ roots_cfg = sec_cfg.get("roots")
176
+
177
+ # Select and sort subjects
178
+ chosen = select_subjects(graph, select_key, ordering_config.selectors)
179
+ chosen = [s for s in chosen if s not in seen]
180
+
181
+ ordered = sort_subjects(graph, set(chosen), sort_mode, roots_cfg)
182
+
183
+ for s in ordered:
184
+ if s not in seen:
185
+ ordered_subjects.append(s)
186
+ seen.add(s)
187
+
188
+ # Build output graph
189
+ out_graph = build_section_graph(graph, ordered_subjects)
190
+
191
+ # Rebind prefixes if configured
192
+ if ordering_config.defaults.get("preserve_prefix_order", True):
193
+ if ordering_config.prefix_order:
194
+ rebind_prefixes(out_graph, ordering_config.prefix_order, prefix_map)
195
+
196
+ # Get predicate ordering for this profile
197
+ predicate_order = ordering_config.get_predicate_order(prof_name)
198
+
199
+ # Serialise with predicate ordering
200
+ out_file = outdir / f"{source.stem}-{prof_name}.ttl"
201
+ serialise_turtle(out_graph, ordered_subjects, out_file, predicate_order)
202
+ click.secho(f" ✓ {out_file}", fg="green")
203
+
204
+ click.secho(
205
+ f"\nConstructed {len(profiles_to_gen)} profile(s) in {outdir}/", fg="cyan"
206
+ )
207
+
208
+
209
+ @cli.command()
210
+ @click.argument("config", type=click.Path(exists=True, path_type=Path))
211
+ def profiles(config: Path):
212
+ """List available profiles in a configuration file.
213
+
214
+ CONFIG: YAML configuration file to inspect
215
+ """
216
+ ordering_config = OrderingConfig(config)
217
+
218
+ click.secho("Available profiles:", fg="cyan", bold=True)
219
+ click.echo()
220
+
221
+ for prof_name in ordering_config.list_profiles():
222
+ prof = ordering_config.get_profile(prof_name)
223
+ click.secho(f" {prof_name}", fg="green", bold=True)
224
+ if prof.description:
225
+ click.echo(f" {prof.description}")
226
+ click.echo(f" Sections: {len(prof.sections)}")
227
+ click.echo()
228
+
229
+
230
+ @cli.command()
231
+ @click.argument("sources", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
232
+ @click.option(
233
+ "--config",
234
+ "-C",
235
+ required=True,
236
+ type=click.Path(exists=True, path_type=Path),
237
+ help="YAML configuration file defining UML contexts",
238
+ )
239
+ @click.option(
240
+ "--context",
241
+ "-c",
242
+ multiple=True,
243
+ help="Context(s) to generate (default: all contexts in config)",
244
+ )
245
+ @click.option(
246
+ "--outdir",
247
+ "-o",
248
+ type=click.Path(path_type=Path),
249
+ default="diagrams",
250
+ help="Output directory (default: diagrams)",
251
+ )
252
+ @click.option(
253
+ "--style-config",
254
+ type=click.Path(exists=True, path_type=Path),
255
+ help="Path to style configuration YAML (e.g., examples/uml_styles.yml)"
256
+ )
257
+ @click.option(
258
+ "--style", "-s",
259
+ help="Style scheme name to use (e.g., 'default', 'ies_semantic')"
260
+ )
261
+ @click.option(
262
+ "--layout-config",
263
+ type=click.Path(exists=True, path_type=Path),
264
+ help="Path to layout configuration YAML (e.g., examples/uml_layouts.yml)"
265
+ )
266
+ @click.option(
267
+ "--layout", "-l",
268
+ help="Layout name to use (e.g., 'hierarchy', 'compact')"
269
+ )
270
+ @click.option(
271
+ "--rendering-mode", "-r",
272
+ type=click.Choice(RENDERING_MODES, case_sensitive=False),
273
+ default="default",
274
+ help="Rendering mode: 'default' (custom stereotypes) or 'odm' (OMG ODM RDF Profile compliant)"
275
+ )
276
+ def uml(sources, config, context, outdir, style_config, style, layout_config, layout, rendering_mode):
277
+ """Generate UML class diagrams from RDF ontologies.
278
+
279
+ SOURCES: One or more RDF Turtle files (.ttl). The first file is the primary
280
+ source; additional files provide supporting definitions (e.g., imported
281
+ ontologies for complete class hierarchies).
282
+
283
+ Examples:
284
+
285
+ # Basic usage - single source
286
+ rdf-construct uml ontology.ttl -C contexts.yml
287
+
288
+ # Multiple sources - primary + supporting ontology
289
+ rdf-construct uml building.ttl ies4.ttl -C contexts.yml
290
+
291
+ # Multiple sources with styling (hierarchy inheritance works!)
292
+ rdf-construct uml building.ttl ies4.ttl -C contexts.yml \\
293
+ --style-config ies_colours.yml --style ies_full
294
+
295
+ # Generate specific context with ODM mode
296
+ rdf-construct uml building.ttl ies4.ttl -C contexts.yml -c core -r odm
297
+ """
298
+ # Load style if provided
299
+ style_scheme = None
300
+ if style_config and style:
301
+ style_cfg = load_style_config(style_config)
302
+ try:
303
+ style_scheme = style_cfg.get_scheme(style)
304
+ click.echo(f"Using style: {style}")
305
+ except KeyError as e:
306
+ click.secho(str(e), fg="red", err=True)
307
+ click.echo(f"Available styles: {', '.join(style_cfg.list_schemes())}")
308
+ raise click.Abort()
309
+
310
+ # Load layout if provided
311
+ layout_cfg = None
312
+ if layout_config and layout:
313
+ layout_mgr = load_layout_config(layout_config)
314
+ try:
315
+ layout_cfg = layout_mgr.get_layout(layout)
316
+ click.echo(f"Using layout: {layout}")
317
+ except KeyError as e:
318
+ click.secho(str(e), fg="red", err=True)
319
+ click.echo(f"Available layouts: {', '.join(layout_mgr.list_layouts())}")
320
+ raise click.Abort()
321
+
322
+ # Display rendering mode
323
+ if rendering_mode == "odm":
324
+ click.echo("Using rendering mode: ODM RDF Profile (OMG compliant)")
325
+ else:
326
+ click.echo("Using rendering mode: default")
327
+
328
+ # Load UML configuration
329
+ uml_config = load_uml_config(config)
330
+
331
+ # Determine which contexts to generate
332
+ if context:
333
+ contexts_to_gen = list(context)
334
+ else:
335
+ contexts_to_gen = uml_config.list_contexts()
336
+
337
+ # Validate requested contexts exist
338
+ for ctx_name in contexts_to_gen:
339
+ if ctx_name not in uml_config.contexts:
340
+ click.secho(
341
+ f"Error: Context '{ctx_name}' not found in config.", fg="red", err=True
342
+ )
343
+ available = ", ".join(uml_config.list_contexts())
344
+ click.echo(f"Available contexts: {available}", err=True)
345
+ raise click.Abort()
346
+
347
+ # Create output directory
348
+ # ToDo - handle exceptions properly
349
+ outdir = Path(outdir)
350
+ outdir.mkdir(parents=True, exist_ok=True)
351
+
352
+ # Parse source RDF files into a single graph
353
+ # The first source is considered the "primary" (used for output naming)
354
+ primary_source = sources[0]
355
+ graph = Graph()
356
+
357
+ for source in sources:
358
+ click.echo(f"Loading {source}...")
359
+ # Guess format from extension
360
+ suffix = source.suffix.lower()
361
+ if suffix in (".ttl", ".turtle"):
362
+ fmt = "turtle"
363
+ elif suffix in (".rdf", ".xml", ".owl"):
364
+ fmt = "xml"
365
+ elif suffix in (".nt", ".ntriples"):
366
+ fmt = "nt"
367
+ elif suffix in (".n3",):
368
+ fmt = "n3"
369
+ elif suffix in (".jsonld", ".json"):
370
+ fmt = "json-ld"
371
+ else:
372
+ fmt = "turtle" # Default to turtle
373
+
374
+ graph.parse(source.as_posix(), format=fmt)
375
+
376
+ if len(sources) > 1:
377
+ click.echo(f" Merged {len(sources)} source files ({len(graph)} triples total)")
378
+
379
+ # Get selectors from defaults (if any)
380
+ selectors = uml_config.defaults.get("selectors", {})
381
+
382
+ # Generate each context
383
+ for ctx_name in contexts_to_gen:
384
+ click.echo(f"Generating diagram: {ctx_name}")
385
+ ctx = uml_config.get_context(ctx_name)
386
+
387
+ # Select entities
388
+ entities = collect_diagram_entities(graph, ctx, selectors)
389
+
390
+ # Build output filename (include mode suffix for ODM)
391
+ if rendering_mode == "odm":
392
+ out_file = outdir / f"{primary_source.stem}-{ctx_name}-odm.puml"
393
+ else:
394
+ out_file = outdir / f"{primary_source.stem}-{ctx_name}.puml"
395
+
396
+ # Render with optional style and layout
397
+ if rendering_mode == "odm":
398
+ render_odm_plantuml(graph, entities, out_file, style_scheme, layout_cfg)
399
+ else:
400
+ render_plantuml(graph, entities, out_file, style_scheme, layout_cfg)
401
+
402
+ click.secho(f" ✓ {out_file}", fg="green")
403
+ click.echo(
404
+ f" Classes: {len(entities['classes'])}, "
405
+ f"Properties: {len(entities['object_properties']) + len(entities['datatype_properties'])}, "
406
+ f"Instances: {len(entities['instances'])}"
407
+ )
408
+
409
+ click.secho(
410
+ f"\nGenerated {len(contexts_to_gen)} diagram(s) in {outdir}/", fg="cyan"
411
+ )
412
+
413
+
414
+ @cli.command()
415
+ @click.argument("config", type=click.Path(exists=True, path_type=Path))
416
+ def contexts(config: Path):
417
+ """List available UML contexts in a configuration file.
418
+
419
+ CONFIG: YAML configuration file to inspect
420
+ """
421
+ uml_config = load_uml_config(config)
422
+
423
+ click.secho("Available UML contexts:", fg="cyan", bold=True)
424
+ click.echo()
425
+
426
+ for ctx_name in uml_config.list_contexts():
427
+ ctx = uml_config.get_context(ctx_name)
428
+ click.secho(f" {ctx_name}", fg="green", bold=True)
429
+ if ctx.description:
430
+ click.echo(f" {ctx.description}")
431
+
432
+ # Show selection strategy
433
+ if ctx.root_classes:
434
+ click.echo(f" Roots: {', '.join(ctx.root_classes)}")
435
+ elif ctx.focus_classes:
436
+ click.echo(f" Focus: {', '.join(ctx.focus_classes)}")
437
+ elif ctx.selector:
438
+ click.echo(f" Selector: {ctx.selector}")
439
+
440
+ if ctx.include_descendants:
441
+ depth_str = f"depth={ctx.max_depth}" if ctx.max_depth else "unlimited"
442
+ click.echo(f" Includes descendants ({depth_str})")
443
+
444
+ click.echo(f" Properties: {ctx.property_mode}")
445
+ click.echo()
446
+
447
+
448
+ @cli.command()
449
+ @click.argument("sources", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
450
+ @click.option(
451
+ "--level",
452
+ "-l",
453
+ type=click.Choice(["strict", "standard", "relaxed"], case_sensitive=False),
454
+ default="standard",
455
+ help="Strictness level (default: standard)",
456
+ )
457
+ @click.option(
458
+ "--format",
459
+ "-f",
460
+ "output_format", # Renamed to avoid shadowing builtin
461
+ type=click.Choice(["text", "json"], case_sensitive=False),
462
+ default="text",
463
+ help="Output format (default: text)",
464
+ )
465
+ @click.option(
466
+ "--config",
467
+ "-c",
468
+ type=click.Path(exists=True, path_type=Path),
469
+ help="Path to .rdf-lint.yml configuration file",
470
+ )
471
+ @click.option(
472
+ "--enable",
473
+ "-e",
474
+ multiple=True,
475
+ help="Enable specific rules (can be used multiple times)",
476
+ )
477
+ @click.option(
478
+ "--disable",
479
+ "-d",
480
+ multiple=True,
481
+ help="Disable specific rules (can be used multiple times)",
482
+ )
483
+ @click.option(
484
+ "--no-colour",
485
+ "--no-color",
486
+ is_flag=True,
487
+ help="Disable coloured output",
488
+ )
489
+ @click.option(
490
+ "--list-rules",
491
+ "list_rules_flag",
492
+ is_flag=True,
493
+ help="List available rules and exit",
494
+ )
495
+ @click.option(
496
+ "--init",
497
+ "init_config",
498
+ is_flag=True,
499
+ help="Generate a default .rdf-lint.yml config file and exit",
500
+ )
501
+ def lint(
502
+ sources: tuple[Path, ...],
503
+ level: str,
504
+ output_format: str,
505
+ config: Path | None,
506
+ enable: tuple[str, ...],
507
+ disable: tuple[str, ...],
508
+ no_colour: bool,
509
+ list_rules_flag: bool, # Must match the name above
510
+ init_config: bool,
511
+ ):
512
+ """Check RDF ontologies for quality issues.
513
+
514
+ Performs static analysis to detect structural problems, missing
515
+ documentation, and best practice violations.
516
+
517
+ \b
518
+ SOURCES: One or more RDF files to check (.ttl, .rdf, .owl, etc.)
519
+
520
+ \b
521
+ Exit codes:
522
+ 0 - No issues found
523
+ 1 - Warnings found (no errors)
524
+ 2 - Errors found
525
+
526
+ \b
527
+ Examples:
528
+ # Basic usage
529
+ rdf-construct lint ontology.ttl
530
+
531
+ # Multiple files
532
+ rdf-construct lint core.ttl domain.ttl
533
+
534
+ # Strict mode (warnings become errors)
535
+ rdf-construct lint ontology.ttl --level strict
536
+
537
+ # JSON output for CI
538
+ rdf-construct lint ontology.ttl --format json
539
+
540
+ # Use config file
541
+ rdf-construct lint ontology.ttl --config .rdf-lint.yml
542
+
543
+ # Enable/disable specific rules
544
+ rdf-construct lint ontology.ttl --enable orphan-class --disable missing-comment
545
+
546
+ # List available rules
547
+ rdf-construct lint --list-rules
548
+ """
549
+ # Handle --init flag
550
+ if init_config:
551
+ from .lint import create_default_config
552
+
553
+ config_path = Path(".rdf-lint.yml")
554
+ if config_path.exists():
555
+ click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
556
+ raise click.Abort()
557
+
558
+ config_content = create_default_config()
559
+ config_path.write_text(config_content)
560
+ click.secho(f"Created {config_path}", fg="green")
561
+ return
562
+
563
+ # Handle --list-rules flag
564
+ if list_rules_flag:
565
+ from .lint import get_all_rules
566
+
567
+ rules = get_all_rules()
568
+ click.secho("Available lint rules:", fg="cyan", bold=True)
569
+ click.echo()
570
+
571
+ # Group by category
572
+ categories: dict[str, list] = {}
573
+ for rule_id, spec in sorted(rules.items()):
574
+ cat = spec.category
575
+ if cat not in categories:
576
+ categories[cat] = []
577
+ categories[cat].append(spec)
578
+
579
+ for category, specs in sorted(categories.items()):
580
+ click.secho(f" {category.title()}", fg="yellow", bold=True)
581
+ for spec in specs:
582
+ severity_color = {
583
+ "error": "red",
584
+ "warning": "yellow",
585
+ "info": "blue",
586
+ }[spec.default_severity.value]
587
+ click.echo(
588
+ f" {spec.rule_id}: "
589
+ f"{click.style(spec.default_severity.value, fg=severity_color)} - "
590
+ f"{spec.description}"
591
+ )
592
+ click.echo()
593
+
594
+ return
595
+
596
+ # Validate we have sources for actual linting
597
+ if not sources:
598
+ click.secho("Error: No source files specified.", fg="red", err=True)
599
+ raise click.Abort()
600
+
601
+ lint_config: LintConfig
602
+
603
+ if config:
604
+ # Load from specified config file
605
+ try:
606
+ lint_config = load_lint_config(config)
607
+ click.echo(f"Using config: {config}")
608
+ except (FileNotFoundError, ValueError) as e:
609
+ click.secho(f"Error loading config: {e}", fg="red", err=True)
610
+ raise click.Abort()
611
+ else:
612
+ # Try to find config file automatically
613
+ found_config = find_config_file()
614
+ if found_config:
615
+ try:
616
+ lint_config = load_lint_config(found_config)
617
+ click.echo(f"Using config: {found_config}")
618
+ except (FileNotFoundError, ValueError) as e:
619
+ click.secho(f"Error loading config: {e}", fg="red", err=True)
620
+ raise click.Abort()
621
+ else:
622
+ lint_config = LintConfig()
623
+
624
+ # Apply CLI overrides
625
+ lint_config.level = level
626
+
627
+ if enable:
628
+ lint_config.enabled_rules = set(enable)
629
+ if disable:
630
+ lint_config.disabled_rules.update(disable)
631
+
632
+ # Create engine and run
633
+ engine = LintEngine(lint_config)
634
+
635
+ click.echo(f"Scanning {len(sources)} file(s)...")
636
+ click.echo()
637
+
638
+ summary = engine.lint_files(list(sources))
639
+
640
+ # Format and output results
641
+ use_colour = not no_colour and output_format == "text"
642
+ formatter = get_formatter(output_format, use_colour=use_colour)
643
+
644
+ output = formatter.format_summary(summary)
645
+ click.echo(output)
646
+
647
+ # Exit with appropriate code
648
+ raise SystemExit(summary.exit_code)
649
+
650
+
651
+ @cli.command()
652
+ @click.argument("old_file", type=click.Path(exists=True, path_type=Path))
653
+ @click.argument("new_file", type=click.Path(exists=True, path_type=Path))
654
+ @click.option(
655
+ "--output",
656
+ "-o",
657
+ type=click.Path(path_type=Path),
658
+ help="Write output to file instead of stdout",
659
+ )
660
+ @click.option(
661
+ "--format",
662
+ "-f",
663
+ "output_format",
664
+ type=click.Choice(["text", "markdown", "md", "json"], case_sensitive=False),
665
+ default="text",
666
+ help="Output format (default: text)",
667
+ )
668
+ @click.option(
669
+ "--show",
670
+ type=str,
671
+ help="Show only these change types (comma-separated: added,removed,modified)",
672
+ )
673
+ @click.option(
674
+ "--hide",
675
+ type=str,
676
+ help="Hide these change types (comma-separated: added,removed,modified)",
677
+ )
678
+ @click.option(
679
+ "--entities",
680
+ type=str,
681
+ help="Show only these entity types (comma-separated: classes,properties,instances)",
682
+ )
683
+ @click.option(
684
+ "--ignore-predicates",
685
+ type=str,
686
+ help="Ignore these predicates in comparison (comma-separated CURIEs)",
687
+ )
688
+ def diff(
689
+ old_file: Path,
690
+ new_file: Path,
691
+ output: Path | None,
692
+ output_format: str,
693
+ show: str | None,
694
+ hide: str | None,
695
+ entities: str | None,
696
+ ignore_predicates: str | None,
697
+ ):
698
+ """Compare two RDF files and show semantic differences.
699
+
700
+ Compares OLD_FILE to NEW_FILE and reports changes, ignoring cosmetic
701
+ differences like statement order, prefix bindings, and whitespace.
702
+
703
+ \b
704
+ Examples:
705
+ rdf-construct diff v1.0.ttl v1.1.ttl
706
+ rdf-construct diff v1.0.ttl v1.1.ttl --format markdown -o CHANGELOG.md
707
+ rdf-construct diff old.ttl new.ttl --show added,removed
708
+ rdf-construct diff old.ttl new.ttl --entities classes
709
+
710
+ \b
711
+ Exit codes:
712
+ 0 - Graphs are semantically identical
713
+ 1 - Differences were found
714
+ 2 - Error occurred
715
+ """
716
+
717
+ try:
718
+ # Parse ignored predicates
719
+ ignore_preds: set[URIRef] | None = None
720
+ if ignore_predicates:
721
+ temp_graph = Graph()
722
+ temp_graph.parse(str(old_file), format="turtle")
723
+
724
+ ignore_preds = set()
725
+ for pred_str in ignore_predicates.split(","):
726
+ pred_str = pred_str.strip()
727
+ uri = expand_curie(temp_graph, pred_str)
728
+ if uri:
729
+ ignore_preds.add(uri)
730
+ else:
731
+ click.secho(
732
+ f"Warning: Could not expand predicate '{pred_str}'",
733
+ fg="yellow",
734
+ err=True,
735
+ )
736
+
737
+ # Perform comparison
738
+ click.echo(f"Comparing {old_file.name} → {new_file.name}...", err=True)
739
+ diff_result = compare_files(old_file, new_file, ignore_predicates=ignore_preds)
740
+
741
+ # Apply filters
742
+ if show or hide or entities:
743
+ show_types = parse_filter_string(show) if show else None
744
+ hide_types = parse_filter_string(hide) if hide else None
745
+ entity_types = parse_filter_string(entities) if entities else None
746
+
747
+ diff_result = filter_diff(
748
+ diff_result,
749
+ show_types=show_types,
750
+ hide_types=hide_types,
751
+ entity_types=entity_types,
752
+ )
753
+
754
+ # Load graph for CURIE formatting
755
+ graph_for_format = None
756
+ if output_format in ("text", "markdown", "md"):
757
+ graph_for_format = Graph()
758
+ graph_for_format.parse(str(new_file), format="turtle")
759
+
760
+ # Format output
761
+ formatted = format_diff(diff_result, format_name=output_format, graph=graph_for_format)
762
+
763
+ # Write output
764
+ if output:
765
+ output.write_text(formatted)
766
+ click.secho(f"✓ Wrote diff to {output}", fg="green", err=True)
767
+ else:
768
+ click.echo(formatted)
769
+
770
+ # Exit code: 0 if identical, 1 if different
771
+ if diff_result.is_identical:
772
+ click.secho("Graphs are semantically identical.", fg="green", err=True)
773
+ sys.exit(0)
774
+ else:
775
+ sys.exit(1)
776
+
777
+ except FileNotFoundError as e:
778
+ click.secho(f"Error: {e}", fg="red", err=True)
779
+ sys.exit(2)
780
+ except ValueError as e:
781
+ click.secho(f"Error parsing RDF: {e}", fg="red", err=True)
782
+ sys.exit(2)
783
+ except Exception as e:
784
+ click.secho(f"Error: {e}", fg="red", err=True)
785
+ sys.exit(2)
786
+
787
+
788
+ @cli.command()
789
+ @click.argument("sources", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
790
+ @click.option(
791
+ "--output",
792
+ "-o",
793
+ type=click.Path(path_type=Path),
794
+ default="docs",
795
+ help="Output directory (default: docs)",
796
+ )
797
+ @click.option(
798
+ "--format",
799
+ "-f",
800
+ "output_format",
801
+ type=click.Choice(["html", "markdown", "md", "json"], case_sensitive=False),
802
+ default="html",
803
+ help="Output format (default: html)",
804
+ )
805
+ @click.option(
806
+ "--config",
807
+ "-C",
808
+ type=click.Path(exists=True, path_type=Path),
809
+ help="Path to configuration YAML file",
810
+ )
811
+ @click.option(
812
+ "--template",
813
+ "-t",
814
+ type=click.Path(exists=True, path_type=Path),
815
+ help="Path to custom template directory",
816
+ )
817
+ @click.option(
818
+ "--single-page",
819
+ is_flag=True,
820
+ help="Generate single-page documentation",
821
+ )
822
+ @click.option(
823
+ "--title",
824
+ help="Override ontology title",
825
+ )
826
+ @click.option(
827
+ "--no-search",
828
+ is_flag=True,
829
+ help="Disable search index generation (HTML only)",
830
+ )
831
+ @click.option(
832
+ "--no-instances",
833
+ is_flag=True,
834
+ help="Exclude instances from documentation",
835
+ )
836
+ @click.option(
837
+ "--include",
838
+ type=str,
839
+ help="Include only these entity types (comma-separated: classes,properties,instances)",
840
+ )
841
+ @click.option(
842
+ "--exclude",
843
+ type=str,
844
+ help="Exclude these entity types (comma-separated: classes,properties,instances)",
845
+ )
846
+ def docs(
847
+ sources: tuple[Path, ...],
848
+ output: Path,
849
+ output_format: str,
850
+ config: Path | None,
851
+ template: Path | None,
852
+ single_page: bool,
853
+ title: str | None,
854
+ no_search: bool,
855
+ no_instances: bool,
856
+ include: str | None,
857
+ exclude: str | None,
858
+ ):
859
+ """Generate documentation from RDF ontologies.
860
+
861
+ SOURCES: One or more RDF files to generate documentation from.
862
+
863
+ \b
864
+ Examples:
865
+ # Basic HTML documentation
866
+ rdf-construct docs ontology.ttl
867
+
868
+ # Markdown output to custom directory
869
+ rdf-construct docs ontology.ttl --format markdown -o api-docs/
870
+
871
+ # Single-page HTML with custom title
872
+ rdf-construct docs ontology.ttl --single-page --title "My Ontology"
873
+
874
+ # JSON output for custom rendering
875
+ rdf-construct docs ontology.ttl --format json
876
+
877
+ # Use custom templates
878
+ rdf-construct docs ontology.ttl --template my-templates/
879
+
880
+ # Generate from multiple sources (merged)
881
+ rdf-construct docs domain.ttl foundation.ttl -o docs/
882
+
883
+ \b
884
+ Output formats:
885
+ html - Navigable HTML pages with search (default)
886
+ markdown - GitHub/GitLab compatible Markdown
887
+ json - Structured JSON for custom rendering
888
+ """
889
+ from rdflib import Graph
890
+
891
+ from rdf_construct.docs import DocsConfig, DocsGenerator, load_docs_config
892
+
893
+ # Load or create configuration
894
+ if config:
895
+ doc_config = load_docs_config(config)
896
+ else:
897
+ doc_config = DocsConfig()
898
+
899
+ # Apply CLI overrides
900
+ doc_config.output_dir = output
901
+ doc_config.format = "markdown" if output_format == "md" else output_format
902
+ doc_config.single_page = single_page
903
+ doc_config.include_search = not no_search
904
+ doc_config.include_instances = not no_instances
905
+
906
+ if template:
907
+ doc_config.template_dir = template
908
+ if title:
909
+ doc_config.title = title
910
+
911
+ # Parse include/exclude filters
912
+ if include:
913
+ types = [t.strip().lower() for t in include.split(",")]
914
+ doc_config.include_classes = "classes" in types
915
+ doc_config.include_object_properties = "properties" in types or "object_properties" in types
916
+ doc_config.include_datatype_properties = "properties" in types or "datatype_properties" in types
917
+ doc_config.include_annotation_properties = "properties" in types or "annotation_properties" in types
918
+ doc_config.include_instances = "instances" in types
919
+
920
+ if exclude:
921
+ types = [t.strip().lower() for t in exclude.split(",")]
922
+ if "classes" in types:
923
+ doc_config.include_classes = False
924
+ if "properties" in types:
925
+ doc_config.include_object_properties = False
926
+ doc_config.include_datatype_properties = False
927
+ doc_config.include_annotation_properties = False
928
+ if "instances" in types:
929
+ doc_config.include_instances = False
930
+
931
+ # Load RDF sources
932
+ click.echo(f"Loading {len(sources)} source file(s)...")
933
+ graph = Graph()
934
+
935
+ for source in sources:
936
+ click.echo(f" Parsing {source.name}...")
937
+
938
+ # Determine format from extension
939
+ suffix = source.suffix.lower()
940
+ format_map = {
941
+ ".ttl": "turtle",
942
+ ".turtle": "turtle",
943
+ ".rdf": "xml",
944
+ ".xml": "xml",
945
+ ".owl": "xml",
946
+ ".nt": "nt",
947
+ ".ntriples": "nt",
948
+ ".n3": "n3",
949
+ ".jsonld": "json-ld",
950
+ ".json": "json-ld",
951
+ }
952
+ rdf_format = format_map.get(suffix, "turtle")
953
+
954
+ graph.parse(str(source), format=rdf_format)
955
+
956
+ click.echo(f" Total: {len(graph)} triples")
957
+ click.echo()
958
+
959
+ # Generate documentation
960
+ click.echo(f"Generating {doc_config.format} documentation...")
961
+
962
+ generator = DocsGenerator(doc_config)
963
+ result = generator.generate(graph)
964
+
965
+ # Summary
966
+ click.echo()
967
+ click.secho(f"✓ Generated {result.total_pages} files to {result.output_dir}/", fg="green")
968
+ click.echo(f" Classes: {result.classes_count}")
969
+ click.echo(f" Properties: {result.properties_count}")
970
+ click.echo(f" Instances: {result.instances_count}")
971
+
972
+ # Show entry point
973
+ if doc_config.format == "html":
974
+ index_path = result.output_dir / "index.html"
975
+ click.echo()
976
+ click.secho(f"Open {index_path} in your browser to view the documentation.", fg="cyan")
977
+
978
+
979
+ @cli.command("shacl-gen")
980
+ @click.argument("source", type=click.Path(exists=True, path_type=Path))
981
+ @click.option(
982
+ "--output",
983
+ "-o",
984
+ type=click.Path(path_type=Path),
985
+ help="Output file path (default: <source>-shapes.ttl)",
986
+ )
987
+ @click.option(
988
+ "--format",
989
+ "-f",
990
+ "output_format",
991
+ type=click.Choice(["turtle", "ttl", "json-ld", "jsonld"], case_sensitive=False),
992
+ default="turtle",
993
+ help="Output format (default: turtle)",
994
+ )
995
+ @click.option(
996
+ "--level",
997
+ "-l",
998
+ type=click.Choice(["minimal", "standard", "strict"], case_sensitive=False),
999
+ default="standard",
1000
+ help="Strictness level for constraint generation (default: standard)",
1001
+ )
1002
+ @click.option(
1003
+ "--config",
1004
+ "-C",
1005
+ type=click.Path(exists=True, path_type=Path),
1006
+ help="YAML configuration file",
1007
+ )
1008
+ @click.option(
1009
+ "--classes",
1010
+ type=str,
1011
+ help="Comma-separated list of classes to generate shapes for",
1012
+ )
1013
+ @click.option(
1014
+ "--closed",
1015
+ is_flag=True,
1016
+ help="Generate closed shapes (no extra properties allowed)",
1017
+ )
1018
+ @click.option(
1019
+ "--default-severity",
1020
+ type=click.Choice(["violation", "warning", "info"], case_sensitive=False),
1021
+ default="violation",
1022
+ help="Default severity for generated constraints",
1023
+ )
1024
+ @click.option(
1025
+ "--no-labels",
1026
+ is_flag=True,
1027
+ help="Don't include rdfs:label as sh:name",
1028
+ )
1029
+ @click.option(
1030
+ "--no-descriptions",
1031
+ is_flag=True,
1032
+ help="Don't include rdfs:comment as sh:description",
1033
+ )
1034
+ @click.option(
1035
+ "--no-inherit",
1036
+ is_flag=True,
1037
+ help="Don't inherit constraints from superclasses",
1038
+ )
1039
+ def shacl_gen(
1040
+ source: Path,
1041
+ output: Path | None,
1042
+ output_format: str,
1043
+ level: str,
1044
+ config: Path | None,
1045
+ classes: str | None,
1046
+ closed: bool,
1047
+ default_severity: str,
1048
+ no_labels: bool,
1049
+ no_descriptions: bool,
1050
+ no_inherit: bool,
1051
+ ):
1052
+ """Generate SHACL validation shapes from OWL ontology.
1053
+
1054
+ Converts OWL class definitions to SHACL NodeShapes, extracting
1055
+ constraints from domain/range declarations, cardinality restrictions,
1056
+ functional properties, and other OWL patterns.
1057
+
1058
+ SOURCE: Input RDF ontology file (.ttl, .rdf, .owl, etc.)
1059
+
1060
+ \b
1061
+ Strictness levels:
1062
+ minimal - Basic type constraints only (sh:class, sh:datatype)
1063
+ standard - Adds cardinality and functional property constraints
1064
+ strict - Maximum constraints including sh:closed, enumerations
1065
+
1066
+ \b
1067
+ Examples:
1068
+ # Basic generation
1069
+ rdf-construct shacl-gen ontology.ttl
1070
+
1071
+ # Generate with strict constraints
1072
+ rdf-construct shacl-gen ontology.ttl --level strict --closed
1073
+
1074
+ # Custom output path and format
1075
+ rdf-construct shacl-gen ontology.ttl -o shapes.ttl --format turtle
1076
+
1077
+ # Focus on specific classes
1078
+ rdf-construct shacl-gen ontology.ttl --classes "ex:Building,ex:Floor"
1079
+
1080
+ # Use configuration file
1081
+ rdf-construct shacl-gen ontology.ttl --config shacl-config.yml
1082
+
1083
+ # Generate warnings instead of violations
1084
+ rdf-construct shacl-gen ontology.ttl --default-severity warning
1085
+ """
1086
+ from rdf_construct.shacl import (
1087
+ generate_shapes_to_file,
1088
+ load_shacl_config,
1089
+ ShaclConfig,
1090
+ StrictnessLevel,
1091
+ Severity,
1092
+ )
1093
+
1094
+ # Determine output path
1095
+ if output is None:
1096
+ suffix = ".json" if "json" in output_format.lower() else ".ttl"
1097
+ output = source.with_stem(f"{source.stem}-shapes").with_suffix(suffix)
1098
+
1099
+ # Normalise format string
1100
+ if output_format.lower() in ("ttl", "turtle"):
1101
+ output_format = "turtle"
1102
+ elif output_format.lower() in ("json-ld", "jsonld"):
1103
+ output_format = "json-ld"
1104
+
1105
+ try:
1106
+ # Load configuration from file or build from CLI options
1107
+ if config:
1108
+ shacl_config = load_shacl_config(config)
1109
+ click.echo(f"Loaded configuration from {config}")
1110
+ else:
1111
+ shacl_config = ShaclConfig()
1112
+
1113
+ # Apply CLI overrides
1114
+ shacl_config.level = StrictnessLevel(level.lower())
1115
+
1116
+ if classes:
1117
+ shacl_config.target_classes = [c.strip() for c in classes.split(",")]
1118
+
1119
+ if closed:
1120
+ shacl_config.closed = True
1121
+
1122
+ shacl_config.default_severity = Severity(default_severity.lower())
1123
+
1124
+ if no_labels:
1125
+ shacl_config.include_labels = False
1126
+
1127
+ if no_descriptions:
1128
+ shacl_config.include_descriptions = False
1129
+
1130
+ if no_inherit:
1131
+ shacl_config.inherit_constraints = False
1132
+
1133
+ # Generate shapes
1134
+ click.echo(f"Generating SHACL shapes from {source}...")
1135
+ click.echo(f" Level: {shacl_config.level.value}")
1136
+
1137
+ if shacl_config.target_classes:
1138
+ click.echo(f" Target classes: {', '.join(shacl_config.target_classes)}")
1139
+
1140
+ shapes_graph = generate_shapes_to_file(
1141
+ source,
1142
+ output,
1143
+ shacl_config,
1144
+ output_format,
1145
+ )
1146
+
1147
+ # Count generated shapes
1148
+ from rdf_construct.shacl import SH
1149
+ num_shapes = len(list(shapes_graph.subjects(
1150
+ predicate=None, object=SH.NodeShape
1151
+ )))
1152
+
1153
+ click.secho(f"✓ Generated {num_shapes} shape(s) to {output}", fg="green")
1154
+
1155
+ if shacl_config.closed:
1156
+ click.echo(" (closed shapes enabled)")
1157
+
1158
+ except FileNotFoundError as e:
1159
+ click.secho(f"Error: {e}", fg="red", err=True)
1160
+ raise SystemExit(1)
1161
+ except ValueError as e:
1162
+ click.secho(f"Configuration error: {e}", fg="red", err=True)
1163
+ raise SystemExit(1)
1164
+ except Exception as e:
1165
+ click.secho(f"Error generating shapes: {e}", fg="red", err=True)
1166
+ raise SystemExit(1)
1167
+
1168
+
1169
+ # Output format choices
1170
+ OUTPUT_FORMATS = ["turtle", "ttl", "xml", "rdfxml", "jsonld", "json-ld", "nt", "ntriples"]
1171
+
1172
+
1173
+ @cli.command()
1174
+ @click.argument("source", type=click.Path(exists=True, path_type=Path))
1175
+ @click.option(
1176
+ "--output",
1177
+ "-o",
1178
+ type=click.Path(path_type=Path),
1179
+ help="Output file path (default: source name with .ttl extension)",
1180
+ )
1181
+ @click.option(
1182
+ "--format",
1183
+ "-f",
1184
+ "output_format",
1185
+ type=click.Choice(OUTPUT_FORMATS, case_sensitive=False),
1186
+ default="turtle",
1187
+ help="Output RDF format (default: turtle)",
1188
+ )
1189
+ @click.option(
1190
+ "--namespace",
1191
+ "-n",
1192
+ help="Default namespace URI for the ontology",
1193
+ )
1194
+ @click.option(
1195
+ "--config",
1196
+ "-C",
1197
+ type=click.Path(exists=True, path_type=Path),
1198
+ help="Path to YAML configuration file",
1199
+ )
1200
+ @click.option(
1201
+ "--merge",
1202
+ "-m",
1203
+ type=click.Path(exists=True, path_type=Path),
1204
+ help="Existing ontology file to merge with",
1205
+ )
1206
+ @click.option(
1207
+ "--validate",
1208
+ "-v",
1209
+ is_flag=True,
1210
+ help="Validate only, don't generate output",
1211
+ )
1212
+ @click.option(
1213
+ "--strict",
1214
+ is_flag=True,
1215
+ help="Treat warnings as errors",
1216
+ )
1217
+ @click.option(
1218
+ "--language",
1219
+ "-l",
1220
+ default="en",
1221
+ help="Language tag for labels/comments (default: en)",
1222
+ )
1223
+ @click.option(
1224
+ "--no-labels",
1225
+ is_flag=True,
1226
+ help="Don't auto-generate rdfs:label triples",
1227
+ )
1228
+ def puml2rdf(
1229
+ source: Path,
1230
+ output: Path | None,
1231
+ output_format: str,
1232
+ namespace: str | None,
1233
+ config: Path | None,
1234
+ merge: Path | None,
1235
+ validate: bool,
1236
+ strict: bool,
1237
+ language: str,
1238
+ no_labels: bool,
1239
+ ):
1240
+ """Convert PlantUML class diagram to RDF ontology.
1241
+
1242
+ Parses a PlantUML file and generates an RDF/OWL ontology.
1243
+ Supports classes, attributes, inheritance, and associations.
1244
+
1245
+ SOURCE: PlantUML file (.puml or .plantuml)
1246
+
1247
+ \b
1248
+ Examples:
1249
+ # Basic conversion
1250
+ rdf-construct puml2rdf design.puml
1251
+
1252
+ # Custom output and namespace
1253
+ rdf-construct puml2rdf design.puml -o ontology.ttl -n http://example.org/ont#
1254
+
1255
+ # Validate without generating
1256
+ rdf-construct puml2rdf design.puml --validate
1257
+
1258
+ # Merge with existing ontology
1259
+ rdf-construct puml2rdf design.puml --merge existing.ttl
1260
+
1261
+ # Use configuration file
1262
+ rdf-construct puml2rdf design.puml -C import-config.yml
1263
+
1264
+ \b
1265
+ Exit codes:
1266
+ 0 - Success
1267
+ 1 - Validation warnings (with --strict)
1268
+ 2 - Parse or validation errors
1269
+ """
1270
+ # Normalise output format
1271
+ format_map = {
1272
+ "ttl": "turtle",
1273
+ "rdfxml": "xml",
1274
+ "json-ld": "json-ld",
1275
+ "jsonld": "json-ld",
1276
+ "ntriples": "nt",
1277
+ }
1278
+ rdf_format = format_map.get(output_format.lower(), output_format.lower())
1279
+
1280
+ # Determine output path
1281
+ if output is None and not validate:
1282
+ ext_map = {"turtle": ".ttl", "xml": ".rdf", "json-ld": ".jsonld", "nt": ".nt"}
1283
+ ext = ext_map.get(rdf_format, ".ttl")
1284
+ output = source.with_suffix(ext)
1285
+
1286
+ # Load configuration if provided
1287
+ if config:
1288
+ try:
1289
+ import_config = load_import_config(config)
1290
+ conversion_config = import_config.to_conversion_config()
1291
+ except Exception as e:
1292
+ click.secho(f"Error loading config: {e}", fg="red", err=True)
1293
+ sys.exit(2)
1294
+ else:
1295
+ conversion_config = ConversionConfig()
1296
+
1297
+ # Override config with CLI options
1298
+ if namespace:
1299
+ conversion_config.default_namespace = namespace
1300
+ if language:
1301
+ conversion_config.language = language
1302
+ if no_labels:
1303
+ conversion_config.generate_labels = False
1304
+
1305
+ # Parse PlantUML file
1306
+ click.echo(f"Parsing {source.name}...")
1307
+ parser = PlantUMLParser()
1308
+
1309
+ try:
1310
+ parse_result = parser.parse_file(source)
1311
+ except Exception as e:
1312
+ click.secho(f"Error reading file: {e}", fg="red", err=True)
1313
+ sys.exit(2)
1314
+
1315
+ # Report parse errors
1316
+ if parse_result.errors:
1317
+ click.secho("Parse errors:", fg="red", err=True)
1318
+ for error in parse_result.errors:
1319
+ click.echo(f" Line {error.line_number}: {error.message}", err=True)
1320
+ sys.exit(2)
1321
+
1322
+ # Report parse warnings
1323
+ if parse_result.warnings:
1324
+ click.secho("Parse warnings:", fg="yellow", err=True)
1325
+ for warning in parse_result.warnings:
1326
+ click.echo(f" {warning}", err=True)
1327
+
1328
+ model = parse_result.model
1329
+ click.echo(
1330
+ f" Found: {len(model.classes)} classes, "
1331
+ f"{len(model.relationships)} relationships"
1332
+ )
1333
+
1334
+ # Validate model
1335
+ model_validation = validate_puml(model)
1336
+
1337
+ if model_validation.has_errors:
1338
+ click.secho("Model validation errors:", fg="red", err=True)
1339
+ for issue in model_validation.errors():
1340
+ click.echo(f" {issue}", err=True)
1341
+ sys.exit(2)
1342
+
1343
+ if model_validation.has_warnings:
1344
+ click.secho("Model validation warnings:", fg="yellow", err=True)
1345
+ for issue in model_validation.warnings():
1346
+ click.echo(f" {issue}", err=True)
1347
+ if strict:
1348
+ click.secho("Aborting due to --strict mode", fg="red", err=True)
1349
+ sys.exit(1)
1350
+
1351
+ # If validate-only mode, stop here
1352
+ if validate:
1353
+ if model_validation.has_warnings:
1354
+ click.secho(
1355
+ f"Validation complete: {model_validation.warning_count} warnings",
1356
+ fg="yellow",
1357
+ )
1358
+ else:
1359
+ click.secho("Validation complete: no issues found", fg="green")
1360
+ sys.exit(0)
1361
+
1362
+ # Convert to RDF
1363
+ click.echo("Converting to RDF...")
1364
+ converter = PumlToRdfConverter(conversion_config)
1365
+ conversion_result = converter.convert(model)
1366
+
1367
+ if conversion_result.warnings:
1368
+ click.secho("Conversion warnings:", fg="yellow", err=True)
1369
+ for warning in conversion_result.warnings:
1370
+ click.echo(f" {warning}", err=True)
1371
+
1372
+ graph = conversion_result.graph
1373
+ click.echo(f" Generated: {len(graph)} triples")
1374
+
1375
+ # Validate generated RDF
1376
+ rdf_validation = validate_rdf(graph)
1377
+ if rdf_validation.has_warnings:
1378
+ click.secho("RDF validation warnings:", fg="yellow", err=True)
1379
+ for issue in rdf_validation.warnings():
1380
+ click.echo(f" {issue}", err=True)
1381
+
1382
+ # Merge with existing if requested
1383
+ if merge:
1384
+ click.echo(f"Merging with {merge.name}...")
1385
+ try:
1386
+ merge_result = merge_with_existing(graph, merge)
1387
+ graph = merge_result.graph
1388
+ click.echo(
1389
+ f" Added: {merge_result.added_count}, "
1390
+ f"Preserved: {merge_result.preserved_count}"
1391
+ )
1392
+ if merge_result.conflicts:
1393
+ click.secho("Merge conflicts:", fg="yellow", err=True)
1394
+ for conflict in merge_result.conflicts[:5]: # Limit output
1395
+ click.echo(f" {conflict}", err=True)
1396
+ if len(merge_result.conflicts) > 5:
1397
+ click.echo(
1398
+ f" ... and {len(merge_result.conflicts) - 5} more",
1399
+ err=True,
1400
+ )
1401
+ except Exception as e:
1402
+ click.secho(f"Error merging: {e}", fg="red", err=True)
1403
+ sys.exit(2)
1404
+
1405
+ # Serialise output
1406
+ try:
1407
+ graph.serialize(str(output), format=rdf_format)
1408
+ click.secho(f"✓ Wrote {output}", fg="green")
1409
+ click.echo(
1410
+ f" Classes: {len(conversion_result.class_uris)}, "
1411
+ f"Properties: {len(conversion_result.property_uris)}"
1412
+ )
1413
+ except Exception as e:
1414
+ click.secho(f"Error writing output: {e}", fg="red", err=True)
1415
+ sys.exit(2)
1416
+
1417
+
1418
+ @cli.command("cq-test")
1419
+ @click.argument("ontology", type=click.Path(exists=True, path_type=Path))
1420
+ @click.argument("test_file", type=click.Path(exists=True, path_type=Path))
1421
+ @click.option(
1422
+ "--data",
1423
+ "-d",
1424
+ multiple=True,
1425
+ type=click.Path(exists=True, path_type=Path),
1426
+ help="Additional data file(s) to load alongside the ontology",
1427
+ )
1428
+ @click.option(
1429
+ "--tag",
1430
+ "-t",
1431
+ multiple=True,
1432
+ help="Only run tests with these tags (can specify multiple)",
1433
+ )
1434
+ @click.option(
1435
+ "--exclude-tag",
1436
+ "-x",
1437
+ multiple=True,
1438
+ help="Exclude tests with these tags (can specify multiple)",
1439
+ )
1440
+ @click.option(
1441
+ "--format",
1442
+ "-f",
1443
+ "output_format",
1444
+ type=click.Choice(["text", "json", "junit"], case_sensitive=False),
1445
+ default="text",
1446
+ help="Output format (default: text)",
1447
+ )
1448
+ @click.option(
1449
+ "--output",
1450
+ "-o",
1451
+ type=click.Path(path_type=Path),
1452
+ help="Write output to file instead of stdout",
1453
+ )
1454
+ @click.option(
1455
+ "--verbose",
1456
+ "-v",
1457
+ is_flag=True,
1458
+ help="Show verbose output (query text, timing details)",
1459
+ )
1460
+ @click.option(
1461
+ "--fail-fast",
1462
+ is_flag=True,
1463
+ help="Stop on first failure",
1464
+ )
1465
+ def cq_test(
1466
+ ontology: Path,
1467
+ test_file: Path,
1468
+ data: tuple[Path, ...],
1469
+ tag: tuple[str, ...],
1470
+ exclude_tag: tuple[str, ...],
1471
+ output_format: str,
1472
+ output: Path | None,
1473
+ verbose: bool,
1474
+ fail_fast: bool,
1475
+ ):
1476
+ """Run competency question tests against an ontology.
1477
+
1478
+ Validates whether an ontology can answer competency questions expressed
1479
+ as SPARQL queries with expected results.
1480
+
1481
+ ONTOLOGY: RDF file containing the ontology to test
1482
+ TEST_FILE: YAML file containing competency question tests
1483
+
1484
+ \b
1485
+ Examples:
1486
+ # Run all tests
1487
+ rdf-construct cq-test ontology.ttl cq-tests.yml
1488
+
1489
+ # Run with additional sample data
1490
+ rdf-construct cq-test ontology.ttl cq-tests.yml --data sample-data.ttl
1491
+
1492
+ # Run only tests tagged 'core'
1493
+ rdf-construct cq-test ontology.ttl cq-tests.yml --tag core
1494
+
1495
+ # Generate JUnit XML for CI
1496
+ rdf-construct cq-test ontology.ttl cq-tests.yml --format junit -o results.xml
1497
+
1498
+ # Verbose output with timing
1499
+ rdf-construct cq-test ontology.ttl cq-tests.yml --verbose
1500
+
1501
+ \b
1502
+ Exit codes:
1503
+ 0 - All tests passed
1504
+ 1 - One or more tests failed
1505
+ 2 - Error occurred (invalid file, parse error, etc.)
1506
+ """
1507
+ try:
1508
+ # Load ontology
1509
+ click.echo(f"Loading ontology: {ontology.name}...", err=True)
1510
+ graph = Graph()
1511
+ graph.parse(str(ontology), format=_infer_format(ontology))
1512
+
1513
+ # Load additional data files
1514
+ if data:
1515
+ for data_file in data:
1516
+ click.echo(f"Loading data: {data_file.name}...", err=True)
1517
+ graph.parse(str(data_file), format=_infer_format(data_file))
1518
+
1519
+ # Load test suite
1520
+ click.echo(f"Loading tests: {test_file.name}...", err=True)
1521
+ suite = load_test_suite(test_file)
1522
+
1523
+ # Filter by tags
1524
+ if tag or exclude_tag:
1525
+ include_tags = set(tag) if tag else None
1526
+ exclude_tags = set(exclude_tag) if exclude_tag else None
1527
+ suite = suite.filter_by_tags(include_tags, exclude_tags)
1528
+
1529
+ if not suite.questions:
1530
+ click.secho("No tests to run (check tag filters)", fg="yellow", err=True)
1531
+ sys.exit(0)
1532
+
1533
+ # Run tests
1534
+ click.echo(f"Running {len(suite.questions)} test(s)...", err=True)
1535
+ click.echo("", err=True)
1536
+
1537
+ runner = CQTestRunner(fail_fast=fail_fast, verbose=verbose)
1538
+ results = runner.run(graph, suite, ontology_file=ontology)
1539
+
1540
+ # Format output
1541
+ formatted = format_results(results, format_name=output_format, verbose=verbose)
1542
+
1543
+ # Write output
1544
+ if output:
1545
+ output.write_text(formatted)
1546
+ click.secho(f"✓ Results written to {output}", fg="green", err=True)
1547
+ else:
1548
+ click.echo(formatted)
1549
+
1550
+ # Exit code based on results
1551
+ if results.has_errors:
1552
+ sys.exit(2)
1553
+ elif results.has_failures:
1554
+ sys.exit(1)
1555
+ else:
1556
+ sys.exit(0)
1557
+
1558
+ except FileNotFoundError as e:
1559
+ click.secho(f"Error: {e}", fg="red", err=True)
1560
+ sys.exit(2)
1561
+ except ValueError as e:
1562
+ click.secho(f"Error: {e}", fg="red", err=True)
1563
+ sys.exit(2)
1564
+ except Exception as e:
1565
+ click.secho(f"Error: {type(e).__name__}: {e}", fg="red", err=True)
1566
+ sys.exit(2)
1567
+
1568
+
1569
+ def _infer_format(path: Path) -> str:
1570
+ """Infer RDF format from file extension."""
1571
+ suffix = path.suffix.lower()
1572
+ format_map = {
1573
+ ".ttl": "turtle",
1574
+ ".turtle": "turtle",
1575
+ ".rdf": "xml",
1576
+ ".xml": "xml",
1577
+ ".owl": "xml",
1578
+ ".nt": "nt",
1579
+ ".ntriples": "nt",
1580
+ ".n3": "n3",
1581
+ ".jsonld": "json-ld",
1582
+ ".json": "json-ld",
1583
+ }
1584
+ return format_map.get(suffix, "turtle")
1585
+
1586
+
1587
+ @cli.command()
1588
+ @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
1589
+ @click.option(
1590
+ "--output",
1591
+ "-o",
1592
+ type=click.Path(path_type=Path),
1593
+ help="Write output to file instead of stdout",
1594
+ )
1595
+ @click.option(
1596
+ "--format",
1597
+ "-f",
1598
+ "output_format",
1599
+ type=click.Choice(["text", "json", "markdown", "md"], case_sensitive=False),
1600
+ default="text",
1601
+ help="Output format (default: text)",
1602
+ )
1603
+ @click.option(
1604
+ "--compare",
1605
+ is_flag=True,
1606
+ help="Compare two ontology files (requires exactly 2 files)",
1607
+ )
1608
+ @click.option(
1609
+ "--include",
1610
+ type=str,
1611
+ help="Include only these metric categories (comma-separated: basic,hierarchy,properties,documentation,complexity,connectivity)",
1612
+ )
1613
+ @click.option(
1614
+ "--exclude",
1615
+ type=str,
1616
+ help="Exclude these metric categories (comma-separated)",
1617
+ )
1618
+ def stats(
1619
+ files: tuple[Path, ...],
1620
+ output: Path | None,
1621
+ output_format: str,
1622
+ compare: bool,
1623
+ include: str | None,
1624
+ exclude: str | None,
1625
+ ):
1626
+ """Compute and display ontology statistics.
1627
+
1628
+ Analyses one or more RDF ontology files and displays comprehensive metrics
1629
+ about structure, complexity, and documentation coverage.
1630
+
1631
+ \b
1632
+ Examples:
1633
+ # Basic statistics
1634
+ rdf-construct stats ontology.ttl
1635
+
1636
+ # JSON output for programmatic use
1637
+ rdf-construct stats ontology.ttl --format json -o stats.json
1638
+
1639
+ # Markdown for documentation
1640
+ rdf-construct stats ontology.ttl --format markdown >> README.md
1641
+
1642
+ # Compare two versions
1643
+ rdf-construct stats v1.ttl v2.ttl --compare
1644
+
1645
+ # Only show specific categories
1646
+ rdf-construct stats ontology.ttl --include basic,documentation
1647
+
1648
+ # Exclude some categories
1649
+ rdf-construct stats ontology.ttl --exclude connectivity,complexity
1650
+
1651
+ \b
1652
+ Metric Categories:
1653
+ basic - Counts (triples, classes, properties, individuals)
1654
+ hierarchy - Structure (depth, branching, orphans)
1655
+ properties - Coverage (domain, range, functional, symmetric)
1656
+ documentation - Labels and comments
1657
+ complexity - Multiple inheritance, OWL axioms
1658
+ connectivity - Most connected class, isolated classes
1659
+
1660
+ \b
1661
+ Exit codes:
1662
+ 0 - Success
1663
+ 1 - Error occurred
1664
+ """
1665
+ try:
1666
+ # Validate file count for compare mode
1667
+ if compare:
1668
+ if len(files) != 2:
1669
+ click.secho(
1670
+ "Error: --compare requires exactly 2 files",
1671
+ fg="red",
1672
+ err=True,
1673
+ )
1674
+ sys.exit(1)
1675
+
1676
+ # Parse include/exclude categories
1677
+ include_set: set[str] | None = None
1678
+ exclude_set: set[str] | None = None
1679
+
1680
+ if include:
1681
+ include_set = {cat.strip().lower() for cat in include.split(",")}
1682
+ if exclude:
1683
+ exclude_set = {cat.strip().lower() for cat in exclude.split(",")}
1684
+
1685
+ # Load graphs
1686
+ graphs: list[tuple[Graph, Path]] = []
1687
+ for filepath in files:
1688
+ click.echo(f"Loading {filepath}...", err=True)
1689
+ graph = Graph()
1690
+ graph.parse(str(filepath), format="turtle")
1691
+ graphs.append((graph, filepath))
1692
+ click.echo(f" Loaded {len(graph)} triples", err=True)
1693
+
1694
+ if compare:
1695
+ # Comparison mode
1696
+ old_graph, old_path = graphs[0]
1697
+ new_graph, new_path = graphs[1]
1698
+
1699
+ click.echo("Collecting statistics...", err=True)
1700
+ old_stats = collect_stats(
1701
+ old_graph,
1702
+ source=str(old_path),
1703
+ include=include_set,
1704
+ exclude=exclude_set,
1705
+ )
1706
+ new_stats = collect_stats(
1707
+ new_graph,
1708
+ source=str(new_path),
1709
+ include=include_set,
1710
+ exclude=exclude_set,
1711
+ )
1712
+
1713
+ click.echo("Comparing versions...", err=True)
1714
+ comparison = compare_stats(old_stats, new_stats)
1715
+
1716
+ # Format output
1717
+ formatted = format_comparison(
1718
+ comparison,
1719
+ format_name=output_format,
1720
+ graph=new_graph,
1721
+ )
1722
+ else:
1723
+ # Single file or multiple files (show stats for first)
1724
+ graph, filepath = graphs[0]
1725
+
1726
+ click.echo("Collecting statistics...", err=True)
1727
+ ontology_stats = collect_stats(
1728
+ graph,
1729
+ source=str(filepath),
1730
+ include=include_set,
1731
+ exclude=exclude_set,
1732
+ )
1733
+
1734
+ # Format output
1735
+ formatted = format_stats(
1736
+ ontology_stats,
1737
+ format_name=output_format,
1738
+ graph=graph,
1739
+ )
1740
+
1741
+ # Write output
1742
+ if output:
1743
+ output.write_text(formatted)
1744
+ click.secho(f"✓ Wrote stats to {output}", fg="green", err=True)
1745
+ else:
1746
+ click.echo(formatted)
1747
+
1748
+ sys.exit(0)
1749
+
1750
+ except ValueError as e:
1751
+ click.secho(f"Error: {e}", fg="red", err=True)
1752
+ sys.exit(1)
1753
+ except FileNotFoundError as e:
1754
+ click.secho(f"Error: {e}", fg="red", err=True)
1755
+ sys.exit(1)
1756
+ except Exception as e:
1757
+ click.secho(f"Error: {e}", fg="red", err=True)
1758
+ sys.exit(1)
1759
+
1760
+
1761
+ if __name__ == "__main__":
1762
+ cli()