coop-data-doc 0.15.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 (40) hide show
  1. coop_data_doc/__init__.py +3 -0
  2. coop_data_doc/__main__.py +4 -0
  3. coop_data_doc/cli.py +586 -0
  4. coop_data_doc/config.py +362 -0
  5. coop_data_doc/crawler.py +149 -0
  6. coop_data_doc/diagnostics.py +157 -0
  7. coop_data_doc/graph/__init__.py +23 -0
  8. coop_data_doc/graph/model.py +246 -0
  9. coop_data_doc/graph/serialize.py +33 -0
  10. coop_data_doc/layering.py +149 -0
  11. coop_data_doc/linker/__init__.py +1 -0
  12. coop_data_doc/linker/cache.py +110 -0
  13. coop_data_doc/linker/interactive.py +50 -0
  14. coop_data_doc/linker/resolver.py +234 -0
  15. coop_data_doc/parsers/__init__.py +1 -0
  16. coop_data_doc/parsers/bim.py +150 -0
  17. coop_data_doc/parsers/dax.py +105 -0
  18. coop_data_doc/parsers/mcode.py +98 -0
  19. coop_data_doc/parsers/pbir.py +329 -0
  20. coop_data_doc/parsers/pbix.py +168 -0
  21. coop_data_doc/parsers/sql_common.py +255 -0
  22. coop_data_doc/parsers/sql_objects.py +271 -0
  23. coop_data_doc/parsers/sql_procs.py +343 -0
  24. coop_data_doc/parsers/tmdl.py +347 -0
  25. coop_data_doc/progress.py +101 -0
  26. coop_data_doc/render/__init__.py +1 -0
  27. coop_data_doc/render/markdown.py +308 -0
  28. coop_data_doc/render/mermaid.py +162 -0
  29. coop_data_doc/render/site.py +346 -0
  30. coop_data_doc/templates/assets/README.md +13 -0
  31. coop_data_doc/templates/assets/custom.css +22 -0
  32. coop_data_doc/templates/assets/iframe-worker-shim.js +1 -0
  33. coop_data_doc/templates/assets/mermaid.min.js +3405 -0
  34. coop_data_doc/upgrade.py +338 -0
  35. coop_data_doc/wizard.py +359 -0
  36. coop_data_doc-0.15.0.dist-info/METADATA +612 -0
  37. coop_data_doc-0.15.0.dist-info/RECORD +40 -0
  38. coop_data_doc-0.15.0.dist-info/WHEEL +4 -0
  39. coop_data_doc-0.15.0.dist-info/entry_points.txt +2 -0
  40. coop_data_doc-0.15.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """coop-data-doc: offline, deterministic data-lineage documentation generator."""
2
+
3
+ __version__ = "0.15.0"
@@ -0,0 +1,4 @@
1
+ from coop_data_doc.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
coop_data_doc/cli.py ADDED
@@ -0,0 +1,586 @@
1
+ """Command-line interface (Module 6).
2
+
3
+ Thin wrappers around the pipeline modules — no parsing or rendering logic
4
+ lives here. User-facing failures print one friendly line; tracebacks only
5
+ with -v.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import filecmp
11
+ import json
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ import click
20
+ import questionary
21
+
22
+ from coop_data_doc import __version__
23
+ from coop_data_doc.config import Config, ConfigError, ParseWarning
24
+ from coop_data_doc.diagnostics import Diagnostics
25
+ from coop_data_doc.crawler import FileKind, crawl
26
+ from coop_data_doc.graph.model import LineageGraph
27
+ from coop_data_doc.graph.serialize import to_json_file
28
+ from coop_data_doc.linker.cache import LineageCache
29
+ from coop_data_doc.linker.resolver import ResolutionResult, link_graph
30
+ from coop_data_doc.parsers.bim import parse_bim
31
+ from coop_data_doc.parsers.pbir import link_visual_bindings, parse_legacy_reports, parse_pbir
32
+ from coop_data_doc.parsers.pbix import parse_pbix
33
+ from coop_data_doc.parsers.sql_objects import parse_sql_objects
34
+ from coop_data_doc.layering import assign_layers, prune_schemas
35
+ from coop_data_doc.parsers.sql_procs import (
36
+ parse_sql_procs,
37
+ resolve_stub_references,
38
+ )
39
+ from coop_data_doc.parsers.tmdl import parse_tmdl
40
+ from coop_data_doc.progress import Progress, should_enable
41
+ from coop_data_doc.render.markdown import render_markdown, write_diagnostics
42
+ from coop_data_doc.render.site import build_site, write_mkdocs_config
43
+
44
+ STRICT_CATEGORIES = ("regex_fallback", "dynamic_sql")
45
+ DEFAULT_CONFIG = "coop-data-doc.yml"
46
+ _log = logging.getLogger("coop_data_doc")
47
+
48
+
49
+ def run_pipeline(
50
+ config: Config,
51
+ interactive: bool,
52
+ progress: Progress | None = None,
53
+ ) -> tuple[LineageGraph, ResolutionResult, list[ParseWarning]]:
54
+ """Execute the full crawl -> parse -> link pipeline and return
55
+ (graph, resolution result, warnings). Shared by scan/build/check.
56
+
57
+ ``progress`` (optional) drives stderr progress bars; defaults to a
58
+ disabled no-op so callers and tests that don't want output are silent.
59
+ """
60
+ progress = progress or Progress(enabled=False)
61
+ graph = LineageGraph()
62
+ inventory, warnings = crawl(config)
63
+ progress.line(f"Crawling repos… {len(inventory.entries)} files found")
64
+ _log.debug("crawled %d files across %d repos", len(inventory.entries), len(config.repos))
65
+
66
+ sql_entries = inventory.by_kind(FileKind.SQL_FILE)
67
+ _log.debug("parsing %d SQL files (dialect=%s)", len(sql_entries), config.sql_dialect)
68
+ with progress.bar("Parsing SQL", total=2 * len(sql_entries)) as tick:
69
+ warnings += parse_sql_objects(sql_entries, graph, config.sql_dialect, on_file=tick)
70
+ warnings += parse_sql_procs(sql_entries, graph, config.sql_dialect, on_file=tick)
71
+ resolve_stub_references(graph)
72
+
73
+ tmdl = inventory.by_kind(FileKind.TMDL)
74
+ bim = inventory.by_kind(FileKind.BIM)
75
+ visuals = inventory.by_kind(FileKind.PBIR_VISUAL)
76
+ legacy = inventory.by_kind(FileKind.REPORT_JSON_LEGACY)
77
+ pbix = inventory.by_kind(FileKind.PBIX)
78
+ pbi_total = len(tmdl) + len(bim) + len(visuals) + len(legacy) + len(pbix)
79
+ with progress.bar("Parsing Power BI", total=pbi_total) as tick:
80
+ warnings += parse_tmdl(tmdl, graph, on_file=tick)
81
+ warnings += parse_bim(bim, graph, on_file=tick)
82
+ warnings += parse_pbir(visuals, inventory.by_kind(FileKind.PBIR_PAGE), graph, on_file=tick)
83
+ warnings += parse_legacy_reports(legacy, graph, on_file=tick)
84
+ warnings += parse_pbix(pbix, graph, on_file=tick)
85
+ warnings += link_visual_bindings(graph)
86
+
87
+ dropped = prune_schemas(graph, config.ignore_schemas)
88
+ if dropped:
89
+ progress.line(f"Dropped {dropped} objects in ignored/system schemas")
90
+ warnings += assign_layers(graph, config)
91
+
92
+ if dropped:
93
+ _log.debug("pruned %d nodes in system/ignored schemas", dropped)
94
+ progress.line("Linking cross-repo references…")
95
+ cache = LineageCache.load(config.base_dir / ".lineage-cache.json")
96
+ result, link_warnings = link_graph(graph, config, cache, interactive)
97
+ warnings += link_warnings
98
+ _log.debug(
99
+ "done: %d nodes, %d edges, %d cross-repo links, %d unresolved, %d warnings",
100
+ len(graph.nodes),
101
+ len(graph.edges),
102
+ result.resolved,
103
+ len(result.unresolved),
104
+ len(warnings),
105
+ )
106
+ return graph, result, warnings
107
+
108
+
109
+ def _strict_failures(result: ResolutionResult, warnings: list[ParseWarning]) -> list[str]:
110
+ failures = [f"unresolved reference: {key}" for key in result.unresolved]
111
+ failures += [
112
+ f"{warning.category}: {warning.file}" for warning in warnings if warning.category in STRICT_CATEGORIES
113
+ ]
114
+ return failures
115
+
116
+
117
+ def _scan(
118
+ config: Config,
119
+ non_interactive: bool,
120
+ strict: bool,
121
+ quiet: bool,
122
+ progress: Progress | None = None,
123
+ ) -> tuple[LineageGraph, Diagnostics]:
124
+ graph, result, warnings = run_pipeline(config, interactive=not non_interactive, progress=progress)
125
+ out_dir = config.output_dir()
126
+ out_dir.mkdir(parents=True, exist_ok=True)
127
+ to_json_file(graph, out_dir / "graph.json")
128
+
129
+ diagnostics = Diagnostics(warnings=warnings, unresolved=list(result.unresolved))
130
+ (out_dir / "diagnostics.json").write_text(
131
+ json.dumps(diagnostics.to_json(), indent=2, sort_keys=True) + "\n",
132
+ encoding="utf-8",
133
+ newline="\n",
134
+ )
135
+ if not quiet:
136
+ click.echo("", err=True)
137
+ for line in diagnostics.console_lines():
138
+ click.echo(line, err=True)
139
+ click.echo(
140
+ f"\n{len(graph.nodes)} objects, {len(graph.edges)} lineage edges "
141
+ f"({result.resolved} cross-repo links; {len(result.unresolved)} unresolved)",
142
+ err=True,
143
+ )
144
+ if strict:
145
+ failures = _strict_failures(result, warnings)
146
+ if failures:
147
+ for failure in failures:
148
+ click.echo(f"strict: {failure}", err=True)
149
+ sys.exit(2)
150
+ return graph, diagnostics
151
+
152
+
153
+ def _load_config(config_path: str) -> Config:
154
+ return Config.load(Path(config_path))
155
+
156
+
157
+ def _stdio_is_interactive() -> bool:
158
+ try:
159
+ return sys.stdin.isatty() and sys.stdout.isatty()
160
+ except (AttributeError, ValueError):
161
+ return False
162
+
163
+
164
+ @click.group(invoke_without_command=True)
165
+ @click.version_option(version=__version__, prog_name="coop-data-doc")
166
+ @click.option("-v", "--verbose", is_flag=True, help="Debug logging and full tracebacks.")
167
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress warning summaries.")
168
+ @click.option("--log-file", type=click.Path(), default=None, help="Write a verbose debug log to this file.")
169
+ @click.pass_context
170
+ def cli(ctx: click.Context, verbose: bool, quiet: bool, log_file: str | None) -> None:
171
+ """Offline data-lineage documentation for SQL + Power BI estates.
172
+
173
+ Run with no arguments in a terminal to get an interactive menu.
174
+ """
175
+ ctx.ensure_object(dict)
176
+ ctx.obj["verbose"] = verbose
177
+ ctx.obj["quiet"] = quiet
178
+ logging.basicConfig(level=logging.DEBUG if verbose else logging.WARNING)
179
+ if not verbose:
180
+ # sqlglot logs every unsupported-syntax fallback; already surfaced
181
+ # (deduplicated) via the diagnostics summary
182
+ logging.getLogger("sqlglot").setLevel(logging.ERROR)
183
+ if log_file:
184
+ try:
185
+ handler = logging.FileHandler(log_file, mode="w", encoding="utf-8")
186
+ except OSError as exc:
187
+ raise click.ClickException(f"could not open log file {log_file}: {exc}") from exc
188
+ handler.setLevel(logging.DEBUG)
189
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
190
+ root = logging.getLogger()
191
+ # keep the console at WARNING so the file (not the terminal) gets the
192
+ # DEBUG flood; raise only the file-bound loggers to DEBUG
193
+ for existing in root.handlers:
194
+ if not isinstance(existing, logging.FileHandler) and existing.level < logging.WARNING:
195
+ existing.setLevel(logging.WARNING)
196
+ existing.addFilter(lambda r: not r.name.startswith("sqlglot"))
197
+ root.setLevel(logging.DEBUG)
198
+ root.addHandler(handler)
199
+ logging.getLogger("coop_data_doc").setLevel(logging.DEBUG)
200
+ logging.getLogger("sqlglot").setLevel(logging.DEBUG)
201
+ if ctx.invoked_subcommand is None:
202
+ if _stdio_is_interactive():
203
+ _interactive_home(ctx)
204
+ else:
205
+ click.echo(ctx.get_help())
206
+
207
+
208
+ def _interactive_home(ctx: click.Context) -> None:
209
+ """The menu shown when `coop-data-doc` is run bare in a terminal."""
210
+ click.echo(f"coop-data-doc {__version__} — offline lineage docs for SQL + Power BI\n")
211
+ config_exists = Path(DEFAULT_CONFIG).is_file()
212
+ if config_exists:
213
+ message = f"Found {DEFAULT_CONFIG} in this folder. What would you like to do?"
214
+ choices = [
215
+ questionary.Choice("Update the docs (scan repos + rebuild everything)", "update"),
216
+ questionary.Choice("Scan only (refresh graph.json, no rendering)", "scan"),
217
+ questionary.Choice("Change settings (re-run the setup wizard)", "setup"),
218
+ questionary.Choice("Check docs freshness (the CI gate)", "check"),
219
+ questionary.Choice("Upgrade the tool & dependencies (uses network)", "upgrade"),
220
+ questionary.Choice("Exit", "exit"),
221
+ ]
222
+ else:
223
+ message = "No coop-data-doc.yml in this folder yet. What would you like to do?"
224
+ choices = [
225
+ questionary.Choice("Set up interactively (recommended)", "setup"),
226
+ questionary.Choice("Write a starter config to edit by hand", "init"),
227
+ questionary.Choice("Exit", "exit"),
228
+ ]
229
+ try:
230
+ # unsafe_ask: let Ctrl-C propagate so it exits 130, distinct from "Exit"
231
+ action = questionary.select(message, choices=choices).unsafe_ask()
232
+ except OSError:
233
+ click.echo(ctx.get_help())
234
+ return
235
+ if action in (None, "exit"):
236
+ return
237
+ if action == "setup":
238
+ ctx.invoke(setup, path=DEFAULT_CONFIG)
239
+ elif action == "init":
240
+ ctx.invoke(init, path=DEFAULT_CONFIG, force=False)
241
+ elif action == "scan":
242
+ ctx.invoke(scan, config_path=DEFAULT_CONFIG, non_interactive=False, strict=False)
243
+ elif action == "check":
244
+ ctx.invoke(check, config_path=DEFAULT_CONFIG)
245
+ elif action == "upgrade":
246
+ ctx.invoke(upgrade, check_only=False, yes=False)
247
+ elif action == "update":
248
+ _run_build(
249
+ ctx,
250
+ config_path=DEFAULT_CONFIG,
251
+ non_interactive=False,
252
+ strict=False,
253
+ skip_html=False,
254
+ serve=False,
255
+ )
256
+
257
+
258
+ @cli.command(name="help")
259
+ @click.argument("command_name", required=False)
260
+ @click.pass_context
261
+ def help_cmd(ctx: click.Context, command_name: str | None) -> None:
262
+ """Show help for coop-data-doc or a specific command."""
263
+ if command_name is None:
264
+ click.echo(ctx.parent.get_help())
265
+ return
266
+ command = cli.get_command(ctx, command_name)
267
+ if command is None:
268
+ # UsageError -> exit 2, same as `coop-data-doc <unknown>` itself
269
+ raise click.UsageError(
270
+ f"unknown command '{command_name}' — try `coop-data-doc help`",
271
+ ctx=ctx.parent,
272
+ )
273
+ sub_ctx = click.Context(command, info_name=command_name, parent=ctx.parent)
274
+ click.echo(command.get_help(sub_ctx))
275
+
276
+
277
+ @cli.command()
278
+ @click.argument("path", default=DEFAULT_CONFIG)
279
+ def setup(path: str) -> None:
280
+ """Interactively create or update coop-data-doc.yml.
281
+
282
+ Prompts for every value, prefilled from the existing config when present,
283
+ then saves and re-validates. Ctrl-C before the end writes nothing.
284
+ """
285
+ from coop_data_doc.wizard import run_setup
286
+
287
+ try:
288
+ config = run_setup(Path(path))
289
+ except KeyboardInterrupt:
290
+ click.echo("\nSetup cancelled — nothing was written.", err=True)
291
+ sys.exit(130)
292
+ except OSError:
293
+ click.echo(
294
+ "setup needs an interactive terminal. In CI or scripts, edit "
295
+ "coop-data-doc.yml directly or scaffold one with `coop-data-doc init`.",
296
+ err=True,
297
+ )
298
+ sys.exit(1)
299
+ if config is None:
300
+ click.echo(f"Saved {path}. Fix the noted problem, then run `coop-data-doc build`.")
301
+ return
302
+ click.echo(
303
+ f"Saved {path} — project '{config.project_name}', "
304
+ f"{len(config.repos)} repos, {len(config.schema_mappings)} schema mapping(s)."
305
+ )
306
+ click.echo("Next: run `coop-data-doc build`.")
307
+
308
+
309
+ @cli.command()
310
+ @click.argument("path", default=DEFAULT_CONFIG)
311
+ @click.option("--force", is_flag=True, help="Overwrite an existing config.")
312
+ def init(path: str, force: bool) -> None:
313
+ """Write a starter coop-data-doc.yml to edit by hand (see also: setup)."""
314
+ target = Path(path)
315
+ if target.exists() and not force:
316
+ raise click.ClickException(f"{target} already exists (use --force to overwrite)")
317
+ try:
318
+ if target.exists():
319
+ target.unlink()
320
+ if target.parent != Path(""):
321
+ target.parent.mkdir(parents=True, exist_ok=True)
322
+ Config.scaffold(target)
323
+ except OSError as exc:
324
+ raise click.ClickException(f"could not write {target}: {exc}") from exc
325
+ click.echo(f"Wrote {target}.")
326
+ click.echo("Next: edit the two repo paths, then run `coop-data-doc build`.")
327
+
328
+
329
+ @cli.command()
330
+ @click.option("--config", "config_path", default=DEFAULT_CONFIG, show_default=True)
331
+ @click.option("--non-interactive", is_flag=True, help="Never prompt (CI mode).")
332
+ @click.option("--strict", is_flag=True, help="Exit 2 on unresolved refs / risky parses.")
333
+ @click.pass_context
334
+ def scan(ctx: click.Context, config_path: str, non_interactive: bool, strict: bool) -> None:
335
+ """Crawl, parse, and link both repos; write graph.json."""
336
+ config = _load_config(config_path)
337
+ progress = Progress(should_enable(ctx.obj["quiet"]))
338
+ _scan(config, non_interactive, strict, ctx.obj["quiet"], progress=progress)
339
+
340
+
341
+ def _run_build(
342
+ ctx: click.Context,
343
+ config_path: str,
344
+ non_interactive: bool,
345
+ strict: bool,
346
+ skip_html: bool,
347
+ serve: bool,
348
+ ) -> None:
349
+ """Shared implementation behind `build` and `update`."""
350
+ config = _load_config(config_path)
351
+ progress = Progress(should_enable(ctx.obj["quiet"]))
352
+ graph, diagnostics = _scan(config, non_interactive, strict, ctx.obj["quiet"], progress=progress)
353
+ out_dir = config.output_dir()
354
+ with progress.bar("Rendering pages", total=len(graph.nodes)) as tick:
355
+ render_markdown(graph, out_dir, config.project_name, on_node=tick)
356
+ write_diagnostics(out_dir, diagnostics, config.project_name)
357
+ click.echo(f"Markdown docs: {out_dir}", err=True)
358
+ if skip_html:
359
+ return
360
+ mkdocs_config = write_mkdocs_config(
361
+ out_dir,
362
+ config.site_dir(),
363
+ config.project_name,
364
+ graph,
365
+ branding=config.branding,
366
+ config_dir=config.base_dir,
367
+ )
368
+ if serve:
369
+ os.execvp(sys.executable, [sys.executable, "-m", "mkdocs", "serve", "-f", str(mkdocs_config)])
370
+ with progress.spinner(f"Building HTML site ({len(graph.nodes)} pages)"):
371
+ build_site(mkdocs_config, config.site_dir())
372
+ index = config.site_dir() / "index.html"
373
+ click.echo(f"HTML portal: file://{index}", err=True)
374
+
375
+
376
+ _BUILD_OPTIONS = [
377
+ click.option("--config", "config_path", default=DEFAULT_CONFIG, show_default=True),
378
+ click.option("--non-interactive", is_flag=True, help="Never prompt (CI mode)."),
379
+ click.option("--strict", is_flag=True, help="Exit 2 on unresolved refs / risky parses."),
380
+ click.option("--skip-html", is_flag=True, help="Markdown only; skip the mkdocs site."),
381
+ click.option("--serve", is_flag=True, help="Start `mkdocs serve` after building."),
382
+ ]
383
+
384
+
385
+ def _with_build_options(func):
386
+ for option in reversed(_BUILD_OPTIONS):
387
+ func = option(func)
388
+ return func
389
+
390
+
391
+ @cli.command()
392
+ @_with_build_options
393
+ @click.pass_context
394
+ def build(
395
+ ctx: click.Context,
396
+ config_path: str,
397
+ non_interactive: bool,
398
+ strict: bool,
399
+ skip_html: bool,
400
+ serve: bool,
401
+ ) -> None:
402
+ """Full pipeline: scan + markdown docs + searchable HTML portal."""
403
+ _run_build(ctx, config_path, non_interactive, strict, skip_html, serve)
404
+
405
+
406
+ @cli.command()
407
+ @_with_build_options
408
+ @click.pass_context
409
+ def update(
410
+ ctx: click.Context,
411
+ config_path: str,
412
+ non_interactive: bool,
413
+ strict: bool,
414
+ skip_html: bool,
415
+ serve: bool,
416
+ ) -> None:
417
+ """Re-scan the repos and refresh all documentation (same as build)."""
418
+ _run_build(ctx, config_path, non_interactive, strict, skip_html, serve)
419
+
420
+
421
+ @cli.command()
422
+ @click.option("--check", "check_only", is_flag=True, help="Report available updates; change nothing.")
423
+ @click.option("--yes", is_flag=True, help="Apply without asking for confirmation.")
424
+ @click.pass_context
425
+ def upgrade(ctx: click.Context, check_only: bool, yes: bool) -> None:
426
+ """Update the tool itself and apply non-breaking dependency updates.
427
+
428
+ The ONLY command that uses the network (PyPI metadata / git fetch).
429
+ Major-version dependency jumps are reported but never auto-applied.
430
+ """
431
+ from coop_data_doc.upgrade import UpgradeError, apply_plan, build_plan
432
+
433
+ progress = Progress(should_enable(ctx.obj["quiet"]))
434
+ with progress.spinner("Checking for updates"):
435
+ plan = build_plan()
436
+ click.echo(f"\ncoop-data-doc {plan.tool_installed} ({plan.install_method}) — {plan.tool_note}")
437
+ if plan.dependencies:
438
+ click.echo("\nDependencies:")
439
+ for dep in plan.dependencies:
440
+ latest = dep.latest or "?"
441
+ label = {
442
+ "current": "up to date",
443
+ "safe": f"update available → {latest}",
444
+ "major": f"MAJOR update available → {latest} (review before applying)",
445
+ "unknown": "could not check (offline?)",
446
+ }[dep.kind]
447
+ click.echo(f" {dep.name:20} {dep.installed:12} {label}")
448
+ if check_only:
449
+ return
450
+ nothing_to_apply = (
451
+ not plan.safe_updates
452
+ and not plan.is_vcs_install # a git install can always re-pull a newer commit
453
+ and "new commit(s)" not in plan.tool_note
454
+ and ("latest release is" not in plan.tool_note)
455
+ )
456
+ if nothing_to_apply and plan.install_method not in ("pip", "git-checkout"):
457
+ click.echo("\nEverything is up to date.")
458
+ return
459
+ if not yes:
460
+ if not _stdio_is_interactive():
461
+ click.echo("\nRe-run with --yes to apply in non-interactive environments.", err=True)
462
+ return
463
+ answer = questionary.confirm(
464
+ "Apply the tool upgrade and non-breaking dependency updates?", default=True
465
+ ).ask()
466
+ if not answer:
467
+ click.echo("Nothing changed.")
468
+ return
469
+ try:
470
+ with progress.spinner("Applying update (downloading + reinstalling)"):
471
+ executed = apply_plan(plan)
472
+ except UpgradeError as exc:
473
+ raise click.ClickException(str(exc)) from exc
474
+ for command in executed:
475
+ click.echo(f"ran: {' '.join(command)}", err=True)
476
+ click.echo("Upgrade complete. Run `coop-data-doc --version` to confirm.")
477
+
478
+
479
+ @cli.command()
480
+ @click.option("--config", "config_path", default=DEFAULT_CONFIG, show_default=True)
481
+ @click.option(
482
+ "--lenient",
483
+ is_flag=True,
484
+ help="Tolerate risky-parse warnings (regex_fallback/dynamic_sql); still "
485
+ "fail on unresolved references and stale docs.",
486
+ )
487
+ @click.pass_context
488
+ def check(ctx: click.Context, config_path: str, lenient: bool) -> None:
489
+ """CI gate: fail when committed docs are stale, references are
490
+ unresolved, or (unless --lenient) risky-parse warnings exist
491
+ (regex_fallback / dynamic_sql). Exit 2 for pipeline problems, 1 for
492
+ stale docs."""
493
+ config = _load_config(config_path)
494
+ graph, result, warnings = run_pipeline(config, interactive=False)
495
+ if lenient:
496
+ failures = [f"unresolved reference: {key}" for key in result.unresolved]
497
+ else:
498
+ failures = _strict_failures(result, warnings)
499
+ if failures:
500
+ for failure in failures:
501
+ click.echo(f"check: {failure}", err=True)
502
+ sys.exit(2)
503
+ committed = config.output_dir()
504
+ if not committed.is_dir():
505
+ raise click.ClickException(f"no committed docs at {committed}; run `coop-data-doc build` first")
506
+ with tempfile.TemporaryDirectory() as tmp:
507
+ fresh = Path(tmp) / "docs"
508
+ # start from the committed tree so human-authored Business Intent
509
+ # blocks are preserved in the regenerated pages
510
+ shutil.copytree(committed, fresh)
511
+ render_markdown(graph, fresh, config.project_name)
512
+ diagnostics = Diagnostics(warnings=warnings, unresolved=list(result.unresolved))
513
+ write_diagnostics(fresh, diagnostics, config.project_name)
514
+ (fresh / "diagnostics.json").write_text(
515
+ json.dumps(diagnostics.to_json(), indent=2, sort_keys=True) + "\n",
516
+ encoding="utf-8",
517
+ newline="\n",
518
+ )
519
+ to_json_file(graph, fresh / "graph.json")
520
+ stale = _tree_diff(committed, fresh)
521
+ if stale:
522
+ for path in stale:
523
+ click.echo(f"stale: {path}", err=True)
524
+ click.echo("docs are out of date — run `coop-data-doc build`", err=True)
525
+ sys.exit(1)
526
+ click.echo("docs are up to date")
527
+
528
+
529
+ def _tree_diff(committed: Path, fresh: Path) -> list[str]:
530
+ """Generated files that differ between the two doc trees, both ways:
531
+ changed/missing in committed AND committed files the fresh render no
532
+ longer produces (orphaned pages of deleted objects)."""
533
+
534
+ def generated_files(root: Path) -> set[Path]:
535
+ files = {p.relative_to(root) for p in root.rglob("*.md") if p.is_file()}
536
+ for name in ("graph.json", "manifest.json", "diagnostics.json"):
537
+ if (root / name).is_file():
538
+ files.add(Path(name))
539
+ return files
540
+
541
+ stale: set[str] = set()
542
+ fresh_files = generated_files(fresh)
543
+ committed_files = generated_files(committed)
544
+ for relative in fresh_files | committed_files:
545
+ committed_file = committed / relative
546
+ fresh_file = fresh / relative
547
+ if not (
548
+ committed_file.is_file()
549
+ and fresh_file.is_file()
550
+ and filecmp.cmp(committed_file, fresh_file, shallow=False)
551
+ ):
552
+ stale.add(str(relative))
553
+ return sorted(stale)
554
+
555
+
556
+ def main() -> None:
557
+ """Console-script entrypoint: friendly one-line errors, exit 130 on Ctrl-C.
558
+
559
+ standalone_mode=False so click doesn't swallow KeyboardInterrupt into a
560
+ bare "Aborted!" — interactive sessions (linker prompts, the menu) need
561
+ the cache-preservation message and the conventional 130 exit code.
562
+ """
563
+ try:
564
+ cli(obj={}, standalone_mode=False)
565
+ except click.exceptions.Abort:
566
+ # click converts EOFError/KeyboardInterrupt inside commands to Abort
567
+ click.echo(
568
+ "\nInterrupted — any answers you gave are saved in .lineage-cache.json; run again to continue.",
569
+ err=True,
570
+ )
571
+ sys.exit(130)
572
+ except click.exceptions.Exit as exc: # e.g. --help / --version
573
+ sys.exit(exc.exit_code)
574
+ except click.ClickException as exc:
575
+ exc.show()
576
+ sys.exit(exc.exit_code)
577
+ except ConfigError as exc:
578
+ click.echo(f"error: {exc}", err=True)
579
+ sys.exit(1)
580
+ except KeyboardInterrupt:
581
+ click.echo("\nInterrupted.", err=True)
582
+ sys.exit(130)
583
+
584
+
585
+ if __name__ == "__main__":
586
+ main()