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.
- coop_data_doc/__init__.py +3 -0
- coop_data_doc/__main__.py +4 -0
- coop_data_doc/cli.py +586 -0
- coop_data_doc/config.py +362 -0
- coop_data_doc/crawler.py +149 -0
- coop_data_doc/diagnostics.py +157 -0
- coop_data_doc/graph/__init__.py +23 -0
- coop_data_doc/graph/model.py +246 -0
- coop_data_doc/graph/serialize.py +33 -0
- coop_data_doc/layering.py +149 -0
- coop_data_doc/linker/__init__.py +1 -0
- coop_data_doc/linker/cache.py +110 -0
- coop_data_doc/linker/interactive.py +50 -0
- coop_data_doc/linker/resolver.py +234 -0
- coop_data_doc/parsers/__init__.py +1 -0
- coop_data_doc/parsers/bim.py +150 -0
- coop_data_doc/parsers/dax.py +105 -0
- coop_data_doc/parsers/mcode.py +98 -0
- coop_data_doc/parsers/pbir.py +329 -0
- coop_data_doc/parsers/pbix.py +168 -0
- coop_data_doc/parsers/sql_common.py +255 -0
- coop_data_doc/parsers/sql_objects.py +271 -0
- coop_data_doc/parsers/sql_procs.py +343 -0
- coop_data_doc/parsers/tmdl.py +347 -0
- coop_data_doc/progress.py +101 -0
- coop_data_doc/render/__init__.py +1 -0
- coop_data_doc/render/markdown.py +308 -0
- coop_data_doc/render/mermaid.py +162 -0
- coop_data_doc/render/site.py +346 -0
- coop_data_doc/templates/assets/README.md +13 -0
- coop_data_doc/templates/assets/custom.css +22 -0
- coop_data_doc/templates/assets/iframe-worker-shim.js +1 -0
- coop_data_doc/templates/assets/mermaid.min.js +3405 -0
- coop_data_doc/upgrade.py +338 -0
- coop_data_doc/wizard.py +359 -0
- coop_data_doc-0.15.0.dist-info/METADATA +612 -0
- coop_data_doc-0.15.0.dist-info/RECORD +40 -0
- coop_data_doc-0.15.0.dist-info/WHEEL +4 -0
- coop_data_doc-0.15.0.dist-info/entry_points.txt +2 -0
- coop_data_doc-0.15.0.dist-info/licenses/LICENSE +21 -0
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()
|