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.
- datalake_doco/__init__.py +0 -0
- datalake_doco/cli.py +165 -0
- datalake_doco/config.py +31 -0
- datalake_doco/freshness.py +50 -0
- datalake_doco/frontend/app.js +651 -0
- datalake_doco/frontend/index.html +26 -0
- datalake_doco/frontend/style.css +401 -0
- datalake_doco/markdown.py +291 -0
- datalake_doco/models.py +94 -0
- datalake_doco/profiler.py +167 -0
- datalake_doco/readers/__init__.py +20 -0
- datalake_doco/readers/delta.py +196 -0
- datalake_doco/scanner.py +56 -0
- datalake_doco/timeseries.py +145 -0
- datalake_doco/writer.py +76 -0
- datalake_doco-0.1.27.dist-info/METADATA +420 -0
- datalake_doco-0.1.27.dist-info/RECORD +19 -0
- datalake_doco-0.1.27.dist-info/WHEEL +4 -0
- datalake_doco-0.1.27.dist-info/entry_points.txt +2 -0
|
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()
|
datalake_doco/config.py
ADDED
|
@@ -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)
|