rdf-construct 0.2.1__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. rdf_construct/__init__.py +1 -1
  2. rdf_construct/cli.py +1794 -0
  3. rdf_construct/describe/__init__.py +93 -0
  4. rdf_construct/describe/analyzer.py +176 -0
  5. rdf_construct/describe/documentation.py +146 -0
  6. rdf_construct/describe/formatters/__init__.py +47 -0
  7. rdf_construct/describe/formatters/json.py +65 -0
  8. rdf_construct/describe/formatters/markdown.py +275 -0
  9. rdf_construct/describe/formatters/text.py +315 -0
  10. rdf_construct/describe/hierarchy.py +232 -0
  11. rdf_construct/describe/imports.py +213 -0
  12. rdf_construct/describe/metadata.py +187 -0
  13. rdf_construct/describe/metrics.py +145 -0
  14. rdf_construct/describe/models.py +552 -0
  15. rdf_construct/describe/namespaces.py +180 -0
  16. rdf_construct/describe/profiles.py +415 -0
  17. rdf_construct/localise/__init__.py +114 -0
  18. rdf_construct/localise/config.py +508 -0
  19. rdf_construct/localise/extractor.py +427 -0
  20. rdf_construct/localise/formatters/__init__.py +36 -0
  21. rdf_construct/localise/formatters/markdown.py +229 -0
  22. rdf_construct/localise/formatters/text.py +224 -0
  23. rdf_construct/localise/merger.py +346 -0
  24. rdf_construct/localise/reporter.py +356 -0
  25. rdf_construct/merge/__init__.py +165 -0
  26. rdf_construct/merge/config.py +354 -0
  27. rdf_construct/merge/conflicts.py +281 -0
  28. rdf_construct/merge/formatters.py +426 -0
  29. rdf_construct/merge/merger.py +425 -0
  30. rdf_construct/merge/migrator.py +339 -0
  31. rdf_construct/merge/rules.py +377 -0
  32. rdf_construct/merge/splitter.py +1102 -0
  33. rdf_construct/refactor/__init__.py +72 -0
  34. rdf_construct/refactor/config.py +362 -0
  35. rdf_construct/refactor/deprecator.py +328 -0
  36. rdf_construct/refactor/formatters/__init__.py +8 -0
  37. rdf_construct/refactor/formatters/text.py +311 -0
  38. rdf_construct/refactor/renamer.py +294 -0
  39. {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/METADATA +91 -6
  40. {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/RECORD +43 -7
  41. {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/WHEEL +0 -0
  42. {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/entry_points.txt +0 -0
  43. {rdf_construct-0.2.1.dist-info → rdf_construct-0.4.0.dist-info}/licenses/LICENSE +0 -0
rdf_construct/cli.py CHANGED
@@ -62,6 +62,58 @@ from rdf_construct.stats import (
62
62
  format_comparison,
63
63
  )
64
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
+
65
117
  # Valid rendering modes
66
118
  RENDERING_MODES = ["default", "odm"]
67
119
 
@@ -1758,5 +1810,1747 @@ def stats(
1758
1810
  sys.exit(1)
1759
1811
 
1760
1812
 
1813
+ @cli.command()
1814
+ @click.argument("file", type=click.Path(exists=True, path_type=Path))
1815
+ @click.option(
1816
+ "--output",
1817
+ "-o",
1818
+ type=click.Path(path_type=Path),
1819
+ help="Write output to file instead of stdout",
1820
+ )
1821
+ @click.option(
1822
+ "--format",
1823
+ "-f",
1824
+ "output_format",
1825
+ type=click.Choice(["text", "json", "markdown", "md"], case_sensitive=False),
1826
+ default="text",
1827
+ help="Output format (default: text)",
1828
+ )
1829
+ @click.option(
1830
+ "--brief",
1831
+ is_flag=True,
1832
+ help="Show brief summary only (metadata, metrics, profile)",
1833
+ )
1834
+ @click.option(
1835
+ "--no-resolve",
1836
+ is_flag=True,
1837
+ help="Skip import resolution checks",
1838
+ )
1839
+ @click.option(
1840
+ "--reasoning",
1841
+ is_flag=True,
1842
+ help="Include reasoning analysis",
1843
+ )
1844
+ @click.option(
1845
+ "--no-colour",
1846
+ "--no-color",
1847
+ is_flag=True,
1848
+ help="Disable coloured output (text format only)",
1849
+ )
1850
+ def describe(
1851
+ file: Path,
1852
+ output: Path | None,
1853
+ output_format: str,
1854
+ brief: bool,
1855
+ no_resolve: bool,
1856
+ reasoning: bool,
1857
+ no_colour: bool,
1858
+ ):
1859
+ """Describe an ontology: profile, metrics, imports, and structure.
1860
+
1861
+ Provides a comprehensive analysis of an RDF ontology file, including:
1862
+ - Profile detection (RDF, RDFS, OWL DL, OWL Full)
1863
+ - Basic metrics (classes, properties, individuals)
1864
+ - Import analysis with optional resolvability checking
1865
+ - Namespace categorisation
1866
+ - Class hierarchy analysis
1867
+ - Documentation coverage
1868
+
1869
+ FILE: RDF ontology file to describe (.ttl, .rdf, .owl, etc.)
1870
+
1871
+ \b
1872
+ Examples:
1873
+ # Basic description
1874
+ rdf-construct describe ontology.ttl
1875
+
1876
+ # Brief summary only
1877
+ rdf-construct describe ontology.ttl --brief
1878
+
1879
+ # JSON output for programmatic use
1880
+ rdf-construct describe ontology.ttl --format json -o description.json
1881
+
1882
+ # Markdown for documentation
1883
+ rdf-construct describe ontology.ttl --format markdown -o DESCRIPTION.md
1884
+
1885
+ # Skip slow import resolution
1886
+ rdf-construct describe ontology.ttl --no-resolve
1887
+
1888
+ \b
1889
+ Exit codes:
1890
+ 0 - Success
1891
+ 1 - Success with warnings (unresolvable imports, etc.)
1892
+ 2 - Error (file not found, parse error)
1893
+ """
1894
+ from rdf_construct.describe import describe_file, format_description
1895
+
1896
+ try:
1897
+ click.echo(f"Analysing {file}...", err=True)
1898
+
1899
+ # Perform analysis
1900
+ description = describe_file(
1901
+ file,
1902
+ brief=brief,
1903
+ resolve_imports=not no_resolve,
1904
+ include_reasoning=reasoning,
1905
+ )
1906
+
1907
+ # Format output
1908
+ use_colour = not no_colour and output_format == "text" and output is None
1909
+ formatted = format_description(
1910
+ description,
1911
+ format_name=output_format,
1912
+ use_colour=use_colour,
1913
+ )
1914
+
1915
+ # Write output
1916
+ if output:
1917
+ output.parent.mkdir(parents=True, exist_ok=True)
1918
+ output.write_text(formatted)
1919
+ click.secho(f"✓ Wrote description to {output}", fg="green", err=True)
1920
+ else:
1921
+ click.echo(formatted)
1922
+
1923
+ # Exit code based on warnings
1924
+ if description.imports and description.imports.unresolvable_count > 0:
1925
+ sys.exit(1)
1926
+ else:
1927
+ sys.exit(0)
1928
+
1929
+ except FileNotFoundError as e:
1930
+ click.secho(f"Error: {e}", fg="red", err=True)
1931
+ sys.exit(2)
1932
+ except ValueError as e:
1933
+ click.secho(f"Error parsing RDF: {e}", fg="red", err=True)
1934
+ sys.exit(2)
1935
+ except Exception as e:
1936
+ click.secho(f"Error: {e}", fg="red", err=True)
1937
+ sys.exit(2)
1938
+
1939
+
1940
+ @cli.command()
1941
+ @click.argument("sources", nargs=-1, type=click.Path(exists=True, path_type=Path))
1942
+ @click.option(
1943
+ "--output",
1944
+ "-o",
1945
+ type=click.Path(path_type=Path),
1946
+ required=True,
1947
+ help="Output file for merged ontology",
1948
+ )
1949
+ @click.option(
1950
+ "--config",
1951
+ "-c",
1952
+ "config_file",
1953
+ type=click.Path(exists=True, path_type=Path),
1954
+ help="YAML configuration file",
1955
+ )
1956
+ @click.option(
1957
+ "--priority",
1958
+ "-p",
1959
+ multiple=True,
1960
+ type=int,
1961
+ help="Priority for each source (order matches sources)",
1962
+ )
1963
+ @click.option(
1964
+ "--strategy",
1965
+ type=click.Choice(["priority", "first", "last", "mark_all"], case_sensitive=False),
1966
+ default="priority",
1967
+ help="Conflict resolution strategy (default: priority)",
1968
+ )
1969
+ @click.option(
1970
+ "--report",
1971
+ "-r",
1972
+ type=click.Path(path_type=Path),
1973
+ help="Write conflict report to file",
1974
+ )
1975
+ @click.option(
1976
+ "--report-format",
1977
+ type=click.Choice(["text", "markdown", "md"], case_sensitive=False),
1978
+ default="markdown",
1979
+ help="Format for conflict report (default: markdown)",
1980
+ )
1981
+ @click.option(
1982
+ "--imports",
1983
+ type=click.Choice(["preserve", "remove", "merge"], case_sensitive=False),
1984
+ default="preserve",
1985
+ help="How to handle owl:imports (default: preserve)",
1986
+ )
1987
+ @click.option(
1988
+ "--migrate-data",
1989
+ multiple=True,
1990
+ type=click.Path(exists=True, path_type=Path),
1991
+ help="Data file(s) to migrate",
1992
+ )
1993
+ @click.option(
1994
+ "--migration-rules",
1995
+ type=click.Path(exists=True, path_type=Path),
1996
+ help="YAML file with migration rules",
1997
+ )
1998
+ @click.option(
1999
+ "--data-output",
2000
+ type=click.Path(path_type=Path),
2001
+ help="Output path for migrated data",
2002
+ )
2003
+ @click.option(
2004
+ "--dry-run",
2005
+ is_flag=True,
2006
+ help="Show what would happen without writing files",
2007
+ )
2008
+ @click.option(
2009
+ "--no-colour",
2010
+ is_flag=True,
2011
+ help="Disable coloured output",
2012
+ )
2013
+ @click.option(
2014
+ "--init",
2015
+ "init_config",
2016
+ is_flag=True,
2017
+ help="Generate a default merge configuration file",
2018
+ )
2019
+ def merge(
2020
+ sources: tuple[Path, ...],
2021
+ output: Path,
2022
+ config_file: Path | None,
2023
+ priority: tuple[int, ...],
2024
+ strategy: str,
2025
+ report: Path | None,
2026
+ report_format: str,
2027
+ imports: str,
2028
+ migrate_data: tuple[Path, ...],
2029
+ migration_rules: Path | None,
2030
+ data_output: Path | None,
2031
+ dry_run: bool,
2032
+ no_colour: bool,
2033
+ init_config: bool,
2034
+ ):
2035
+ """Merge multiple RDF ontology files.
2036
+
2037
+ Combines SOURCES into a single output ontology, detecting and handling
2038
+ conflicts between definitions.
2039
+
2040
+ \b
2041
+ SOURCES: One or more RDF files to merge (.ttl, .rdf, .owl)
2042
+
2043
+ \b
2044
+ Exit codes:
2045
+ 0 - Merge successful, no unresolved conflicts
2046
+ 1 - Merge successful, but unresolved conflicts marked in output
2047
+ 2 - Error (file not found, parse error, etc.)
2048
+
2049
+ \b
2050
+ Examples:
2051
+ # Basic merge of two files
2052
+ rdf-construct merge core.ttl ext.ttl -o merged.ttl
2053
+
2054
+ # With priorities (higher wins conflicts)
2055
+ rdf-construct merge core.ttl ext.ttl -o merged.ttl -p 1 -p 2
2056
+
2057
+ # Generate conflict report
2058
+ rdf-construct merge core.ttl ext.ttl -o merged.ttl --report conflicts.md
2059
+
2060
+ # Mark all conflicts for manual review
2061
+ rdf-construct merge core.ttl ext.ttl -o merged.ttl --strategy mark_all
2062
+
2063
+ # With data migration
2064
+ rdf-construct merge core.ttl ext.ttl -o merged.ttl \\
2065
+ --migrate-data split_instances.ttl --data-output migrated.ttl
2066
+
2067
+ # Use configuration file
2068
+ rdf-construct merge --config merge.yml -o merged.ttl
2069
+
2070
+ # Generate default config file
2071
+ rdf-construct merge --init
2072
+ """
2073
+ # Handle --init flag
2074
+ if init_config:
2075
+ config_path = Path("merge.yml")
2076
+ if config_path.exists():
2077
+ click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
2078
+ raise click.Abort()
2079
+
2080
+ config_content = create_default_config()
2081
+ config_path.write_text(config_content)
2082
+ click.secho(f"Created {config_path}", fg="green")
2083
+ click.echo("Edit this file to configure your merge, then run:")
2084
+ click.echo(f" rdf-construct merge --config {config_path} -o merged.ttl")
2085
+ return
2086
+
2087
+ # Validate we have sources
2088
+ if not sources and not config_file:
2089
+ click.secho("Error: No source files specified.", fg="red", err=True)
2090
+ click.echo("Provide source files or use --config with a configuration file.", err=True)
2091
+ raise click.Abort()
2092
+
2093
+ # Build configuration
2094
+ if config_file:
2095
+ try:
2096
+ config = load_merge_config(config_file)
2097
+ click.echo(f"Using config: {config_file}")
2098
+
2099
+ # Override output if provided on CLI
2100
+ if output:
2101
+ config.output = OutputConfig(path=output)
2102
+ except (FileNotFoundError, ValueError) as e:
2103
+ click.secho(f"Error loading config: {e}", fg="red", err=True)
2104
+ raise click.Abort()
2105
+ else:
2106
+ # Build config from CLI arguments
2107
+ priorities_list = list(priority) if priority else list(range(1, len(sources) + 1))
2108
+
2109
+ # Pad priorities if needed
2110
+ while len(priorities_list) < len(sources):
2111
+ priorities_list.append(len(priorities_list) + 1)
2112
+
2113
+ source_configs = [
2114
+ SourceConfig(path=p, priority=pri)
2115
+ for p, pri in zip(sources, priorities_list)
2116
+ ]
2117
+
2118
+ conflict_strategy = ConflictStrategy[strategy.upper()]
2119
+ imports_strategy = ImportsStrategy[imports.upper()]
2120
+
2121
+ # Data migration config
2122
+ data_migration = None
2123
+ if migrate_data:
2124
+ data_migration = DataMigrationConfig(
2125
+ data_sources=list(migrate_data),
2126
+ output_path=data_output,
2127
+ )
2128
+
2129
+ config = MergeConfig(
2130
+ sources=source_configs,
2131
+ output=OutputConfig(path=output),
2132
+ conflicts=ConflictConfig(
2133
+ strategy=conflict_strategy,
2134
+ report_path=report,
2135
+ ),
2136
+ imports=imports_strategy,
2137
+ migrate_data=data_migration,
2138
+ dry_run=dry_run,
2139
+ )
2140
+
2141
+ # Execute merge
2142
+ click.echo("Merging ontologies...")
2143
+
2144
+ merger = OntologyMerger(config)
2145
+ result = merger.merge()
2146
+
2147
+ if not result.success:
2148
+ click.secho(f"✗ Merge failed: {result.error}", fg="red", err=True)
2149
+ raise SystemExit(2)
2150
+
2151
+ # Display results
2152
+ use_colour = not no_colour
2153
+ text_formatter = get_formatter("text", use_colour=use_colour)
2154
+ click.echo(text_formatter.format_merge_result(result, result.merged_graph))
2155
+
2156
+ # Write output (unless dry run)
2157
+ if not dry_run and result.merged_graph and config.output:
2158
+ merger.write_output(result, config.output.path)
2159
+ click.echo()
2160
+ click.secho(f"✓ Wrote {config.output.path}", fg="green")
2161
+
2162
+ # Generate conflict report if requested
2163
+ if report and result.conflicts:
2164
+ report_formatter = get_formatter(report_format)
2165
+ report_content = report_formatter.format_conflict_report(
2166
+ result.conflicts, result.merged_graph
2167
+ )
2168
+ report.parent.mkdir(parents=True, exist_ok=True)
2169
+ report.write_text(report_content)
2170
+ click.echo(f" Conflict report: {report}")
2171
+
2172
+ # Handle data migration
2173
+ if config.migrate_data and config.migrate_data.data_sources:
2174
+ click.echo()
2175
+ click.echo("Migrating data...")
2176
+
2177
+ # Build URI map from any namespace remappings
2178
+ from rdf_construct.merge import DataMigrator
2179
+
2180
+ migrator = DataMigrator()
2181
+ uri_map: dict[URIRef, URIRef] = {}
2182
+
2183
+ # Collect namespace remaps from all sources
2184
+ for src in config.sources:
2185
+ if src.namespace_remap:
2186
+ for old_ns, new_ns in src.namespace_remap.items():
2187
+ # We'd need to scan data files to build complete map
2188
+ # For now, this is a placeholder
2189
+ pass
2190
+
2191
+ # Apply migration
2192
+ migration_result = migrate_data_files(
2193
+ data_paths=config.migrate_data.data_sources,
2194
+ uri_map=uri_map if uri_map else None,
2195
+ rules=config.migrate_data.rules if config.migrate_data.rules else None,
2196
+ output_path=config.migrate_data.output_path if not dry_run else None,
2197
+ )
2198
+
2199
+ if migration_result.success:
2200
+ click.echo(text_formatter.format_migration_result(migration_result))
2201
+ if config.migrate_data.output_path and not dry_run:
2202
+ click.secho(
2203
+ f"✓ Wrote migrated data to {config.migrate_data.output_path}",
2204
+ fg="green",
2205
+ )
2206
+ else:
2207
+ click.secho(
2208
+ f"✗ Data migration failed: {migration_result.error}",
2209
+ fg="red",
2210
+ err=True,
2211
+ )
2212
+
2213
+ # Exit code based on unresolved conflicts
2214
+ if result.unresolved_conflicts:
2215
+ click.echo()
2216
+ click.secho(
2217
+ f"⚠ {len(result.unresolved_conflicts)} unresolved conflict(s) "
2218
+ "marked in output",
2219
+ fg="yellow",
2220
+ )
2221
+ raise SystemExit(1)
2222
+ else:
2223
+ raise SystemExit(0)
2224
+
2225
+
2226
+ @cli.command()
2227
+ @click.argument("source", type=click.Path(exists=True, path_type=Path))
2228
+ @click.option(
2229
+ "--output",
2230
+ "-o",
2231
+ "output_dir",
2232
+ type=click.Path(path_type=Path),
2233
+ default=Path("modules"),
2234
+ help="Output directory for split modules (default: modules/)",
2235
+ )
2236
+ @click.option(
2237
+ "--config",
2238
+ "-c",
2239
+ "config_file",
2240
+ type=click.Path(exists=True, path_type=Path),
2241
+ help="YAML configuration file for split",
2242
+ )
2243
+ @click.option(
2244
+ "--by-namespace",
2245
+ is_flag=True,
2246
+ help="Automatically split by namespace (auto-detect modules)",
2247
+ )
2248
+ @click.option(
2249
+ "--migrate-data",
2250
+ multiple=True,
2251
+ type=click.Path(exists=True, path_type=Path),
2252
+ help="Data file(s) to split by instance type",
2253
+ )
2254
+ @click.option(
2255
+ "--data-output",
2256
+ type=click.Path(path_type=Path),
2257
+ help="Output directory for split data files",
2258
+ )
2259
+ @click.option(
2260
+ "--unmatched",
2261
+ type=click.Choice(["common", "error"], case_sensitive=False),
2262
+ default="common",
2263
+ help="Strategy for unmatched entities (default: common)",
2264
+ )
2265
+ @click.option(
2266
+ "--common-name",
2267
+ default="common",
2268
+ help="Name for common module (default: common)",
2269
+ )
2270
+ @click.option(
2271
+ "--no-manifest",
2272
+ is_flag=True,
2273
+ help="Don't generate manifest.yml",
2274
+ )
2275
+ @click.option(
2276
+ "--dry-run",
2277
+ is_flag=True,
2278
+ help="Show what would happen without writing files",
2279
+ )
2280
+ @click.option(
2281
+ "--no-colour",
2282
+ is_flag=True,
2283
+ help="Disable coloured output",
2284
+ )
2285
+ @click.option(
2286
+ "--init",
2287
+ "init_config",
2288
+ is_flag=True,
2289
+ help="Generate a default split configuration file",
2290
+ )
2291
+ def split(
2292
+ source: Path,
2293
+ output_dir: Path,
2294
+ config_file: Path | None,
2295
+ by_namespace: bool,
2296
+ migrate_data: tuple[Path, ...],
2297
+ data_output: Path | None,
2298
+ unmatched: str,
2299
+ common_name: str,
2300
+ no_manifest: bool,
2301
+ dry_run: bool,
2302
+ no_colour: bool,
2303
+ init_config: bool,
2304
+ ):
2305
+ """Split a monolithic ontology into multiple modules.
2306
+
2307
+ SOURCE: RDF ontology file to split (.ttl, .rdf, .owl)
2308
+
2309
+ \b
2310
+ Exit codes:
2311
+ 0 - Split successful
2312
+ 1 - Split successful with unmatched entities in common module
2313
+ 2 - Error (file not found, config invalid, etc.)
2314
+
2315
+ \b
2316
+ Examples:
2317
+ # Split by namespace (auto-detect modules)
2318
+ rdf-construct split large.ttl -o modules/ --by-namespace
2319
+
2320
+ # Split using configuration file
2321
+ rdf-construct split large.ttl -o modules/ -c split.yml
2322
+
2323
+ # With data migration
2324
+ rdf-construct split large.ttl -o modules/ -c split.yml \\
2325
+ --migrate-data split_instances.ttl --data-output data/
2326
+
2327
+ # Dry run - show what would be created
2328
+ rdf-construct split large.ttl -o modules/ --by-namespace --dry-run
2329
+
2330
+ # Generate default config file
2331
+ rdf-construct split --init
2332
+ """
2333
+ # Handle --init flag
2334
+ if init_config:
2335
+ config_path = Path("split.yml")
2336
+ if config_path.exists():
2337
+ click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
2338
+ raise click.Abort()
2339
+
2340
+ config_content = create_default_split_config()
2341
+ config_path.write_text(config_content)
2342
+ click.secho(f"Created {config_path}", fg="green")
2343
+ click.echo("Edit this file to configure your split, then run:")
2344
+ click.echo(f" rdf-construct split your-ontology.ttl -c {config_path}")
2345
+ return
2346
+
2347
+ # Validate we have a source
2348
+ if not source:
2349
+ click.secho("Error: SOURCE is required.", fg="red", err=True)
2350
+ raise click.Abort()
2351
+
2352
+ # Handle --by-namespace mode
2353
+ if by_namespace:
2354
+ click.echo(f"Splitting {source.name} by namespace...")
2355
+
2356
+ result = split_by_namespace(source, output_dir, dry_run=dry_run)
2357
+
2358
+ if not result.success:
2359
+ click.secho(f"✗ Split failed: {result.error}", fg="red", err=True)
2360
+ raise SystemExit(2)
2361
+
2362
+ _display_split_result(result, output_dir, dry_run, not no_colour)
2363
+ raise SystemExit(0 if not result.unmatched_entities else 1)
2364
+
2365
+ # Build configuration from file or CLI
2366
+ if config_file:
2367
+ try:
2368
+ config = SplitConfig.from_yaml(config_file)
2369
+ # Override source and output_dir if provided
2370
+ config.source = source
2371
+ config.output_dir = output_dir
2372
+ config.dry_run = dry_run
2373
+ config.generate_manifest = not no_manifest
2374
+ click.echo(f"Using config: {config_file}")
2375
+ except (FileNotFoundError, ValueError) as e:
2376
+ click.secho(f"Error loading config: {e}", fg="red", err=True)
2377
+ raise click.Abort()
2378
+ else:
2379
+ # Need either --by-namespace or --config
2380
+ if not by_namespace:
2381
+ click.secho(
2382
+ "Error: Specify either --by-namespace or --config.",
2383
+ fg="red",
2384
+ err=True,
2385
+ )
2386
+ click.echo("Use --by-namespace for auto-detection or -c for a config file.")
2387
+ click.echo("Run 'rdf-construct split --init' to generate a config template.")
2388
+ raise click.Abort()
2389
+
2390
+ # Build minimal config
2391
+ config = SplitConfig(
2392
+ source=source,
2393
+ output_dir=output_dir,
2394
+ modules=[],
2395
+ unmatched=UnmatchedStrategy(
2396
+ strategy=unmatched,
2397
+ common_module=common_name,
2398
+ common_output=f"{common_name}.ttl",
2399
+ ),
2400
+ generate_manifest=not no_manifest,
2401
+ dry_run=dry_run,
2402
+ )
2403
+
2404
+ # Add data migration config if specified
2405
+ if migrate_data:
2406
+ config.split_data = SplitDataConfig(
2407
+ sources=list(migrate_data),
2408
+ output_dir=data_output if data_output else output_dir,
2409
+ prefix="data_",
2410
+ )
2411
+
2412
+ # Override unmatched strategy if specified on CLI
2413
+ if unmatched:
2414
+ config.unmatched = UnmatchedStrategy(
2415
+ strategy=unmatched,
2416
+ common_module=common_name,
2417
+ common_output=f"{common_name}.ttl",
2418
+ )
2419
+
2420
+ # Execute split
2421
+ click.echo(f"Splitting {source.name}...")
2422
+
2423
+ splitter = OntologySplitter(config)
2424
+ result = splitter.split()
2425
+
2426
+ if not result.success:
2427
+ click.secho(f"✗ Split failed: {result.error}", fg="red", err=True)
2428
+ raise SystemExit(2)
2429
+
2430
+ # Write output (unless dry run)
2431
+ if not dry_run:
2432
+ splitter.write_modules(result)
2433
+ if config.generate_manifest:
2434
+ splitter.write_manifest(result)
2435
+
2436
+ _display_split_result(result, output_dir, dry_run, not no_colour)
2437
+
2438
+ # Exit code based on unmatched entities
2439
+ if result.unmatched_entities and config.unmatched.strategy == "common":
2440
+ click.echo()
2441
+ click.secho(
2442
+ f"⚠ {len(result.unmatched_entities)} unmatched entities placed in "
2443
+ f"{config.unmatched.common_module} module",
2444
+ fg="yellow",
2445
+ )
2446
+ raise SystemExit(1)
2447
+ else:
2448
+ raise SystemExit(0)
2449
+
2450
+
2451
+ def _display_split_result(
2452
+ result: "SplitResult",
2453
+ output_dir: Path,
2454
+ dry_run: bool,
2455
+ use_colour: bool,
2456
+ ) -> None:
2457
+ """Display split results to console.
2458
+
2459
+ Args:
2460
+ result: SplitResult from split operation.
2461
+ output_dir: Output directory.
2462
+ dry_run: Whether this was a dry run.
2463
+ use_colour: Whether to use coloured output.
2464
+ """
2465
+ # Header
2466
+ if dry_run:
2467
+ click.echo("\n[DRY RUN] Would create:")
2468
+ else:
2469
+ click.echo("\nSplit complete:")
2470
+
2471
+ # Module summary
2472
+ click.echo(f"\n Modules: {result.total_modules}")
2473
+ click.echo(f" Total triples: {result.total_triples}")
2474
+
2475
+ # Module details
2476
+ if result.module_stats:
2477
+ click.echo("\n Module breakdown:")
2478
+ for stats in result.module_stats:
2479
+ deps_str = ""
2480
+ if stats.dependencies:
2481
+ deps_str = f" (deps: {', '.join(stats.dependencies)})"
2482
+ click.echo(
2483
+ f" {stats.file}: {stats.classes} classes, "
2484
+ f"{stats.properties} properties, {stats.triples} triples{deps_str}"
2485
+ )
2486
+
2487
+ # Unmatched entities
2488
+ if result.unmatched_entities:
2489
+ click.echo(f"\n Unmatched entities: {len(result.unmatched_entities)}")
2490
+ # Show first few
2491
+ sample = list(result.unmatched_entities)[:5]
2492
+ for uri in sample:
2493
+ click.echo(f" - {uri}")
2494
+ if len(result.unmatched_entities) > 5:
2495
+ click.echo(f" ... and {len(result.unmatched_entities) - 5} more")
2496
+
2497
+ # Output location
2498
+ if not dry_run:
2499
+ click.echo()
2500
+ if use_colour:
2501
+ click.secho(f"✓ Wrote modules to {output_dir}/", fg="green")
2502
+ else:
2503
+ click.echo(f"✓ Wrote modules to {output_dir}/")
2504
+
2505
+
2506
+ # Refactor command group
2507
+ @cli.group()
2508
+ def refactor():
2509
+ """Refactor ontologies: rename URIs and deprecate entities.
2510
+
2511
+ \b
2512
+ Subcommands:
2513
+ rename Rename URIs (single entity or bulk namespace)
2514
+ deprecate Mark entities as deprecated
2515
+
2516
+ \b
2517
+ Examples:
2518
+ # Fix a typo
2519
+ rdf-construct refactor rename ont.ttl --from ex:Buiding --to ex:Building -o fixed.ttl
2520
+
2521
+ # Bulk namespace change
2522
+ rdf-construct refactor rename ont.ttl \\
2523
+ --from-namespace http://old/ --to-namespace http://new/ -o migrated.ttl
2524
+
2525
+ # Deprecate entity with replacement
2526
+ rdf-construct refactor deprecate ont.ttl \\
2527
+ --entity ex:OldClass --replaced-by ex:NewClass \\
2528
+ --message "Use NewClass instead." -o updated.ttl
2529
+ """
2530
+ pass
2531
+
2532
+
2533
+ @refactor.command("rename")
2534
+ @click.argument("sources", nargs=-1, type=click.Path(exists=True, path_type=Path))
2535
+ @click.option(
2536
+ "-o", "--output",
2537
+ type=click.Path(path_type=Path),
2538
+ help="Output file (for single source) or directory (for multiple sources).",
2539
+ )
2540
+ @click.option(
2541
+ "--from", "from_uri",
2542
+ help="Single URI to rename (use with --to).",
2543
+ )
2544
+ @click.option(
2545
+ "--to", "to_uri",
2546
+ help="New URI for single rename (use with --from).",
2547
+ )
2548
+ @click.option(
2549
+ "--from-namespace",
2550
+ help="Old namespace prefix for bulk rename.",
2551
+ )
2552
+ @click.option(
2553
+ "--to-namespace",
2554
+ help="New namespace prefix for bulk rename.",
2555
+ )
2556
+ @click.option(
2557
+ "-c", "--config",
2558
+ "config_file",
2559
+ type=click.Path(exists=True, path_type=Path),
2560
+ help="YAML configuration file with rename mappings.",
2561
+ )
2562
+ @click.option(
2563
+ "--migrate-data",
2564
+ multiple=True,
2565
+ type=click.Path(exists=True, path_type=Path),
2566
+ help="Data files to migrate (can be repeated).",
2567
+ )
2568
+ @click.option(
2569
+ "--data-output",
2570
+ type=click.Path(path_type=Path),
2571
+ help="Output path for migrated data.",
2572
+ )
2573
+ @click.option(
2574
+ "--dry-run",
2575
+ is_flag=True,
2576
+ help="Preview changes without writing files.",
2577
+ )
2578
+ @click.option(
2579
+ "--no-colour", "--no-color",
2580
+ is_flag=True,
2581
+ help="Disable coloured output.",
2582
+ )
2583
+ @click.option(
2584
+ "--init",
2585
+ "init_config",
2586
+ is_flag=True,
2587
+ help="Generate a template rename configuration file.",
2588
+ )
2589
+ def refactor_rename(
2590
+ sources: tuple[Path, ...],
2591
+ output: Path | None,
2592
+ from_uri: str | None,
2593
+ to_uri: str | None,
2594
+ from_namespace: str | None,
2595
+ to_namespace: str | None,
2596
+ config_file: Path | None,
2597
+ migrate_data: tuple[Path, ...],
2598
+ data_output: Path | None,
2599
+ dry_run: bool,
2600
+ no_colour: bool,
2601
+ init_config: bool,
2602
+ ):
2603
+ """Rename URIs in ontology files.
2604
+
2605
+ Supports single entity renames (fixing typos) and bulk namespace changes
2606
+ (project migrations). The renamer updates subject, predicate, and object
2607
+ positions but intentionally leaves literal values unchanged.
2608
+
2609
+ \b
2610
+ SOURCES: One or more RDF files to process (.ttl, .rdf, .owl)
2611
+
2612
+ \b
2613
+ Exit codes:
2614
+ 0 - Success
2615
+ 1 - Success with warnings (some URIs not found)
2616
+ 2 - Error (file not found, parse error, etc.)
2617
+
2618
+ \b
2619
+ Examples:
2620
+ # Fix a single typo
2621
+ rdf-construct refactor rename ontology.ttl \\
2622
+ --from "http://example.org/ont#Buiding" \\
2623
+ --to "http://example.org/ont#Building" \\
2624
+ -o fixed.ttl
2625
+
2626
+ # Bulk namespace change
2627
+ rdf-construct refactor rename ontology.ttl \\
2628
+ --from-namespace "http://old.example.org/" \\
2629
+ --to-namespace "http://new.example.org/" \\
2630
+ -o migrated.ttl
2631
+
2632
+ # With data migration
2633
+ rdf-construct refactor rename ontology.ttl \\
2634
+ --from "ex:OldClass" --to "ex:NewClass" \\
2635
+ --migrate-data instances.ttl \\
2636
+ --data-output updated-instances.ttl
2637
+
2638
+ # From configuration file
2639
+ rdf-construct refactor rename --config renames.yml
2640
+
2641
+ # Preview changes (dry run)
2642
+ rdf-construct refactor rename ontology.ttl \\
2643
+ --from "ex:Old" --to "ex:New" --dry-run
2644
+
2645
+ # Process multiple files
2646
+ rdf-construct refactor rename modules/*.ttl \\
2647
+ --from-namespace "http://old/" --to-namespace "http://new/" \\
2648
+ -o migrated/
2649
+
2650
+ # Generate template config
2651
+ rdf-construct refactor rename --init
2652
+ """
2653
+ # Handle --init flag
2654
+ if init_config:
2655
+ config_path = Path("refactor_rename.yml")
2656
+ if config_path.exists():
2657
+ click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
2658
+ raise click.Abort()
2659
+
2660
+ config_content = create_default_rename_config()
2661
+ config_path.write_text(config_content)
2662
+ click.secho(f"Created {config_path}", fg="green")
2663
+ click.echo("Edit this file to configure your renames, then run:")
2664
+ click.echo(f" rdf-construct refactor rename --config {config_path}")
2665
+ return
2666
+
2667
+ # Validate input options
2668
+ if not sources and not config_file:
2669
+ click.secho("Error: No source files specified.", fg="red", err=True)
2670
+ click.echo("Provide source files or use --config with a configuration file.", err=True)
2671
+ raise click.Abort()
2672
+
2673
+ # Validate rename options
2674
+ if from_uri and not to_uri:
2675
+ click.secho("Error: --from requires --to", fg="red", err=True)
2676
+ raise click.Abort()
2677
+ if to_uri and not from_uri:
2678
+ click.secho("Error: --to requires --from", fg="red", err=True)
2679
+ raise click.Abort()
2680
+ if from_namespace and not to_namespace:
2681
+ click.secho("Error: --from-namespace requires --to-namespace", fg="red", err=True)
2682
+ raise click.Abort()
2683
+ if to_namespace and not from_namespace:
2684
+ click.secho("Error: --to-namespace requires --from-namespace", fg="red", err=True)
2685
+ raise click.Abort()
2686
+
2687
+ # Build configuration
2688
+ if config_file:
2689
+ try:
2690
+ config = load_refactor_config(config_file)
2691
+ click.echo(f"Using config: {config_file}")
2692
+
2693
+ # Override output if provided on CLI
2694
+ if output:
2695
+ if len(sources) > 1 or (config.source_files and len(config.source_files) > 1):
2696
+ config.output_dir = output
2697
+ else:
2698
+ config.output = output
2699
+
2700
+ # Override sources if provided on CLI
2701
+ if sources:
2702
+ config.source_files = list(sources)
2703
+ except (FileNotFoundError, ValueError) as e:
2704
+ click.secho(f"Error loading config: {e}", fg="red", err=True)
2705
+ raise click.Abort()
2706
+ else:
2707
+ # Build config from CLI arguments
2708
+ rename_config = RenameConfig()
2709
+
2710
+ if from_namespace and to_namespace:
2711
+ rename_config.namespaces[from_namespace] = to_namespace
2712
+
2713
+ if from_uri and to_uri:
2714
+ # Expand CURIEs if needed
2715
+ rename_config.entities[from_uri] = to_uri
2716
+
2717
+ config = RefactorConfig(
2718
+ rename=rename_config,
2719
+ source_files=list(sources),
2720
+ output=output if len(sources) == 1 else None,
2721
+ output_dir=output if len(sources) > 1 else None,
2722
+ dry_run=dry_run,
2723
+ )
2724
+
2725
+ # Validate we have something to rename
2726
+ if config.rename is None or (not config.rename.namespaces and not config.rename.entities):
2727
+ click.secho(
2728
+ "Error: No renames specified. Use --from/--to, --from-namespace/--to-namespace, "
2729
+ "or provide a config file.",
2730
+ fg="red",
2731
+ err=True,
2732
+ )
2733
+ raise click.Abort()
2734
+
2735
+ # Execute rename
2736
+ formatter = RefactorTextFormatter(use_colour=not no_colour)
2737
+ renamer = OntologyRenamer()
2738
+
2739
+ for source_path in config.source_files:
2740
+ click.echo(f"\nProcessing: {source_path}")
2741
+
2742
+ # Load source graph
2743
+ graph = Graph()
2744
+ try:
2745
+ graph.parse(source_path.as_posix())
2746
+ except Exception as e:
2747
+ click.secho(f"✗ Failed to parse: {e}", fg="red", err=True)
2748
+ raise SystemExit(2)
2749
+
2750
+ # Build mappings for preview
2751
+ mappings = config.rename.build_mappings(graph)
2752
+
2753
+ if dry_run:
2754
+ # Show preview
2755
+ click.echo()
2756
+ click.echo(
2757
+ formatter.format_rename_preview(
2758
+ mappings=mappings,
2759
+ source_file=source_path.name,
2760
+ source_triples=len(graph),
2761
+ )
2762
+ )
2763
+ else:
2764
+ # Perform rename
2765
+ result = renamer.rename(graph, config.rename)
2766
+
2767
+ if not result.success:
2768
+ click.secho(f"✗ Rename failed: {result.error}", fg="red", err=True)
2769
+ raise SystemExit(2)
2770
+
2771
+ # Show result
2772
+ click.echo(formatter.format_rename_result(result))
2773
+
2774
+ # Write output
2775
+ if result.renamed_graph:
2776
+ out_path = config.output or (config.output_dir / source_path.name if config.output_dir else None)
2777
+ if out_path:
2778
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2779
+ result.renamed_graph.serialize(destination=out_path.as_posix(), format="turtle")
2780
+ click.secho(f"✓ Wrote {out_path}", fg="green")
2781
+
2782
+ # Handle data migration
2783
+ if migrate_data and not dry_run:
2784
+ click.echo("\nMigrating data...")
2785
+
2786
+ # Build URI map from rename config
2787
+ combined_graph = Graph()
2788
+ for source_path in config.source_files:
2789
+ combined_graph.parse(source_path.as_posix())
2790
+
2791
+ uri_map = {}
2792
+ for mapping in config.rename.build_mappings(combined_graph):
2793
+ uri_map[mapping.from_uri] = mapping.to_uri
2794
+
2795
+ if uri_map:
2796
+ migrator = DataMigrator()
2797
+ for data_path in migrate_data:
2798
+ data_graph = Graph()
2799
+ try:
2800
+ data_graph.parse(data_path.as_posix())
2801
+ except Exception as e:
2802
+ click.secho(f"✗ Failed to parse data file {data_path}: {e}", fg="red", err=True)
2803
+ continue
2804
+
2805
+ migration_result = migrator.migrate(data_graph, uri_map=uri_map)
2806
+
2807
+ if migration_result.success and migration_result.migrated_graph:
2808
+ # Determine output path
2809
+ if data_output and len(migrate_data) == 1:
2810
+ out_path = data_output
2811
+ elif data_output:
2812
+ out_path = data_output / data_path.name
2813
+ else:
2814
+ out_path = data_path.parent / f"migrated_{data_path.name}"
2815
+
2816
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2817
+ migration_result.migrated_graph.serialize(
2818
+ destination=out_path.as_posix(), format="turtle"
2819
+ )
2820
+ click.echo(f" Migrated {data_path.name}: {migration_result.stats.total_changes} changes")
2821
+ click.secho(f" ✓ Wrote {out_path}", fg="green")
2822
+
2823
+ raise SystemExit(0)
2824
+
2825
+
2826
+ @refactor.command("deprecate")
2827
+ @click.argument("sources", nargs=-1, type=click.Path(exists=True, path_type=Path))
2828
+ @click.option(
2829
+ "-o", "--output",
2830
+ type=click.Path(path_type=Path),
2831
+ help="Output file.",
2832
+ )
2833
+ @click.option(
2834
+ "--entity",
2835
+ help="URI of entity to deprecate.",
2836
+ )
2837
+ @click.option(
2838
+ "--replaced-by",
2839
+ help="URI of replacement entity (adds dcterms:isReplacedBy).",
2840
+ )
2841
+ @click.option(
2842
+ "--message", "-m",
2843
+ help="Deprecation message (added to rdfs:comment).",
2844
+ )
2845
+ @click.option(
2846
+ "--version",
2847
+ help="Version when deprecated (included in message).",
2848
+ )
2849
+ @click.option(
2850
+ "-c", "--config",
2851
+ "config_file",
2852
+ type=click.Path(exists=True, path_type=Path),
2853
+ help="YAML configuration file with deprecation specs.",
2854
+ )
2855
+ @click.option(
2856
+ "--dry-run",
2857
+ is_flag=True,
2858
+ help="Preview changes without writing files.",
2859
+ )
2860
+ @click.option(
2861
+ "--no-colour", "--no-color",
2862
+ is_flag=True,
2863
+ help="Disable coloured output.",
2864
+ )
2865
+ @click.option(
2866
+ "--init",
2867
+ "init_config",
2868
+ is_flag=True,
2869
+ help="Generate a template deprecation configuration file.",
2870
+ )
2871
+ def refactor_deprecate(
2872
+ sources: tuple[Path, ...],
2873
+ output: Path | None,
2874
+ entity: str | None,
2875
+ replaced_by: str | None,
2876
+ message: str | None,
2877
+ version: str | None,
2878
+ config_file: Path | None,
2879
+ dry_run: bool,
2880
+ no_colour: bool,
2881
+ init_config: bool,
2882
+ ):
2883
+ """Mark ontology entities as deprecated.
2884
+
2885
+ Adds standard deprecation annotations:
2886
+ - owl:deprecated true
2887
+ - dcterms:isReplacedBy (if replacement specified)
2888
+ - rdfs:comment with "DEPRECATED: ..." message
2889
+
2890
+ Deprecation marks entities but does NOT rename or migrate references.
2891
+ Use 'refactor rename' to actually migrate references after deprecation.
2892
+
2893
+ \b
2894
+ SOURCES: One or more RDF files to process (.ttl, .rdf, .owl)
2895
+
2896
+ \b
2897
+ Exit codes:
2898
+ 0 - Success
2899
+ 1 - Success with warnings (some entities not found)
2900
+ 2 - Error (file not found, parse error, etc.)
2901
+
2902
+ \b
2903
+ Examples:
2904
+ # Deprecate with replacement
2905
+ rdf-construct refactor deprecate ontology.ttl \\
2906
+ --entity "http://example.org/ont#LegacyTerm" \\
2907
+ --replaced-by "http://example.org/ont#NewTerm" \\
2908
+ --message "Use NewTerm instead. Will be removed in v3.0." \\
2909
+ -o updated.ttl
2910
+
2911
+ # Deprecate without replacement
2912
+ rdf-construct refactor deprecate ontology.ttl \\
2913
+ --entity "ex:ObsoleteThing" \\
2914
+ --message "No longer needed. Will be removed in v3.0." \\
2915
+ -o updated.ttl
2916
+
2917
+ # Bulk deprecation from config
2918
+ rdf-construct refactor deprecate ontology.ttl \\
2919
+ -c deprecations.yml \\
2920
+ -o updated.ttl
2921
+
2922
+ # Preview changes (dry run)
2923
+ rdf-construct refactor deprecate ontology.ttl \\
2924
+ --entity "ex:Legacy" --replaced-by "ex:Modern" --dry-run
2925
+
2926
+ # Generate template config
2927
+ rdf-construct refactor deprecate --init
2928
+ """
2929
+ # Handle --init flag
2930
+ if init_config:
2931
+ config_path = Path("refactor_deprecate.yml")
2932
+ if config_path.exists():
2933
+ click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
2934
+ raise click.Abort()
2935
+
2936
+ config_content = create_default_deprecation_config()
2937
+ config_path.write_text(config_content)
2938
+ click.secho(f"Created {config_path}", fg="green")
2939
+ click.echo("Edit this file to configure your deprecations, then run:")
2940
+ click.echo(f" rdf-construct refactor deprecate --config {config_path}")
2941
+ return
2942
+
2943
+ # Validate input options
2944
+ if not sources and not config_file:
2945
+ click.secho("Error: No source files specified.", fg="red", err=True)
2946
+ click.echo("Provide source files or use --config with a configuration file.", err=True)
2947
+ raise click.Abort()
2948
+
2949
+ # Build configuration
2950
+ if config_file:
2951
+ try:
2952
+ config = load_refactor_config(config_file)
2953
+ click.echo(f"Using config: {config_file}")
2954
+
2955
+ # Override output if provided on CLI
2956
+ if output:
2957
+ config.output = output
2958
+
2959
+ # Override sources if provided on CLI
2960
+ if sources:
2961
+ config.source_files = list(sources)
2962
+ except (FileNotFoundError, ValueError) as e:
2963
+ click.secho(f"Error loading config: {e}", fg="red", err=True)
2964
+ raise click.Abort()
2965
+ else:
2966
+ # Build config from CLI arguments
2967
+ if not entity:
2968
+ click.secho(
2969
+ "Error: --entity is required when not using a config file.",
2970
+ fg="red",
2971
+ err=True,
2972
+ )
2973
+ raise click.Abort()
2974
+
2975
+ spec = DeprecationSpec(
2976
+ entity=entity,
2977
+ replaced_by=replaced_by,
2978
+ message=message,
2979
+ version=version,
2980
+ )
2981
+
2982
+ config = RefactorConfig(
2983
+ deprecations=[spec],
2984
+ source_files=list(sources),
2985
+ output=output,
2986
+ dry_run=dry_run,
2987
+ )
2988
+
2989
+ # Validate we have something to deprecate
2990
+ if not config.deprecations:
2991
+ click.secho(
2992
+ "Error: No deprecations specified. Use --entity or provide a config file.",
2993
+ fg="red",
2994
+ err=True,
2995
+ )
2996
+ raise click.Abort()
2997
+
2998
+ # Execute deprecation
2999
+ formatter = RefactorTextFormatter(use_colour=not no_colour)
3000
+ deprecator = OntologyDeprecator()
3001
+
3002
+ for source_path in config.source_files:
3003
+ click.echo(f"\nProcessing: {source_path}")
3004
+
3005
+ # Load source graph
3006
+ graph = Graph()
3007
+ try:
3008
+ graph.parse(source_path.as_posix())
3009
+ except Exception as e:
3010
+ click.secho(f"✗ Failed to parse: {e}", fg="red", err=True)
3011
+ raise SystemExit(2)
3012
+
3013
+ if dry_run:
3014
+ # Perform dry run to get entity info
3015
+ temp_graph = Graph()
3016
+ for triple in graph:
3017
+ temp_graph.add(triple)
3018
+
3019
+ result = deprecator.deprecate_bulk(temp_graph, config.deprecations)
3020
+
3021
+ # Show preview
3022
+ click.echo()
3023
+ click.echo(
3024
+ formatter.format_deprecation_preview(
3025
+ specs=config.deprecations,
3026
+ entity_info=result.entity_info,
3027
+ source_file=source_path.name,
3028
+ source_triples=len(graph),
3029
+ )
3030
+ )
3031
+ else:
3032
+ # Perform deprecation
3033
+ result = deprecator.deprecate_bulk(graph, config.deprecations)
3034
+
3035
+ if not result.success:
3036
+ click.secho(f"✗ Deprecation failed: {result.error}", fg="red", err=True)
3037
+ raise SystemExit(2)
3038
+
3039
+ # Show result
3040
+ click.echo(formatter.format_deprecation_result(result))
3041
+
3042
+ # Write output
3043
+ if result.deprecated_graph:
3044
+ out_path = config.output or source_path.with_stem(f"{source_path.stem}_deprecated")
3045
+ out_path.parent.mkdir(parents=True, exist_ok=True)
3046
+ result.deprecated_graph.serialize(destination=out_path.as_posix(), format="turtle")
3047
+ click.secho(f"✓ Wrote {out_path}", fg="green")
3048
+
3049
+ # Warn about entities not found
3050
+ if result.stats.entities_not_found > 0:
3051
+ click.secho(
3052
+ f"\n⚠ {result.stats.entities_not_found} entity/entities not found in graph",
3053
+ fg="yellow",
3054
+ )
3055
+ raise SystemExit(1)
3056
+
3057
+ raise SystemExit(0)
3058
+
3059
+
3060
+ @cli.group()
3061
+ def localise():
3062
+ """Multi-language translation management.
3063
+
3064
+ Extract translatable strings, merge translations, and track coverage.
3065
+
3066
+ \b
3067
+ Commands:
3068
+ extract Extract strings for translation
3069
+ merge Merge translations back into ontology
3070
+ report Generate translation coverage report
3071
+ init Create empty translation file for new language
3072
+
3073
+ \b
3074
+ Examples:
3075
+ # Extract strings for German translation
3076
+ rdf-construct localise extract ontology.ttl --language de -o translations/de.yml
3077
+
3078
+ # Merge completed translations
3079
+ rdf-construct localise merge ontology.ttl translations/de.yml -o localised.ttl
3080
+
3081
+ # Check translation coverage
3082
+ rdf-construct localise report ontology.ttl --languages en,de,fr
3083
+ """
3084
+ pass
3085
+
3086
+
3087
+ @localise.command("extract")
3088
+ @click.argument("source", type=click.Path(exists=True, path_type=Path))
3089
+ @click.option(
3090
+ "--language",
3091
+ "-l",
3092
+ "target_language",
3093
+ required=True,
3094
+ help="Target language code (e.g., de, fr, es)",
3095
+ )
3096
+ @click.option(
3097
+ "--output",
3098
+ "-o",
3099
+ type=click.Path(path_type=Path),
3100
+ help="Output YAML file (default: {language}.yml)",
3101
+ )
3102
+ @click.option(
3103
+ "--source-language",
3104
+ default="en",
3105
+ help="Source language code (default: en)",
3106
+ )
3107
+ @click.option(
3108
+ "--properties",
3109
+ "-p",
3110
+ help="Comma-separated properties to extract (e.g., rdfs:label,rdfs:comment)",
3111
+ )
3112
+ @click.option(
3113
+ "--include-deprecated",
3114
+ is_flag=True,
3115
+ help="Include deprecated entities",
3116
+ )
3117
+ @click.option(
3118
+ "--missing-only",
3119
+ is_flag=True,
3120
+ help="Only extract strings missing in target language",
3121
+ )
3122
+ @click.option(
3123
+ "--config",
3124
+ "-c",
3125
+ "config_file",
3126
+ type=click.Path(exists=True, path_type=Path),
3127
+ help="YAML configuration file",
3128
+ )
3129
+ def localise_extract(
3130
+ source: Path,
3131
+ target_language: str,
3132
+ output: Path | None,
3133
+ source_language: str,
3134
+ properties: str | None,
3135
+ include_deprecated: bool,
3136
+ missing_only: bool,
3137
+ config_file: Path | None,
3138
+ ):
3139
+ """Extract translatable strings from an ontology.
3140
+
3141
+ Generates a YAML file with source text and empty translation fields,
3142
+ ready to be filled in by translators.
3143
+
3144
+ \b
3145
+ Examples:
3146
+ # Basic extraction
3147
+ rdf-construct localise extract ontology.ttl --language de -o de.yml
3148
+
3149
+ # Extract only labels
3150
+ rdf-construct localise extract ontology.ttl -l de -p rdfs:label
3151
+
3152
+ # Extract missing strings only (for updates)
3153
+ rdf-construct localise extract ontology.ttl -l de --missing-only -o de_update.yml
3154
+ """
3155
+ from rdflib import Graph
3156
+ from rdf_construct.localise import (
3157
+ StringExtractor,
3158
+ ExtractConfig,
3159
+ get_formatter as get_localise_formatter,
3160
+ )
3161
+
3162
+ # Load config if provided
3163
+ if config_file:
3164
+ from rdf_construct.localise import load_localise_config
3165
+ config = load_localise_config(config_file)
3166
+ extract_config = config.extract
3167
+ extract_config.target_language = target_language
3168
+ else:
3169
+ # Build config from CLI args
3170
+ prop_list = None
3171
+ if properties:
3172
+ prop_list = [_expand_localise_property(p.strip()) for p in properties.split(",")]
3173
+
3174
+ extract_config = ExtractConfig(
3175
+ source_language=source_language,
3176
+ target_language=target_language,
3177
+ properties=prop_list or ExtractConfig().properties,
3178
+ include_deprecated=include_deprecated,
3179
+ missing_only=missing_only,
3180
+ )
3181
+
3182
+ # Load graph
3183
+ click.echo(f"Loading {source}...")
3184
+ graph = Graph()
3185
+ graph.parse(source)
3186
+
3187
+ # Extract
3188
+ click.echo(f"Extracting strings for {target_language}...")
3189
+ extractor = StringExtractor(extract_config)
3190
+ result = extractor.extract(graph, source, target_language)
3191
+
3192
+ # Display result
3193
+ formatter = get_localise_formatter("text")
3194
+ click.echo(formatter.format_extraction_result(result))
3195
+
3196
+ if not result.success:
3197
+ raise SystemExit(2)
3198
+
3199
+ # Save output
3200
+ output_path = output or Path(f"{target_language}.yml")
3201
+ if result.translation_file:
3202
+ result.translation_file.save(output_path)
3203
+ click.echo()
3204
+ click.secho(f"✓ Wrote {output_path}", fg="green")
3205
+
3206
+ raise SystemExit(0)
3207
+
3208
+
3209
+ @localise.command("merge")
3210
+ @click.argument("source", type=click.Path(exists=True, path_type=Path))
3211
+ @click.argument("translations", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
3212
+ @click.option(
3213
+ "--output",
3214
+ "-o",
3215
+ type=click.Path(path_type=Path),
3216
+ required=True,
3217
+ help="Output file for merged ontology",
3218
+ )
3219
+ @click.option(
3220
+ "--status",
3221
+ type=click.Choice(["pending", "needs_review", "translated", "approved"], case_sensitive=False),
3222
+ default="translated",
3223
+ help="Minimum status to include (default: translated)",
3224
+ )
3225
+ @click.option(
3226
+ "--existing",
3227
+ type=click.Choice(["preserve", "overwrite"], case_sensitive=False),
3228
+ default="preserve",
3229
+ help="How to handle existing translations (default: preserve)",
3230
+ )
3231
+ @click.option(
3232
+ "--dry-run",
3233
+ is_flag=True,
3234
+ help="Show what would happen without writing files",
3235
+ )
3236
+ @click.option(
3237
+ "--no-colour",
3238
+ is_flag=True,
3239
+ help="Disable coloured output",
3240
+ )
3241
+ def localise_merge(
3242
+ source: Path,
3243
+ translations: tuple[Path, ...],
3244
+ output: Path,
3245
+ status: str,
3246
+ existing: str,
3247
+ dry_run: bool,
3248
+ no_colour: bool,
3249
+ ):
3250
+ """Merge translation files back into an ontology.
3251
+
3252
+ Takes completed YAML translation files and adds the translations
3253
+ as new language-tagged literals to the ontology.
3254
+
3255
+ \b
3256
+ Examples:
3257
+ # Merge single translation file
3258
+ rdf-construct localise merge ontology.ttl de.yml -o localised.ttl
3259
+
3260
+ # Merge multiple languages
3261
+ rdf-construct localise merge ontology.ttl translations/*.yml -o multilingual.ttl
3262
+
3263
+ # Merge only approved translations
3264
+ rdf-construct localise merge ontology.ttl de.yml --status approved -o localised.ttl
3265
+ """
3266
+ from rdflib import Graph
3267
+ from rdf_construct.localise import (
3268
+ TranslationMerger,
3269
+ TranslationFile,
3270
+ MergeConfig as LocaliseMergeConfig,
3271
+ TranslationStatus,
3272
+ ExistingStrategy,
3273
+ get_formatter as get_localise_formatter,
3274
+ )
3275
+
3276
+ # Load graph
3277
+ click.echo(f"Loading {source}...")
3278
+ graph = Graph()
3279
+ graph.parse(source)
3280
+
3281
+ # Load translation files
3282
+ click.echo(f"Loading {len(translations)} translation file(s)...")
3283
+ trans_files = [TranslationFile.from_yaml(p) for p in translations]
3284
+
3285
+ # Build config
3286
+ config = LocaliseMergeConfig(
3287
+ min_status=TranslationStatus(status),
3288
+ existing=ExistingStrategy(existing),
3289
+ )
3290
+
3291
+ # Merge
3292
+ click.echo("Merging translations...")
3293
+ merger = TranslationMerger(config)
3294
+ result = merger.merge_multiple(graph, trans_files)
3295
+
3296
+ # Display result
3297
+ formatter = get_localise_formatter("text", use_colour=not no_colour)
3298
+ click.echo(formatter.format_merge_result(result))
3299
+
3300
+ if not result.success:
3301
+ raise SystemExit(2)
3302
+
3303
+ # Save output (unless dry run)
3304
+ if not dry_run and result.merged_graph:
3305
+ output.parent.mkdir(parents=True, exist_ok=True)
3306
+ result.merged_graph.serialize(destination=output, format="turtle")
3307
+ click.echo()
3308
+ click.secho(f"✓ Wrote {output}", fg="green")
3309
+
3310
+ # Exit code based on warnings
3311
+ if result.stats.errors > 0:
3312
+ raise SystemExit(1)
3313
+ else:
3314
+ raise SystemExit(0)
3315
+
3316
+
3317
+ @localise.command("report")
3318
+ @click.argument("source", type=click.Path(exists=True, path_type=Path))
3319
+ @click.option(
3320
+ "--languages",
3321
+ "-l",
3322
+ required=True,
3323
+ help="Comma-separated language codes to check (e.g., en,de,fr)",
3324
+ )
3325
+ @click.option(
3326
+ "--source-language",
3327
+ default="en",
3328
+ help="Base language for translations (default: en)",
3329
+ )
3330
+ @click.option(
3331
+ "--properties",
3332
+ "-p",
3333
+ help="Comma-separated properties to check",
3334
+ )
3335
+ @click.option(
3336
+ "--output",
3337
+ "-o",
3338
+ type=click.Path(path_type=Path),
3339
+ help="Output file for report",
3340
+ )
3341
+ @click.option(
3342
+ "--format",
3343
+ "-f",
3344
+ "output_format",
3345
+ type=click.Choice(["text", "markdown", "md"], case_sensitive=False),
3346
+ default="text",
3347
+ help="Output format (default: text)",
3348
+ )
3349
+ @click.option(
3350
+ "--verbose",
3351
+ "-v",
3352
+ is_flag=True,
3353
+ help="Show detailed missing translation list",
3354
+ )
3355
+ @click.option(
3356
+ "--no-colour",
3357
+ is_flag=True,
3358
+ help="Disable coloured output",
3359
+ )
3360
+ def localise_report(
3361
+ source: Path,
3362
+ languages: str,
3363
+ source_language: str,
3364
+ properties: str | None,
3365
+ output: Path | None,
3366
+ output_format: str,
3367
+ verbose: bool,
3368
+ no_colour: bool,
3369
+ ):
3370
+ """Generate translation coverage report.
3371
+
3372
+ Analyses an ontology and reports what percentage of translatable
3373
+ content has been translated into each target language.
3374
+
3375
+ \b
3376
+ Examples:
3377
+ # Basic coverage report
3378
+ rdf-construct localise report ontology.ttl --languages en,de,fr
3379
+
3380
+ # Detailed report with missing entities
3381
+ rdf-construct localise report ontology.ttl -l en,de,fr --verbose
3382
+
3383
+ # Markdown report to file
3384
+ rdf-construct localise report ontology.ttl -l en,de,fr -f markdown -o coverage.md
3385
+ """
3386
+ from rdflib import Graph
3387
+ from rdf_construct.localise import (
3388
+ CoverageReporter,
3389
+ get_formatter as get_localise_formatter,
3390
+ )
3391
+
3392
+ # Parse languages
3393
+ lang_list = [lang.strip() for lang in languages.split(",")]
3394
+
3395
+ # Parse properties
3396
+ prop_list = None
3397
+ if properties:
3398
+ prop_list = [_expand_localise_property(p.strip()) for p in properties.split(",")]
3399
+
3400
+ # Load graph
3401
+ click.echo(f"Loading {source}...")
3402
+ graph = Graph()
3403
+ graph.parse(source)
3404
+
3405
+ # Generate report
3406
+ click.echo("Analysing translation coverage...")
3407
+ reporter = CoverageReporter(
3408
+ source_language=source_language,
3409
+ properties=prop_list,
3410
+ )
3411
+ report = reporter.report(graph, lang_list, source)
3412
+
3413
+ # Format output
3414
+ formatter = get_localise_formatter(output_format, use_colour=not no_colour)
3415
+ report_text = formatter.format_coverage_report(report, verbose=verbose)
3416
+
3417
+ # Output
3418
+ if output:
3419
+ output.parent.mkdir(parents=True, exist_ok=True)
3420
+ output.write_text(report_text)
3421
+ click.secho(f"✓ Wrote {output}", fg="green")
3422
+ else:
3423
+ click.echo()
3424
+ click.echo(report_text)
3425
+
3426
+ raise SystemExit(0)
3427
+
3428
+
3429
+ @localise.command("init")
3430
+ @click.argument("source", type=click.Path(exists=True, path_type=Path))
3431
+ @click.option(
3432
+ "--language",
3433
+ "-l",
3434
+ "target_language",
3435
+ required=True,
3436
+ help="Target language code (e.g., de, fr, es)",
3437
+ )
3438
+ @click.option(
3439
+ "--output",
3440
+ "-o",
3441
+ type=click.Path(path_type=Path),
3442
+ help="Output YAML file (default: {language}.yml)",
3443
+ )
3444
+ @click.option(
3445
+ "--source-language",
3446
+ default="en",
3447
+ help="Source language code (default: en)",
3448
+ )
3449
+ def localise_init(
3450
+ source: Path,
3451
+ target_language: str,
3452
+ output: Path | None,
3453
+ source_language: str,
3454
+ ):
3455
+ """Create empty translation file for a new language.
3456
+
3457
+ Equivalent to 'extract' but explicitly for starting a new language.
3458
+
3459
+ \b
3460
+ Examples:
3461
+ rdf-construct localise init ontology.ttl --language ja -o translations/ja.yml
3462
+ """
3463
+ from rdflib import Graph
3464
+ from rdf_construct.localise import (
3465
+ StringExtractor,
3466
+ ExtractConfig,
3467
+ get_formatter as get_localise_formatter,
3468
+ )
3469
+
3470
+ # Build config
3471
+ extract_config = ExtractConfig(
3472
+ source_language=source_language,
3473
+ target_language=target_language,
3474
+ )
3475
+
3476
+ # Load graph
3477
+ click.echo(f"Loading {source}...")
3478
+ graph = Graph()
3479
+ graph.parse(source)
3480
+
3481
+ # Extract
3482
+ click.echo(f"Initialising translation file for {target_language}...")
3483
+ extractor = StringExtractor(extract_config)
3484
+ result = extractor.extract(graph, source, target_language)
3485
+
3486
+ # Display result
3487
+ formatter = get_localise_formatter("text")
3488
+ click.echo(formatter.format_extraction_result(result))
3489
+
3490
+ if not result.success:
3491
+ raise SystemExit(2)
3492
+
3493
+ # Save output
3494
+ output_path = output or Path(f"{target_language}.yml")
3495
+ if result.translation_file:
3496
+ result.translation_file.save(output_path)
3497
+ click.echo()
3498
+ click.secho(f"✓ Created {output_path}", fg="green")
3499
+ click.echo(f" Fill in translations and run:")
3500
+ click.echo(f" rdf-construct localise merge {source} {output_path} -o localised.ttl")
3501
+
3502
+ raise SystemExit(0)
3503
+
3504
+
3505
+ @localise.command("config")
3506
+ @click.option(
3507
+ "--init",
3508
+ "init_config",
3509
+ is_flag=True,
3510
+ help="Generate a default localise configuration file",
3511
+ )
3512
+ def localise_config(init_config: bool):
3513
+ """Generate or validate localise configuration.
3514
+
3515
+ \b
3516
+ Examples:
3517
+ rdf-construct localise config --init
3518
+ """
3519
+ from rdf_construct.localise import create_default_config as create_default_localise_config
3520
+
3521
+ if init_config:
3522
+ config_path = Path("localise.yml")
3523
+ if config_path.exists():
3524
+ click.secho(f"Config file already exists: {config_path}", fg="red", err=True)
3525
+ raise click.Abort()
3526
+
3527
+ config_content = create_default_localise_config()
3528
+ config_path.write_text(config_content)
3529
+ click.secho(f"Created {config_path}", fg="green")
3530
+ click.echo("Edit this file to configure your localisation workflow.")
3531
+ else:
3532
+ click.echo("Use --init to create a default configuration file.")
3533
+
3534
+ raise SystemExit(0)
3535
+
3536
+
3537
+ def _expand_localise_property(prop: str) -> str:
3538
+ """Expand a CURIE to full URI for localise commands."""
3539
+ prefixes = {
3540
+ "rdfs:": "http://www.w3.org/2000/01/rdf-schema#",
3541
+ "skos:": "http://www.w3.org/2004/02/skos/core#",
3542
+ "owl:": "http://www.w3.org/2002/07/owl#",
3543
+ "rdf:": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
3544
+ "dc:": "http://purl.org/dc/elements/1.1/",
3545
+ "dcterms:": "http://purl.org/dc/terms/",
3546
+ }
3547
+
3548
+ for prefix, namespace in prefixes.items():
3549
+ if prop.startswith(prefix):
3550
+ return namespace + prop[len(prefix):]
3551
+
3552
+ return prop
3553
+
3554
+
1761
3555
  if __name__ == "__main__":
1762
3556
  cli()