datalake-doco 0.1.27__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.
File without changes
datalake_doco/cli.py ADDED
@@ -0,0 +1,165 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ import time
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+
8
+ from datalake_doco.config import DEFAULT_HISTORY_LIMIT, OPEN_FILE_LIMIT
9
+ from datalake_doco.models import Catalog, CatalogEntry, TableDoc
10
+ from datalake_doco.readers.delta import DeltaReader
11
+ from datalake_doco.scanner import scan
12
+ from datalake_doco.writer import (
13
+ copy_frontend,
14
+ init_output_dir,
15
+ write_catalog,
16
+ write_llm_docs,
17
+ write_table_doc,
18
+ )
19
+
20
+
21
+ def generate(
22
+ uris: list[str],
23
+ output: Path,
24
+ sample_data_count: int,
25
+ sample_data_exclude_tables: str,
26
+ history_limit: int | None,
27
+ base_url: str | None = None,
28
+ ) -> None:
29
+ generated_at = datetime.now(UTC).isoformat()
30
+
31
+ excluded = {t.strip() for t in sample_data_exclude_tables.split(",") if t.strip()}
32
+
33
+ endpoint_url = os.environ.get("AWS_ENDPOINT_URL_S3")
34
+ storage_options: dict | None = {"AWS_ENDPOINT_URL": endpoint_url} if endpoint_url else None
35
+
36
+ all_discovered: list[tuple[str, str]] = []
37
+ for uri in uris:
38
+ print(f"Scanning {uri} ...")
39
+ discovered = scan(uri, endpoint_url=endpoint_url)
40
+ print(f" Found {len(discovered)} table(s)")
41
+ all_discovered.extend(discovered)
42
+
43
+ total = len(all_discovered)
44
+ init_output_dir(output)
45
+ copy_frontend(output)
46
+ write_catalog(Catalog(generatedAt=generated_at, sources=uris, tables=[]), output)
47
+
48
+ table_docs = []
49
+ catalog_entries = []
50
+
51
+ def record(table_uri: str, doc: TableDoc) -> None:
52
+ table_docs.append(doc)
53
+ write_table_doc(output, doc)
54
+
55
+ doc_path = f"tables/{doc.fullyQualifiedName.replace('.', '/')}.json"
56
+ catalog_entries.append(
57
+ CatalogEntry(
58
+ fullyQualifiedName=doc.fullyQualifiedName,
59
+ name=doc.name,
60
+ uri=table_uri,
61
+ docPath=doc_path,
62
+ updatedAt=doc.updatedAt,
63
+ rowCount=doc.tableProfile.rowCount,
64
+ columnCount=doc.tableProfile.columnCount,
65
+ description=doc.description,
66
+ freshness=doc.freshness,
67
+ )
68
+ )
69
+
70
+ write_catalog(build_catalog(), output)
71
+
72
+ def build_catalog() -> Catalog:
73
+ return Catalog(
74
+ generatedAt=generated_at,
75
+ sources=uris,
76
+ tables=sorted(catalog_entries, key=lambda e: e.fullyQualifiedName),
77
+ )
78
+
79
+ for idx, (table_uri, table_name) in enumerate(all_discovered, start=1):
80
+ fqn = table_name.replace("/", ".")
81
+ sample_count = 0 if fqn in excluded else sample_data_count
82
+
83
+ sys.stdout.write(f" [{idx}/{total}] {fqn} ... ")
84
+ sys.stdout.flush()
85
+ start = time.monotonic()
86
+
87
+ try:
88
+ doc = DeltaReader().read(table_uri, table_name, sample_count=sample_count, storage_options=storage_options, history_limit=history_limit)
89
+ except Exception as exc:
90
+ elapsed = time.monotonic() - start
91
+ sys.stdout.write(f"[error] ({elapsed:.1f}s): {exc}\n")
92
+ sys.stdout.flush()
93
+ continue
94
+
95
+ elapsed = time.monotonic() - start
96
+ sys.stdout.write(f"done ({elapsed:.1f}s)\n")
97
+ sys.stdout.flush()
98
+ record(table_uri, doc)
99
+
100
+ write_llm_docs(build_catalog(), table_docs, output, base_url)
101
+
102
+ print(f"\nDone. {len(table_docs)} table(s) documented → {output}")
103
+ print(f"Serve with: python -m http.server 8000 --directory {output}")
104
+
105
+
106
+ def publish(output: Path) -> None:
107
+ """Copy the frontend (HTML/CSS/JS) to the output directory without re-profiling tables."""
108
+ copy_frontend(output)
109
+ print(f"Frontend published → {output}")
110
+ print(f"Serve with: python -m http.server 8000 --directory {output}")
111
+
112
+
113
+ def build_parser() -> argparse.ArgumentParser:
114
+ parser = argparse.ArgumentParser(
115
+ prog="datalake-doco",
116
+ description="Generate static documentation for a datalake.",
117
+ )
118
+ subparsers = parser.add_subparsers(dest="command", required=True)
119
+
120
+ generate_parser = subparsers.add_parser("generate", help="Scan URIs, profile tables, write output.")
121
+ generate_parser.add_argument("uris", nargs="+", help="One or more URIs to scan (S3 or local paths).")
122
+ generate_parser.add_argument("--output", "-o", type=Path, default=Path("./datalake-docs"), help="Output directory.")
123
+ generate_parser.add_argument("--sample-data-count", type=int, default=0, help="Number of sample rows to include per table.")
124
+ generate_parser.add_argument("--sample-data-exclude-tables", default="", help="Comma-separated table names to exclude from sample data.")
125
+ generate_parser.add_argument(
126
+ "--history-limit", type=int, default=DEFAULT_HISTORY_LIMIT,
127
+ help=f"Max number of history entries to read per table; 0 reads all of them (default: {DEFAULT_HISTORY_LIMIT}).",
128
+ )
129
+ generate_parser.add_argument("--base-url", default=None, help="Public URL of the output directory; makes links in llms.txt and table Markdown absolute.")
130
+ generate_parser.set_defaults(func=lambda args: generate(
131
+ args.uris, args.output, args.sample_data_count, args.sample_data_exclude_tables, args.history_limit or None, args.base_url,
132
+ ))
133
+
134
+ publish_parser = subparsers.add_parser("publish", help="Copy frontend assets without re-profiling tables.")
135
+ publish_parser.add_argument("--output", "-o", type=Path, default=Path("./datalake-docs"), help="Output directory.")
136
+ publish_parser.set_defaults(func=lambda args: publish(args.output))
137
+
138
+ return parser
139
+
140
+
141
+ def raise_open_file_limit(target: int = OPEN_FILE_LIMIT) -> None:
142
+ """Raise the soft open-file limit towards `target`, capped at the hard limit.
143
+
144
+ Reading S3-backed tables opens many connections in parallel, and the storage clients keep
145
+ them pooled, so the common default soft limit of 1024 can run out mid-run.
146
+ """
147
+ try:
148
+ import resource
149
+ except ImportError: # not available on Windows
150
+ return
151
+ soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
152
+ wanted = target if hard == resource.RLIM_INFINITY else min(target, hard)
153
+ if soft != resource.RLIM_INFINITY and soft < wanted:
154
+ resource.setrlimit(resource.RLIMIT_NOFILE, (wanted, hard))
155
+
156
+
157
+ def main(argv: list[str] | None = None) -> None:
158
+ raise_open_file_limit()
159
+ parser = build_parser()
160
+ args = parser.parse_args(argv)
161
+ args.func(args)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
@@ -0,0 +1,31 @@
1
+ """Tunable constants for time distributions and table freshness, kept in one place."""
2
+
3
+ # Time zone that tz-aware timestamps are converted to before bucketing into days, weekdays and
4
+ # hours. Naive timestamps and dates are always bucketed as stored.
5
+ BUCKET_TIMEZONE = "UTC"
6
+
7
+ # How many gap ranges the Markdown page lists before summarising the rest.
8
+ MAX_GAP_RANGES = 5
9
+
10
+ # Delta operations that change a table's data, as opposed to compaction or metadata commits.
11
+ DATA_OPERATIONS = frozenset({"WRITE", "MERGE", "DELETE", "UPDATE"})
12
+
13
+ # The typical write interval is the median of up to this many of the most recent gaps between
14
+ # data commits.
15
+ FRESHNESS_GAPS = 49
16
+
17
+ # Data commits closer together than this are one burst (e.g. a backfill writing hundreds of
18
+ # commits a second apart), so the gaps inside it don't count towards the typical interval.
19
+ # Tables that genuinely write this often leave fewer than MIN_INTERVAL_GAPS longer gaps, and
20
+ # fall back to the median of all gaps.
21
+ BURST_GAP_SECONDS = 60
22
+ MIN_INTERVAL_GAPS = 3
23
+
24
+ # History entries read per table unless --history-limit says otherwise. Each entry is a separate
25
+ # commit file to fetch, and busy tables keep thousands; 100 still covers FRESHNESS_GAPS gaps
26
+ # between data commits when about half the commits are compaction.
27
+ DEFAULT_HISTORY_LIMIT = 100
28
+
29
+ # Soft open-file limit the CLI raises itself to at startup (capped at the hard limit), since
30
+ # S3 reads keep many pooled connections open.
31
+ OPEN_FILE_LIMIT = 65536
@@ -0,0 +1,50 @@
1
+ """When a table's data was last written, and how that compares to how often it's written."""
2
+
3
+ from datetime import UTC, datetime
4
+ from itertools import pairwise
5
+ from statistics import median
6
+
7
+ from datalake_doco.config import (
8
+ BURST_GAP_SECONDS,
9
+ DATA_OPERATIONS,
10
+ FRESHNESS_GAPS,
11
+ MIN_INTERVAL_GAPS,
12
+ )
13
+ from datalake_doco.models import HistoryEntry
14
+
15
+
16
+ def freshness(history: list[HistoryEntry], now: datetime) -> dict | None:
17
+ """Facts from the data-changing commits in `history`, measured up to `now`.
18
+
19
+ The typical interval is the median of the last FRESHNESS_GAPS gaps between data commits,
20
+ leaving out gaps under BURST_GAP_SECONDS so a burst of commits (a backfill) counts as one
21
+ write; with fewer than MIN_INTERVAL_GAPS such gaps, the table writes that often and every
22
+ gap counts. `ratio` is the time since the last write divided by the typical interval. Both
23
+ are None with fewer than two data commits. Returns None when there are no data commits.
24
+ """
25
+ writes = sorted(
26
+ (_parse(h.timestamp) for h in history if h.operation in DATA_OPERATIONS and h.timestamp),
27
+ reverse=True,
28
+ )
29
+ if not writes:
30
+ return None
31
+ since = (now - writes[0]).total_seconds()
32
+ gaps = [(a - b).total_seconds() for a, b in pairwise(writes)]
33
+ spaced = [g for g in gaps if g >= BURST_GAP_SECONDS][:FRESHNESS_GAPS]
34
+ burst_gap = BURST_GAP_SECONDS if len(spaced) >= MIN_INTERVAL_GAPS else None
35
+ used = spaced if burst_gap else gaps[:FRESHNESS_GAPS]
36
+ interval = median(used) if used else None
37
+ return {
38
+ "lastWrite": writes[0].isoformat(),
39
+ "secondsSinceLastWrite": round(since),
40
+ "typicalIntervalSeconds": round(interval, 1) if interval is not None else None,
41
+ "intervalGaps": len(used),
42
+ "burstGapSeconds": burst_gap,
43
+ "dataCommits": len(writes),
44
+ "ratio": round(since / interval, 2) if interval else None,
45
+ }
46
+
47
+
48
+ def _parse(ts: str) -> datetime:
49
+ parsed = datetime.fromisoformat(ts)
50
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)