datalake-doco 0.1.27__tar.gz

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 (29) hide show
  1. datalake_doco-0.1.27/.gitignore +11 -0
  2. datalake_doco-0.1.27/PKG-INFO +420 -0
  3. datalake_doco-0.1.27/README.md +406 -0
  4. datalake_doco-0.1.27/pyproject.toml +42 -0
  5. datalake_doco-0.1.27/src/datalake_doco/__init__.py +0 -0
  6. datalake_doco-0.1.27/src/datalake_doco/cli.py +165 -0
  7. datalake_doco-0.1.27/src/datalake_doco/config.py +31 -0
  8. datalake_doco-0.1.27/src/datalake_doco/freshness.py +50 -0
  9. datalake_doco-0.1.27/src/datalake_doco/frontend/app.js +651 -0
  10. datalake_doco-0.1.27/src/datalake_doco/frontend/index.html +26 -0
  11. datalake_doco-0.1.27/src/datalake_doco/frontend/style.css +401 -0
  12. datalake_doco-0.1.27/src/datalake_doco/markdown.py +291 -0
  13. datalake_doco-0.1.27/src/datalake_doco/models.py +94 -0
  14. datalake_doco-0.1.27/src/datalake_doco/profiler.py +167 -0
  15. datalake_doco-0.1.27/src/datalake_doco/readers/__init__.py +20 -0
  16. datalake_doco-0.1.27/src/datalake_doco/readers/delta.py +196 -0
  17. datalake_doco-0.1.27/src/datalake_doco/scanner.py +56 -0
  18. datalake_doco-0.1.27/src/datalake_doco/timeseries.py +145 -0
  19. datalake_doco-0.1.27/src/datalake_doco/writer.py +76 -0
  20. datalake_doco-0.1.27/tests/conftest.py +45 -0
  21. datalake_doco-0.1.27/tests/test_cli.py +289 -0
  22. datalake_doco-0.1.27/tests/test_freshness.py +83 -0
  23. datalake_doco-0.1.27/tests/test_markdown.py +271 -0
  24. datalake_doco-0.1.27/tests/test_profiler.py +267 -0
  25. datalake_doco-0.1.27/tests/test_reader.py +295 -0
  26. datalake_doco-0.1.27/tests/test_scanner.py +79 -0
  27. datalake_doco-0.1.27/tests/test_timeseries.py +122 -0
  28. datalake_doco-0.1.27/tests/test_type_mapping.py +50 -0
  29. datalake_doco-0.1.27/tests/test_writer.py +196 -0
@@ -0,0 +1,11 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ docs/
@@ -0,0 +1,420 @@
1
+ Metadata-Version: 2.5
2
+ Name: datalake-doco
3
+ Version: 0.1.27
4
+ Summary: Static documentation generator for datalakes
5
+ Project-URL: Homepage, https://github.com/leprechaun/static-datalake-doco
6
+ Project-URL: Repository, https://github.com/leprechaun/static-datalake-doco
7
+ Project-URL: Issues, https://github.com/leprechaun/static-datalake-doco/issues
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: deltalake>=0.19
10
+ Requires-Dist: fsspec>=2024.2
11
+ Requires-Dist: polars>=1.0
12
+ Requires-Dist: s3fs>=2024.2
13
+ Description-Content-Type: text/markdown
14
+
15
+ # datalake-doco
16
+
17
+ [![Maintainability](https://qlty.sh/gh/leprechaun/projects/static-datalake-doco/maintainability.svg)](https://qlty.sh/gh/leprechaun/projects/static-datalake-doco)
18
+ [![Code Coverage](https://qlty.sh/gh/leprechaun/projects/static-datalake-doco/coverage.svg)](https://qlty.sh/gh/leprechaun/projects/static-datalake-doco)
19
+
20
+ ## Purpose
21
+
22
+ <img width="300" alt="Image" src="https://github.com/user-attachments/assets/553ee789-b0fe-45be-925b-7e920024cba3" />
23
+ <img width="300" alt="Image" src="https://github.com/user-attachments/assets/2c53cfb7-a966-45b2-8b7b-69ef3cb4d8dc" />
24
+ <img width="300" alt="Image" src="https://github.com/user-attachments/assets/11826c47-98db-4b6a-b3ac-3a97e4149f9a" />
25
+ <img width="300" alt="Image" src="https://github.com/user-attachments/assets/fcd5b2df-2e60-46c8-b8fb-6e2e9c22f07e" />
26
+
27
+ ```
28
+ a.k.a. "Poor Man's Data Catalogue"
29
+ ```
30
+
31
+ This tool will generate a data catalogue after scanning an S3 bucket. The catalogue is a set of OpenMetadata-looking JSON files, and a catalogue. There's also a static HTML page that can be used to view the catalogue as human being.
32
+
33
+ Why? Because it's easy, quick, and does not require spinning up any sort of machinery.
34
+
35
+ Each table's documentation includes the schema, row count, version history, statistics, and a time distribution for each date/timestamp column. Sample data is off by default; turn it on with `--sample-data-count`, and leave out individual tables with `--sample-data-exclude-tables` in case there are concerns with data sensitivity.
36
+
37
+ ## Usage
38
+
39
+ `datalake-doco generate` scans one or more filesystem or S3 roots for Delta Lake tables, profiles each table, and writes a static JSON catalog plus per-table JSON documents to an output directory. The output is self-contained and can be served by any static HTTP server.
40
+
41
+ ---
42
+
43
+ ## Pipeline
44
+
45
+ ```
46
+ URI(s)
47
+ └─ scanner.scan() → list[(table_uri, table_name)]
48
+ └─ writer.init_output_dir() + copy_frontend()
49
+ └─ per table:
50
+ └─ DeltaReader.read() → TableDoc
51
+ ├─ profiler.profile() → column statistics + time distributions
52
+ ├─ dt.history() → Delta transaction log
53
+ └─ freshness.freshness() → last write vs typical write interval
54
+ └─ writer.write_table_doc() + write_catalog() → tables/**/*.json, catalog.json
55
+ └─ writer.write_llm_docs() → llms.txt + tables/**/*.md
56
+ ```
57
+
58
+ ---
59
+
60
+ ## CLI
61
+
62
+ ### `generate`
63
+
64
+ Scans URIs, profiles all discovered Delta tables, and writes the full output.
65
+
66
+ | Argument / Option | Type | Default | Description |
67
+ |---|---|---|---|
68
+ | `uris` | `list[str]` (positional, 1+) | required | Roots to scan (local path or `s3://...`) |
69
+ | `--output` / `-o` | `Path` | `./datalake-docs` | Output directory |
70
+ | `--sample-data-count` | `int` | `0` | Sample rows per table (`0` = no sample data) |
71
+ | `--sample-data-exclude-tables` | `str` | `""` | Comma-separated `fullyQualifiedName`s to exclude from sample data |
72
+ | `--history-limit` | `int` | `100` | Max history entries read per table (`0` = all). Each entry is a separate commit file fetched from storage, so busy tables with thousands of commits make many requests |
73
+ | `--base-url` | `str` | `None` | Public URL of the output directory. Makes links in `llms.txt` and the table Markdown absolute (`None` = relative links) |
74
+
75
+ **Environment:**
76
+ - `AWS_ENDPOINT_URL_S3` — if set, used as the S3 endpoint for both scanning and reading. Enables MinIO and other S3-compatible stores.
77
+
78
+ **Behaviour:**
79
+ - Tables from all URIs are merged into one catalog.
80
+ - Tables that fail to profile are skipped, with an `[error]` line printed in place of `done`; they do not appear in the catalog.
81
+ - Tables are profiled sequentially; each progress line is printed as soon as profiling starts and completed in place once it finishes.
82
+ - `--sample-data-exclude-tables` is matched against `fullyQualifiedName` (dots, not slashes). Excluded tables are still profiled and appear in the catalog — only sample rows are omitted.
83
+ - The `tables/` subdirectory in the output is deleted and recreated on each run.
84
+
85
+ ### `publish`
86
+
87
+ Copies only the frontend assets (`index.html`, `app.js`, `style.css`) to `--output`. Does not re-profile tables. Use this to update the UI without re-scanning the datalake.
88
+
89
+ ---
90
+
91
+ ## Scanner
92
+
93
+ **`scanner.scan(uri, endpoint_url=None) → list[tuple[str, str]]`**
94
+
95
+ Returns `(table_uri, table_name)` pairs for every Delta table found under `uri`.
96
+
97
+ **Detection:** A directory is a Delta table if and only if it contains a child named `_delta_log`. Recursion stops at that point — subdirectories of a Delta table are not visited.
98
+
99
+ **`table_uri`:** Absolute path or full URI of the table root (no trailing slash).
100
+
101
+ **`table_name`:** Path of `table_uri` relative to the scan root, with the root prefix and leading slash stripped. If the table is the root itself, falls back to the final path segment.
102
+
103
+ Examples (root = `/data`):
104
+
105
+ | Table path | `table_name` |
106
+ |---|---|
107
+ | `/data/events` | `events` |
108
+ | `/data/bronze/events` | `bronze/events` |
109
+ | `/data/mydb/bronze/events` | `mydb/bronze/events` |
110
+
111
+ Trailing slashes in `uri` are stripped before processing.
112
+
113
+ ---
114
+
115
+ ## DeltaReader
116
+
117
+ **`DeltaReader().read(uri, name, sample_count=0, storage_options=None, history_limit=None) → TableDoc`**
118
+
119
+ ### Name → identity fields
120
+
121
+ `name` uses `/` as the segment separator (matches scanner output).
122
+
123
+ | `name` input | `TableDoc.name` | `TableDoc.fullyQualifiedName` |
124
+ |---|---|---|
125
+ | `events` | `events` | `events` |
126
+ | `bronze/events` | `events` | `bronze.events` |
127
+ | `mydb/bronze/events` | `events` | `mydb.bronze.events` |
128
+
129
+ Rule: `name` = last `/`-segment; `fullyQualifiedName` = `name` with `/` replaced by `.`.
130
+
131
+ ### Description
132
+
133
+ `description` ← the Delta table's metadata description (`DeltaTable.metadata().description`). `None` if absent or empty.
134
+
135
+ ### Column schema
136
+
137
+ Each column in the polars schema produces one `Column`:
138
+
139
+ - `name` — column name
140
+ - `dataType` — mapped from polars dtype (see type mapping below)
141
+ - `nullable` — from the Delta schema field, **not** inferred from data
142
+ - `ordinalPosition` — 1-indexed position in the schema
143
+ - `precision`, `scale` — set only for `DECIMAL`; `None` otherwise
144
+ - `partitioned` — whether the column is one of the Delta table's partition columns (also listed in `TableDoc.partitionColumns`)
145
+ - `description` — the Delta field metadata's `description` key, or Spark's `comment` when there is none
146
+ - `tags` — every other field metadata key, such as `unit`; keys written by Delta and Spark themselves (`delta.*`, `__*`) are left out
147
+
148
+ The table page and the Schema tab show the description and tags in a Description column, e.g. "Heart rate (unit: bpm)". The column is omitted when no column is documented.
149
+
150
+ **Type mapping:**
151
+
152
+ | Polars dtype | `dataType` |
153
+ |---|---|
154
+ | `Int8`, `UInt8` | `TINYINT` |
155
+ | `Int16`, `UInt16` | `SMALLINT` |
156
+ | `Int32`, `UInt32` | `INT` |
157
+ | `Int64`, `UInt64` | `BIGINT` |
158
+ | `Float32` | `FLOAT` |
159
+ | `Float64` | `DOUBLE` |
160
+ | `Boolean` | `BOOLEAN` |
161
+ | `String`, `Utf8` | `STRING` |
162
+ | `Date` | `DATE` |
163
+ | `Binary` | `BINARY` |
164
+ | `Datetime(any)` | `TIMESTAMP` |
165
+ | `Decimal(p, s)` | `DECIMAL` (precision=p, scale=s) |
166
+ | `List`, `Array` | `ARRAY` |
167
+ | `Struct` | `STRUCT` |
168
+ | anything else | `str(dtype).upper()` |
169
+
170
+ ### Table profile
171
+
172
+ - `rowCount` — total rows in the table (a streaming `pl.len()` over the scan)
173
+ - `columnCount` — number of columns in the schema
174
+ - `timestamp` — ISO 8601 UTC timestamp at the moment of profiling
175
+ - `columnProfile` — one `ColumnProfile` per column
176
+
177
+ ### Sample data
178
+
179
+ Produced only when `sample_count > 0`.
180
+
181
+ - `columns` — column names in schema order
182
+ - `rows` — up to `sample_count` rows from the head of the table
183
+ - Cell values are coerced to JSON-safe types: `str`, `int`, `float`, `bool`, or `None`. Date/datetime values are serialised via `.isoformat()`; anything else via `str()`.
184
+
185
+ ### Delta history
186
+
187
+ - Stored under `extension["deltaHistory"]` as a list of `HistoryEntry`.
188
+ - Limited to the `history_limit` most-recent entries when set (the CLI defaults to 100, `config.DEFAULT_HISTORY_LIMIT`); all entries otherwise. Freshness is computed from these entries, so a limit below about 100 shortens the window for the typical write interval.
189
+ - `timestamp` is converted from epoch-milliseconds to ISO 8601 UTC string; stored as `""` if absent.
190
+ - `parameters` ← `operationParameters` (empty dict if absent).
191
+ - `description` ← `userMetadata` (`None` if absent/empty).
192
+ - `engine_info` ← `engineInfo` (`None` if absent/empty).
193
+ - If the history list is empty, `extension` is `None`.
194
+ - `commitCount` (top level, not under `extension`) is the latest version + 1: every commit ever made, however many history entries were read and even after old log files are cleaned up.
195
+
196
+ ### Freshness
197
+
198
+ `freshness` (also copied to the table's `catalog.json` entry) comes from the data-changing commits in the history (`config.DATA_OPERATIONS`: `WRITE`, `MERGE`, `DELETE`, `UPDATE`):
199
+
200
+ - `lastWrite`, `secondsSinceLastWrite` — the newest data commit, and its age at profiling time
201
+ - `typicalIntervalSeconds` — median of the last `config.FRESHNESS_GAPS` (49) gaps between data commits; `null` with only one commit. Gaps under `config.BURST_GAP_SECONDS` (60) are left out, so a burst of commits such as a backfill counts as one write. A table with fewer than `config.MIN_INTERVAL_GAPS` (3) longer gaps really does write that often, so all its gaps count
202
+ - `burstGapSeconds` — the burst threshold applied, or `null` when all gaps counted
203
+ - `ratio` — `secondsSinceLastWrite / typicalIntervalSeconds`
204
+ - `intervalGaps`, `dataCommits` — gaps used for the interval, and data commits in the history
205
+ - `null` when the history has no data commits.
206
+
207
+ ---
208
+
209
+ ## Profiler
210
+
211
+ **`profiler.profile(lf: pl.LazyFrame, schema, now=None) → dict[str, dict]`**
212
+
213
+ Returns a dict keyed by column name. Field names are OpenMetadata-aligned. Every column's statistics are computed in a single streaming pass over `lf`; `now` (default: the current UTC time) is the profiling time used to mark partial periods in time distributions.
214
+
215
+ ### Numeric columns
216
+
217
+ | Field | Description |
218
+ |---|---|
219
+ | `valuesCount` | Non-null row count |
220
+ | `nullCount` | Null row count |
221
+ | `mean` | Arithmetic mean |
222
+ | `stddev` | Standard deviation |
223
+ | `min` | Minimum value |
224
+ | `firstQuartile` | 25th percentile |
225
+ | `median` | 50th percentile |
226
+ | `thirdQuartile` | 75th percentile |
227
+ | `max` | Maximum value |
228
+
229
+ `uniqueCount`, `top`, and `freq` are **absent** (not present as `None`).
230
+
231
+ ### Other columns (string, boolean, etc.)
232
+
233
+ | Field | Description |
234
+ |---|---|
235
+ | `valuesCount` | Non-null row count |
236
+ | `nullCount` | Null row count |
237
+ | `uniqueCount` | Number of distinct values (including null) |
238
+ | `top` | Most frequent non-null value as a string, or `null` if all values are null |
239
+ | `freq` | Occurrence count of `top`, or `null` if all values are null |
240
+
241
+ `mean`, `stddev`, `min`, `firstQuartile`, `median`, `thirdQuartile`, and `max` are **absent**.
242
+
243
+ ### Temporal columns (date, timestamp, duration, time)
244
+
245
+ `valuesCount`, `nullCount`, and `min`/`max` as ISO strings. Date and timestamp columns also get a `distribution`, built from per-hour (timestamp) or per-day (date) row counts gathered in the same single pass as the other statistics:
246
+
247
+ | Field | Description |
248
+ |---|---|
249
+ | `grain` | `monthly` (every value is midnight on the 1st), `daily` (every value is midnight) or `sub-daily` |
250
+ | `bucketing` | Time zone tz-aware timestamps were converted to before bucketing (`config.BUCKET_TIMEZONE`, default `UTC`), or `naive`/`date` for values bucketed as stored |
251
+ | `year`, `month`, `day` | Row counts per period that has rows |
252
+ | `weekday`, `hour` | Row counts per weekday and hour of day, every slot listed; `hour` only for sub-daily timestamps |
253
+ | `daysWithData`, `missingDays`, `largestGap` | Days with rows, days without rows between the first and last, and the longest run of those |
254
+ | `gaps` | Runs of missing days (or months, for monthly columns), largest first |
255
+ | `rowsPerDay` | min, p5, median, p95 and max rows per day with data, excluding the partial day; not for monthly columns |
256
+ | `partialDay`, `partialMonth` | The day and month still in progress at profiling time |
257
+ | `sameAs` | Set when the column's per-day counts repeat another column's: `derived` for a date matching a timestamp, `identical` otherwise |
258
+
259
+ ### Float safety
260
+
261
+ Any stat that is `NaN` or `±Inf` is replaced with `null`. Values are rounded to 6 decimal places.
262
+
263
+ ---
264
+
265
+ ## Writer
266
+
267
+ `generate` writes the output incrementally, so a long run leaves a usable catalogue behind as it goes:
268
+
269
+ 1. `init_output_dir` creates `output_dir` if it does not exist, and deletes and recreates `output_dir/tables/`.
270
+ 2. `copy_frontend` copies the assets from the package's `frontend/` directory into `output_dir`, and an empty `catalog.json` is written.
271
+ 3. As each table finishes, `write_table_doc` writes `output_dir/tables/{fullyQualifiedName.replace(".", "/")}.json` (creating intermediate directories as needed) and `catalog.json` is rewritten with the tables so far.
272
+ 4. `write_llm_docs` writes `llms.txt` and one `tables/{path}.md` per table once every table is profiled.
273
+
274
+ `write_output(catalog, table_docs, output_dir, base_url=None)` does all of that in one call, for already-profiled tables.
275
+
276
+ **JSON serialisation:** Dataclasses are recursively converted to plain dicts. `float` values that are `NaN` or `±Inf` are replaced with `null`. Output is UTF-8, indented with 2 spaces.
277
+
278
+ ---
279
+
280
+ ## Output schema
281
+
282
+ ### `catalog.json`
283
+
284
+ ```json
285
+ {
286
+ "generatedAt": "<ISO 8601 UTC>",
287
+ "sources": ["<uri>"],
288
+ "tables": [
289
+ {
290
+ "fullyQualifiedName": "mydb.bronze.events",
291
+ "name": "events",
292
+ "uri": "<absolute table URI>",
293
+ "docPath": "tables/mydb/bronze/events.json",
294
+ "updatedAt": "<ISO 8601 UTC>",
295
+ "rowCount": 1000000,
296
+ "columnCount": 12,
297
+ "description": "Raw clickstream events",
298
+ "freshness": {"lastWrite": "<ISO 8601 UTC>", "secondsSinceLastWrite": 900, "typicalIntervalSeconds": 3600, "intervalGaps": 49, "burstGapSeconds": 60, "dataCommits": 812, "ratio": 0.25}
299
+ }
300
+ ]
301
+ }
302
+ ```
303
+
304
+ ### `tables/{path}.json`
305
+
306
+ ```json
307
+ {
308
+ "name": "events",
309
+ "fullyQualifiedName": "mydb.bronze.events",
310
+ "uri": "<absolute table URI>",
311
+ "updatedAt": "<ISO 8601 UTC>",
312
+ "description": "Raw clickstream events",
313
+ "columns": [
314
+ {
315
+ "name": "id",
316
+ "dataType": "BIGINT",
317
+ "nullable": true,
318
+ "ordinalPosition": 1,
319
+ "precision": null,
320
+ "scale": null,
321
+ "partitioned": false,
322
+ "description": "Event id",
323
+ "tags": {"unit": "count"}
324
+ }
325
+ ],
326
+ "tableProfile": {
327
+ "timestamp": "<ISO 8601 UTC>",
328
+ "rowCount": 1000000,
329
+ "columnCount": 12,
330
+ "columnProfile": [
331
+ {
332
+ "name": "id",
333
+ "timestamp": "<ISO 8601 UTC>",
334
+ "valuesCount": 1000000.0,
335
+ "nullCount": 0.0,
336
+ "mean": 500000.5,
337
+ "stddev": 288675.1,
338
+ "min": 1.0,
339
+ "firstQuartile": 250000.75,
340
+ "median": 500000.5,
341
+ "thirdQuartile": 750000.25,
342
+ "max": 1000000.0,
343
+ "uniqueCount": null,
344
+ "top": null,
345
+ "freq": null,
346
+ "distribution": null
347
+ },
348
+ {
349
+ "name": "ts",
350
+ "timestamp": "<ISO 8601 UTC>",
351
+ "valuesCount": 1000000.0,
352
+ "nullCount": 0.0,
353
+ "min": "2026-09-01T02:04:50+00:00",
354
+ "max": "2026-11-06T13:24:03+00:00",
355
+ "distribution": {
356
+ "grain": "sub-daily",
357
+ "bucketing": "UTC",
358
+ "partialDay": "2026-11-06",
359
+ "partialMonth": "2026-11",
360
+ "daysWithData": 66,
361
+ "missingDays": 1,
362
+ "largestGap": {"start": "2026-10-04", "end": "2026-10-04", "days": 1},
363
+ "gaps": [{"start": "2026-10-04", "end": "2026-10-04", "count": 1}],
364
+ "rowsPerDay": {"min": 9800, "p5": 14100.5, "median": 15200, "p95": 16050.25, "max": 17000},
365
+ "year": {"2026": 1000000},
366
+ "month": {"2026-09": 450000, "2026-10": 460000, "2026-11": 90000},
367
+ "day": {"2026-09-01": 9800, "...": 0},
368
+ "weekday": {"Mon": 142000, "Tue": 143000, "Wed": 144000, "Thu": 143500, "Fri": 142500, "Sat": 142000, "Sun": 143000},
369
+ "hour": {"00": 41000, "...": 0, "23": 41500}
370
+ }
371
+ }
372
+ ]
373
+ },
374
+ "sampleData": {
375
+ "columns": ["id", "name"],
376
+ "rows": [[1, "alice"], [2, "bob"]]
377
+ },
378
+ "extension": {
379
+ "deltaHistory": [
380
+ {
381
+ "version": 5,
382
+ "timestamp": "<ISO 8601 UTC>",
383
+ "operation": "WRITE",
384
+ "parameters": {"mode": "Append"},
385
+ "description": null,
386
+ "engine_info": "Delta-Rust/0.19.0"
387
+ }
388
+ ]
389
+ },
390
+ "partitionColumns": [],
391
+ "commitCount": 84142,
392
+ "freshness": {"lastWrite": "<ISO 8601 UTC>", "secondsSinceLastWrite": 900, "typicalIntervalSeconds": 3600, "intervalGaps": 49, "burstGapSeconds": 60, "dataCommits": 812, "ratio": 0.25}
393
+ }
394
+ ```
395
+
396
+ `description` is `null` when the Delta table has no description. `sampleData` is `null` when `--sample-data-count` is `0` or the table is excluded. `extension` is `null` when the Delta table has no history. `freshness` is `null` when the history has no data-changing commits. `"..."` in the example above stands for the omitted per-day and per-hour entries.
397
+
398
+ ---
399
+
400
+ ### LLM docs (`llms.txt` + `tables/{path}.md`)
401
+
402
+ Written at the end of `generate`, next to the JSON, so the same static server exposes them. Point an agent with a fetch tool at `<server>/llms.txt` and it can follow the links to each table.
403
+
404
+ - `llms.txt` — one section per group (the fully qualified name minus the table name, e.g. `gadgetbridge.gold`), one linked line per table with its description, row and column counts, and the age of its last write compared to its typical write interval.
405
+ - `tables/{path}.md` — description, row/column counts, partition columns, location, total Delta commits, last write and typical write interval, schema, statistics (nulls, distinct, min, mean, max, most common), a time distribution per date/timestamp column (grain, bucketing time zone, rows per day, gaps, a year × month grid with the partial month marked, rows by weekday and hour; a column with the same per-day counts as another is collapsed to one line), sample rows, the last 5 history entries, and links to the other tables in the same group, to the table's JSON and back to `llms.txt`.
406
+
407
+ Links are relative to the file they appear in, unless `--base-url` is given. Use `--base-url` when the files will be pasted into a chat rather than fetched, so the links still resolve. If the docs are published where a cloud-hosted LLM can reach them, consider generating without `--sample-data-count`.
408
+
409
+ ---
410
+
411
+ ## Missing Features
412
+
413
+ - Non-Delta table formats (Parquet, Iceberg, Hudi, etc.)
414
+
415
+ ## Out of scope
416
+
417
+ - Tables with zero columns
418
+ - Incremental / append-only catalog updates
419
+ - S3 credential configuration: only the endpoint (`AWS_ENDPOINT_URL_S3`) is passed explicitly; credentials come from the standard `AWS_*` environment variables, read by `fsspec`/`s3fs` when scanning and by `deltalake`/Polars when reading tables
420
+ - Column ordering guarantees beyond schema order