ripple-sql 0.1.16__tar.gz → 0.1.18__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 (114) hide show
  1. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/CHANGELOG.md +33 -1
  2. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/PKG-INFO +27 -11
  3. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/README.md +26 -10
  4. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/pyproject.toml +5 -2
  5. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/answer_page.py +36 -0
  6. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/check_file/__init__.py +64 -15
  7. ripple_sql-0.1.18/src/ripple/check_file/binding.py +423 -0
  8. ripple_sql-0.1.18/src/ripple/check_file/declared.py +99 -0
  9. ripple_sql-0.1.18/src/ripple/check_file/header_sense.py +47 -0
  10. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/check_file/readers.py +9 -1
  11. ripple_sql-0.1.18/src/ripple/check_file/report.py +234 -0
  12. ripple_sql-0.1.18/src/ripple/check_file/text.py +310 -0
  13. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/ci.py +8 -1
  14. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/cli.py +105 -66
  15. ripple_sql-0.1.18/src/ripple/cli_files.py +181 -0
  16. ripple_sql-0.1.18/src/ripple/dbt_catalog.py +79 -0
  17. ripple_sql-0.1.18/src/ripple/digest.py +55 -0
  18. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/doctor.py +2 -1
  19. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/cte_tracing.py +10 -2
  20. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/select_sources.py +1 -1
  21. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/sql_script.py +5 -2
  22. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/graph.py +17 -3
  23. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/home.py +3 -2
  24. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/loaders/dbt.py +54 -0
  25. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/loaders/dbt_config.py +112 -32
  26. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/loaders/identity.py +97 -0
  27. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/loaders/types.py +3 -0
  28. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/mcp_handlers.py +13 -0
  29. ripple_sql-0.1.18/src/ripple/mcp_server.py +334 -0
  30. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/mcp_tools.py +22 -0
  31. ripple_sql-0.1.18/src/ripple/notify/__init__.py +10 -0
  32. ripple_sql-0.1.18/src/ripple/notify/config.py +254 -0
  33. ripple_sql-0.1.18/src/ripple/notify/formats.py +208 -0
  34. ripple_sql-0.1.18/src/ripple/notify/safe.py +95 -0
  35. ripple_sql-0.1.18/src/ripple/notify/send.py +106 -0
  36. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/project.py +2 -0
  37. ripple_sql-0.1.18/src/ripple/review_reasons.py +81 -0
  38. ripple_sql-0.1.18/src/ripple/schema_drop.py +121 -0
  39. ripple_sql-0.1.18/src/ripple/schema_fetch.py +461 -0
  40. ripple_sql-0.1.18/src/ripple/schemas.py +236 -0
  41. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/server.py +79 -16
  42. ripple_sql-0.1.18/src/ripple/service_warehouse.py +223 -0
  43. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/sourcefiles.py +11 -0
  44. ripple_sql-0.1.18/src/ripple/static/answer.css +401 -0
  45. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/static/answer.html +215 -63
  46. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/static/check.js +18 -14
  47. ripple_sql-0.1.18/src/ripple/static/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  48. ripple_sql-0.1.18/src/ripple/static/fonts/OFL-jetbrains-mono.txt +93 -0
  49. ripple_sql-0.1.18/src/ripple/static/fonts/atkinson-hyperlegible-next-latin.woff2 +0 -0
  50. ripple_sql-0.1.18/src/ripple/static/fonts/jetbrains-mono-latin.woff2 +0 -0
  51. ripple_sql-0.1.18/src/ripple/static/home.js +186 -0
  52. ripple_sql-0.1.18/src/ripple/static/ingest.js +294 -0
  53. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/usage/collect.py +40 -8
  54. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/usage/discover.py +4 -0
  55. ripple_sql-0.1.18/src/ripple/warehouse_columns.py +313 -0
  56. ripple_sql-0.1.18/src/ripple/warehouse_memory.py +56 -0
  57. ripple_sql-0.1.18/src/ripple/watch.py +462 -0
  58. ripple_sql-0.1.18/src/ripple/watch_store.py +225 -0
  59. ripple_sql-0.1.16/src/ripple/check_file/binding.py +0 -275
  60. ripple_sql-0.1.16/src/ripple/check_file/report.py +0 -430
  61. ripple_sql-0.1.16/src/ripple/mcp_server.py +0 -154
  62. ripple_sql-0.1.16/src/ripple/schemas.py +0 -155
  63. ripple_sql-0.1.16/src/ripple/static/answer.css +0 -262
  64. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/.gitignore +0 -0
  65. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/LICENSE +0 -0
  66. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/__init__.py +0 -0
  67. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/answer.py +0 -0
  68. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/cache.py +0 -0
  69. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/check_file/naming.py +0 -0
  70. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/ci_signature.py +0 -0
  71. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/__init__.py +0 -0
  72. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/budget.py +0 -0
  73. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/column_lineage.py +0 -0
  74. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/column_ref.py +0 -0
  75. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/dependencies.py +0 -0
  76. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/dialect.py +0 -0
  77. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/dispatch.py +0 -0
  78. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/extraction.py +0 -0
  79. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/generators.py +0 -0
  80. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/jinja.py +0 -0
  81. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/json_sources.py +0 -0
  82. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/macro_source.py +0 -0
  83. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/pipeline.py +0 -0
  84. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/preprocess.py +0 -0
  85. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/safe_gen.py +0 -0
  86. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/schema_qualification.py +0 -0
  87. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/scope.py +0 -0
  88. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/statement.py +0 -0
  89. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/tech_debt.py +0 -0
  90. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/tsql_catalog.py +0 -0
  91. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/tsql_scalar_vars.py +0 -0
  92. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/tsql_tvf.py +0 -0
  93. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/tsql_xml.py +0 -0
  94. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/types.py +0 -0
  95. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/unused_deps.py +0 -0
  96. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/engine/validation.py +0 -0
  97. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/loaders/__init__.py +0 -0
  98. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/loaders/sidecar.py +0 -0
  99. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/loaders/sqldir.py +0 -0
  100. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/lookml.py +0 -0
  101. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/names.py +0 -0
  102. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/py.typed +0 -0
  103. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/render.py +0 -0
  104. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/render_shims.py +0 -0
  105. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/semantic.py +0 -0
  106. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/star_resolution.py +0 -0
  107. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/static/answer_twin.js +0 -0
  108. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/static/explore.js +0 -0
  109. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/static/find.js +0 -0
  110. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/static/focus.js +0 -0
  111. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/usage/__init__.py +0 -0
  112. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/usage/cli.py +0 -0
  113. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/usage/ingest.py +0 -0
  114. {ripple_sql-0.1.16 → ripple_sql-0.1.18}/src/ripple/usage/report.py +0 -0
@@ -6,6 +6,36 @@ Notable changes to Ripple. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.18] - 2026-10-07
10
+
11
+ ### Added
12
+ - The file check reaches people wherever they work. `ripple.yml` names destinations once, each pointing at an environment variable that holds the webhook URL (a URL in the file is refused). Each feed routes beside its `files:` pattern: `notify: {sender, team, contact, on}`, so the uploader gets the plain message and the data team the report. Slack, Teams Workflows and Discord get their own card shapes; anything else gets one stable JSON object for Zapier, Make, n8n or Power Automate. Old Office 365 connector URLs are refused by name. `ripple check-file --notify` sends; `--markdown` prints a pull request comment body; `--json` gains `schema_version` and `notify`. `ripple notify-test NAME` posts a labeled test card.
13
+ - `ripple watch DIR` is the landing-folder gate as part of the product: incoming/ to ready/, rejected/ (with the sender's message and the report beside the file) or held/, one alert per file, a daily reminder for files left waiting, a failed alert left beside the file with exit 1. One pass for cron or Task Scheduler, `--every` to loop, `--dry-run` to preview. Stdlib only, so it runs on Windows.
14
+ - A file whose every header was retitled in Google Sheets style ("Email Domain" for email_domain) now binds by header style instead of being refused. It stops honestly, since a loader that keeps headers as written lands none of those columns, and tells the sender the names to rename to. `normalize: fivetran` on the source passes it.
15
+ - When `ripple serve` does not read the warehouse by itself, the terminal and the page say why and what to do, for example "warehouse unknown · run with --dialect snowflake, or add profiles.yml beside dbt_project.yml".
16
+ - Warehouse columns fill in by themselves. Ripple reads dbt's `target/catalog.json` (written by `dbt docs generate` or `--write-catalog`) on every run and attaches each source's real columns to the project's own sources: attach only, never a new relation, never over a model's own SQL, never over `.ripple/schemas.json`, and not when the catalog names another physical relation than the project's. When the project is on Snowflake, BigQuery or Databricks and the user's `snow`, `bq` or `databricks` CLI is installed and signed in, `ripple serve` and the MCP server read `information_schema.columns` for the tables that have no columns, in the background, save them to `.ripple/schemas.json` with `origin` and `fetched_at`, and rebuild while the old graph keeps answering; the page shows "Snowflake · prod · reading columns", then the landing with the old figures kept and Undo. It starts on its own only with a known connection (the user's default, the only one, or one picked before, remembered in the user's cache) that signs in without a browser; otherwise the page offers one button, "Fetch from Snowflake · prod", or a connection picker. Names are checked against plain identifier patterns before they reach SQL; results are matched back to the exact database, schema and table; a table whose name differs only by case is skipped; `bq` gets an explicit row cap and a result that reaches it is not saved; a CLI that fails or prints anything but complete JSON saves nothing. Fetched columns replace the old list (a dropped column does not live on), are read again after a week, and a table the connection cannot see is not asked for again for a week; a failed fetch waits 30 minutes before trying on its own. `schemas.json` writes are locked and atomic. New: `ripple fetch-schema`, the MCP tool `fetch_schema`, MCP `instructions` at connect time telling the agent how columns arrive, `lineage_summary.warehouse`, the page's "where columns came from" line (source, tables, age) and a "fully analyzed" count with its gain in the ledger after a landing. `RIPPLE_WAREHOUSE=off` turns the warehouse read off.
17
+ - Drop the warehouse's column list on the page and watch the count move. Two review classes shrink when Ripple knows the columns of tables the project reads but does not own; the page now takes that list itself. Under the reasons it fixes sits one dashed target, "Add warehouse columns · information_schema.columns · csv or json", with "Copy query" (the query names the schemas the gaps live in, in the project's dialect) and "show" to read it first. Drop the export (or pick it; a column list dropped anywhere on the page is offered with one button, and a file that is not one, dropped on the target, offers "Check this file"). The local server reads the whole file, keeps only the tables the project reads (schema-qualified when the export carries `table_schema`), saves them to `.ripple/schemas.json`, and rebuilds in the background while the old graph keeps answering (an answer given meanwhile says "answered before the column import"). The target shows the real count, "Rebuilding · 712 of 1,908 models", from a build callback (`LineageGraph.build(project, on_progress=...)`). When it lands, the numbers change in place with the old figure kept beside them ("2,810 need review · was 3,356", "+4,688" on column links, "12 · was 315" on the rows, a row that reached zero stays as "0 · was 231") and the target becomes the receipt: "cols.csv · 674 tables · 213 models now fully analyzed · Undo". Undo puts the file back as it was, if nothing else touched it since, and rebuilds again. A second drop of the same export says "already included · no changes" and rebuilds nothing; a file with no matching tables saves nothing. Routes: `POST /api/ingest`, `POST /api/undo`, `GET /api/status`, `GET /api/home`. The server now writes exactly one file, `.ripple/schemas.json`, and only from the page it opened. `ripple ingest-schema` reads `table_schema` too, so a real export's `gitlab_dotcom.users` matches the read. Three seats (Codex, Sonnet, Claude) settled the words and the states in a design debate.
18
+
19
+ ### Changed
20
+ - The file check's wording moved from check_file/report.py to check_file/text.py, which was at its 500-line budget.
21
+ - The review count says why. GitLab's data project showed "3356 need review" and 2,706 of those links carried no reason: their house style names a CTE after the model it reads (`with prep_action as (select ... from prep_action)`), the engine marks that as a CTE shadowing a real table, and the mark never reached the link. Every link at review trust now carries a sentence (the engine's own word where it has one: the shadowing CTE, a column a CTE does not name and passes through to its only source, a script transform; "traced at low confidence" otherwise), and the sentences fold into a few classes in one module, each named for the project that produced it. `ripple` prints the classes under the count with a number and the command that shrinks the class (`ripple unresolved`, `ripple ingest-schema cols.csv`, `ripple --json`); `lineage_summary` and the served page carry the same rows as `review_reasons`; on the page the count is a button that opens the list.
22
+ - The home of a large project is a browser, not a listing. GitLab's data project (1,908 models) opened on forty folder rows of chips with "+ 695 more" links. It now opens on the search (⌘K, results most-read first), a folder tree on the left with counts, the hubs (the models most of the project is built on) and the chosen folder as a ranked table in the middle (columns, read by, feeds from, sortable, a status dot per row), and the selected model on the right with its three numbers, its most-read columns (click one to ask) and the way to its lineage. Enter on a row opens its lineage; the last three questions asked in this browser come back as "Continue" chips. A small project gets the same page with a tree of one folder. The typeahead sorts its candidates most-read first before capping matches, so on a big project the hubs come first instead of the first 400 source tables.
23
+ - Ripple's own identity, from the name. A ripple is a drop and its rings. The drop is the one color on the page, a vermilion that marks only the change you asked about (the chosen column, the Ask button); the rings are off-white, like light on dark water, and carry everything the change reaches (wires, the lit chain, the selected row). Wires fade with distance the way rings do: the first hop bright, the second dimmer, the third faint. The mark is rings and the pulse on the chosen column is one ring leaving it. The ground is a warm charcoal; surfaces are flat with one border, no blur, no glow, no gradient, no grid, one small radius. Two open-license faces ship inside the package and are embedded in the page as data URIs, Atkinson Hyperlegible Next for words and JetBrains Mono for names (64 KB together, latin subsets, SIL Open Font License files beside them), so a served page and the offline file look the same on every machine and still load nothing from the network. The top block is one object: the project name in mono beside the rings mark, the counts as a ledger, and the question as a grouped bar with the drop mark inside it, a segmented direction (flipping it re-asks the question on screen) and Ask attached; one drawn ripple sits top right, the only decoration on the page. The copy is cut to labels: the file box says "Check a file · csv, tsv, xlsx or parquet, header only", the legend says built from, dashboard, review, the text bar says Text, Show, Copy, the chip rows carry no captions, and the selected model on the home is one sentence, not tiles.
24
+ - The answer page's look. The headline is now the user's own column and what happens to it, one line of type: `net_arr changes 3 columns in 3 models and 1 dashboard number.` ("at least" when truncated, "changes nothing downstream." when nothing does), with the full name, the deepest hop and the review count under it. The page is dark: a spotlight ground with two soft lights that drift, frosted glass panels and chips with a lit top edge, and one signal red on the chosen column (solid fill, a ring and a glow), the wires that leave it, and the Ask button. The panel floats up when an answer lands, the chosen column pulses once, the first-hop wires draw once, chips lift on hover; all of it is off under reduced motion. The board gets a hop ruler (one hairline across the columns with a tick where each hop starts), model rows with a rule instead of cards, a dashed review outline with no fill, and hover that lights one chain back to the chosen column while only the other wires dim, so chips and review notes stay readable; a click pins the chain and Escape clears it. On an answer the file-check box becomes one quiet row at the foot of the panel. The coverage row says answers work without the warehouse's column list. Every text token clears 4.5:1 on what it sits on. DESIGN.md describes the result; a three-seat design debate produced the headline, the ruler and the hover rules, and the owner picked the material from three rendered directions.
25
+
26
+ ### Fixed
27
+ - A file that fits no table named every checkable table in one line (28,886 characters on GitLab). It now names five, the closest fit first, and how many more.
28
+ - `ripple fetch-schema` no longer calls a project complete when it only had nothing it could ask about.
29
+ - The summary says "1 link needs review".
30
+ - The file check counts the columns the project reads. It diffed a file only against a source's yml-declared columns and refused otherwise, while the lineage graph already held the columns every staging model reads from that table. Found on GitLab's data team project (2021 snapshot), which loads finance spreadsheets through sheetload: 29 of 686 source tables declare yml columns, 0 of the 93 sheetload tables do, and the check refused every one of them with the six column names of the net ARR sheet in hand. The expected header is now the declared columns plus the columns downstream reads, from one helper that the report, the header binding and the table list all use; the refusal is kept for a table nothing declares and nothing reads. `ripple check-file --source sheetload.net_arr_net_iacv_conversion_factors` on that project now reports the dropped and renamed columns with their readers and writes the sender's message.
31
+ - The dialect sniffer no longer reads a regex literal as a psql meta-command. `\d` inside `REGEXP_CONTAINS(app_version, r'^(\d+\.\d+')` matched the psql short-circuit and returned postgres before BigQuery's markers were weighed (14 points to 3), so Mozilla's bigquery-etl parsed as postgres: 1,464 of its 1,826 tables analyzed in full, against 1,551 once read as BigQuery. A meta-command begins a line; the check is anchored there.
32
+ - On Windows, a warehouse CLI installed as a `.cmd` or `.bat` shim (gcloud ships `bq.cmd`) got only the first line of a multi-line query, because cmd.exe ends a batch file's command line at the first newline. Ripple now puts each argument to a shim on one line; a real executable gets its arguments unchanged.
33
+
34
+ ## [0.1.17] - 2026-09-22
35
+
36
+ ### Fixed
37
+ - A dbt node whose artifact row carries no compiled SQL now reads the compiled file dbt wrote on disk (`target/compiled/<package>/<path>`, or the row's own `compiled_path`, in either path spelling) before falling back to the raw jinja. Found running the dbt 2.0 demo on jaffle-shop: dbt-oss 2.0.4 left `compiled_code` empty in every Parquet row while `target/compiled/` held every model, so Ripple silently read raw jinja and a `date_spine` macro became an unresolved table (128 links and one link flagged instead of the manifest's 132). A parse-only manifest gets the same treatment. The file answers only when it is at least as new as the model's own source (found under the package that owns it), the macros and `dbt_project.yml`, so a compile left behind an edit never stands in for the edited SQL. A two-model review of the change found the package source path, the macro edits and the Windows separators; all fixed.
38
+
9
39
  ## [0.1.16] - 2026-09-21
10
40
 
11
41
  ### Added
@@ -324,7 +354,9 @@ First public release.
324
354
  ### Removed
325
355
  - The dark whole-graph canvas that `ripple serve` used to open (`static/index.html`). Every surface now draws one answer.
326
356
 
327
- [Unreleased]: https://github.com/bteh/ripple/compare/v0.1.16...HEAD
357
+ [Unreleased]: https://github.com/bteh/ripple/compare/v0.1.18...HEAD
358
+ [0.1.18]: https://github.com/bteh/ripple/compare/v0.1.17...v0.1.18
359
+ [0.1.17]: https://github.com/bteh/ripple/compare/v0.1.16...v0.1.17
328
360
  [0.1.16]: https://github.com/bteh/ripple/releases/tag/v0.1.16
329
361
  [0.1.15]: https://github.com/bteh/ripple/releases/tag/v0.1.15
330
362
  [0.1.14]: https://github.com/bteh/ripple/releases/tag/v0.1.14
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ripple-sql
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: Offline column-level SQL lineage. See what breaks before you merge.
5
5
  Project-URL: Homepage, https://github.com/bteh/ripple
6
6
  Project-URL: Repository, https://github.com/bteh/ripple
@@ -232,19 +232,25 @@ Ripple is built to be driven by an agent, so the line between what is parsed and
232
232
  is written down and enforced: the engine never guesses, the agent may, and you always know which one
233
233
  is talking. [`docs/AI-BOUNDARY.md`](docs/AI-BOUNDARY.md) is the rule and the reasoning.
234
234
 
235
- ## Bring your warehouse's schemas
235
+ ## Your warehouse's columns, filled in for you
236
236
 
237
- Ripple never connects to your warehouse. But your SQL probably reads tables defined outside the repo, and without their columns those models stop at `SELECT *`. Fix: your agent already has warehouse access, so let it fill the gap. Ask it to "resolve ripple's unresolved tables" and it will call `unresolved_tables`, fetch the columns from `information_schema.columns`, and hand them back via `ingest_schema`. They land in `.ripple/schemas.json`, which you can commit so the whole team gets the coverage.
237
+ Your SQL probably reads tables defined outside the repo, and without their columns those models stop at `SELECT *`. Ripple fills those columns by itself, from two places, with nothing to export:
238
238
 
239
- The credential boundary is the point. Your agent holds the connection; Ripple never does.
239
+ - **dbt's `target/catalog.json`.** `dbt docs generate` (or `--write-catalog` on dbt 2) already wrote every source's real columns there. Ripple reads it on every run.
240
+ - **Your own warehouse CLI.** When the project is on Snowflake, BigQuery or Databricks and `snow`, `bq` or `databricks` is installed and signed in, `ripple serve` and the MCP server read `information_schema.columns` for the missing tables through it, in the background, and the page shows the count move. Only table and column names are read. Columns older than a week are read again.
240
241
 
241
- No agent? Same thing by hand:
242
+ Ripple never stores, prompts for, or sends a credential, and never opens a connection of its own: it runs the CLI you already signed in to, with the connection you marked default (or the only one). It starts on its own only when signing in needs no browser; otherwise the page shows one button, "Fetch from Snowflake · prod". Several connections and no default: the page asks once and remembers the name in your cache, not in the repo. `RIPPLE_WAREHOUSE=off` turns it off.
243
+
244
+ The columns land in `.ripple/schemas.json` with where they came from and when, which you can commit so the whole team gets the coverage. From a terminal:
242
245
 
243
246
  ```bash
244
- ripple unresolved # which tables block the most coverage, worst first
245
- ripple ingest-schema cols.csv # CSV with a table,column header (or JSON, or - for stdin)
247
+ ripple fetch-schema # read missing columns through your CLI, print what moved
248
+ ripple unresolved # which tables still block coverage, worst first
249
+ ripple ingest-schema cols.csv # no CLI? an information_schema.columns export works too
246
250
  ```
247
251
 
252
+ An agent connected over MCP is told all of this when it connects: it calls `fetch_schema`, or, when Ripple cannot read the warehouse and the agent can, queries `information_schema.columns` itself and hands the names to `ingest_schema`.
253
+
248
254
  ## A file arrives from another team
249
255
 
250
256
  Vendors and business units hand over CSVs and spreadsheets by hand. When a column is renamed or dropped in one of them, the pipeline loads it anyway and dashboards go blank the next morning. Before loading, ask Ripple what this exact file breaks:
@@ -308,6 +314,15 @@ The same check is on the `ripple serve` page: drop the file anywhere on it (or c
308
314
 
309
315
  When the loader got there first, check the table that landed instead of a file: `ripple check-file --columns -` reads the column names from stdin (an `information_schema` query), `--columns "a,b,c"` takes them inline, and the MCP tool takes `columns=[...]`. Where to put all of this in a pipeline, with a gate script that moves files between `incoming/`, `ready/` and `rejected/`: [docs/GUARDRAIL.md](docs/GUARDRAIL.md).
310
316
 
317
+ To tell the person who sent it, and the data team, without anyone forwarding
318
+ anything: name a Slack, Teams or Discord channel (or a Zapier, Make, n8n or
319
+ Power Automate hook for email) in `ripple.yml`, point it at an environment
320
+ variable, and run `ripple watch` on the folder files land in. Power Automate
321
+ hosts receive Teams cards by default; set `kind: json` for a custom flow
322
+ that expects JSON. Setup is in
323
+ [docs/GUARDRAIL.md](docs/GUARDRAIL.md#who-hears-about-it).
324
+
325
+
311
326
  ## Bring your query history (optional)
312
327
 
313
328
  Your SQL files say what is supposed to happen. The warehouse's query log says what
@@ -362,10 +377,11 @@ difference; Ripple reads the repo, not the scheduler.
362
377
  engine. The manifest is still schema v12; when a project turns it off
363
378
  (`--no-write-json`) and writes the Parquet information schema instead
364
379
  (`--generate-info-schema`), Ripple reads `target/info_schema/v1/` through
365
- the same path (`pip install pyarrow`). dbt 2.0's own column-level lineage
366
- needs the full dbt distribution, a dbt Labs login and a warehouse
367
- connection; the open-source engine writes `dbt.column_lineage.parquet`
368
- empty. Ripple needs none of those.
380
+ the same path (`pip install pyarrow`); when a build leaves the compiled
381
+ SQL only under `target/compiled/`, Ripple reads it from there. dbt 2.0's
382
+ own column-level lineage needs the full dbt distribution, a dbt Labs login
383
+ and a warehouse connection; the open-source engine writes
384
+ `dbt.column_lineage.parquet` empty. Ripple needs none of those.
369
385
 
370
386
  **Dialects:** Snowflake, BigQuery, Databricks, Redshift, Postgres, DuckDB,
371
387
  Spark SQL. Detected per project, override with `--dialect`. Powered by
@@ -191,19 +191,25 @@ Ripple is built to be driven by an agent, so the line between what is parsed and
191
191
  is written down and enforced: the engine never guesses, the agent may, and you always know which one
192
192
  is talking. [`docs/AI-BOUNDARY.md`](docs/AI-BOUNDARY.md) is the rule and the reasoning.
193
193
 
194
- ## Bring your warehouse's schemas
194
+ ## Your warehouse's columns, filled in for you
195
195
 
196
- Ripple never connects to your warehouse. But your SQL probably reads tables defined outside the repo, and without their columns those models stop at `SELECT *`. Fix: your agent already has warehouse access, so let it fill the gap. Ask it to "resolve ripple's unresolved tables" and it will call `unresolved_tables`, fetch the columns from `information_schema.columns`, and hand them back via `ingest_schema`. They land in `.ripple/schemas.json`, which you can commit so the whole team gets the coverage.
196
+ Your SQL probably reads tables defined outside the repo, and without their columns those models stop at `SELECT *`. Ripple fills those columns by itself, from two places, with nothing to export:
197
197
 
198
- The credential boundary is the point. Your agent holds the connection; Ripple never does.
198
+ - **dbt's `target/catalog.json`.** `dbt docs generate` (or `--write-catalog` on dbt 2) already wrote every source's real columns there. Ripple reads it on every run.
199
+ - **Your own warehouse CLI.** When the project is on Snowflake, BigQuery or Databricks and `snow`, `bq` or `databricks` is installed and signed in, `ripple serve` and the MCP server read `information_schema.columns` for the missing tables through it, in the background, and the page shows the count move. Only table and column names are read. Columns older than a week are read again.
199
200
 
200
- No agent? Same thing by hand:
201
+ Ripple never stores, prompts for, or sends a credential, and never opens a connection of its own: it runs the CLI you already signed in to, with the connection you marked default (or the only one). It starts on its own only when signing in needs no browser; otherwise the page shows one button, "Fetch from Snowflake · prod". Several connections and no default: the page asks once and remembers the name in your cache, not in the repo. `RIPPLE_WAREHOUSE=off` turns it off.
202
+
203
+ The columns land in `.ripple/schemas.json` with where they came from and when, which you can commit so the whole team gets the coverage. From a terminal:
201
204
 
202
205
  ```bash
203
- ripple unresolved # which tables block the most coverage, worst first
204
- ripple ingest-schema cols.csv # CSV with a table,column header (or JSON, or - for stdin)
206
+ ripple fetch-schema # read missing columns through your CLI, print what moved
207
+ ripple unresolved # which tables still block coverage, worst first
208
+ ripple ingest-schema cols.csv # no CLI? an information_schema.columns export works too
205
209
  ```
206
210
 
211
+ An agent connected over MCP is told all of this when it connects: it calls `fetch_schema`, or, when Ripple cannot read the warehouse and the agent can, queries `information_schema.columns` itself and hands the names to `ingest_schema`.
212
+
207
213
  ## A file arrives from another team
208
214
 
209
215
  Vendors and business units hand over CSVs and spreadsheets by hand. When a column is renamed or dropped in one of them, the pipeline loads it anyway and dashboards go blank the next morning. Before loading, ask Ripple what this exact file breaks:
@@ -267,6 +273,15 @@ The same check is on the `ripple serve` page: drop the file anywhere on it (or c
267
273
 
268
274
  When the loader got there first, check the table that landed instead of a file: `ripple check-file --columns -` reads the column names from stdin (an `information_schema` query), `--columns "a,b,c"` takes them inline, and the MCP tool takes `columns=[...]`. Where to put all of this in a pipeline, with a gate script that moves files between `incoming/`, `ready/` and `rejected/`: [docs/GUARDRAIL.md](docs/GUARDRAIL.md).
269
275
 
276
+ To tell the person who sent it, and the data team, without anyone forwarding
277
+ anything: name a Slack, Teams or Discord channel (or a Zapier, Make, n8n or
278
+ Power Automate hook for email) in `ripple.yml`, point it at an environment
279
+ variable, and run `ripple watch` on the folder files land in. Power Automate
280
+ hosts receive Teams cards by default; set `kind: json` for a custom flow
281
+ that expects JSON. Setup is in
282
+ [docs/GUARDRAIL.md](docs/GUARDRAIL.md#who-hears-about-it).
283
+
284
+
270
285
  ## Bring your query history (optional)
271
286
 
272
287
  Your SQL files say what is supposed to happen. The warehouse's query log says what
@@ -321,10 +336,11 @@ difference; Ripple reads the repo, not the scheduler.
321
336
  engine. The manifest is still schema v12; when a project turns it off
322
337
  (`--no-write-json`) and writes the Parquet information schema instead
323
338
  (`--generate-info-schema`), Ripple reads `target/info_schema/v1/` through
324
- the same path (`pip install pyarrow`). dbt 2.0's own column-level lineage
325
- needs the full dbt distribution, a dbt Labs login and a warehouse
326
- connection; the open-source engine writes `dbt.column_lineage.parquet`
327
- empty. Ripple needs none of those.
339
+ the same path (`pip install pyarrow`); when a build leaves the compiled
340
+ SQL only under `target/compiled/`, Ripple reads it from there. dbt 2.0's
341
+ own column-level lineage needs the full dbt distribution, a dbt Labs login
342
+ and a warehouse connection; the open-source engine writes
343
+ `dbt.column_lineage.parquet` empty. Ripple needs none of those.
328
344
 
329
345
  **Dialects:** Snowflake, BigQuery, Databricks, Redshift, Postgres, DuckDB,
330
346
  Spark SQL. Detected per project, override with `--dialect`. Powered by
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ripple-sql"
3
- version = "0.1.16"
3
+ version = "0.1.18"
4
4
  description = "Offline column-level SQL lineage. See what breaks before you merge."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -76,5 +76,8 @@ line-length = 100
76
76
  target-version = "py310"
77
77
 
78
78
  [tool.ruff.lint]
79
- select = ["E", "F", "I", "UP", "B", "SIM"]
79
+ select = ["E", "F", "I", "UP", "B", "SIM", "PLW1514"]
80
+ # PLW1514 is preview-only; explicit keeps other preview rules out of E/F/...
81
+ preview = true
82
+ explicit-preview-rules = true
80
83
  ignore = ["E501"] # line length is the formatter's job, not the linter's
@@ -27,6 +27,39 @@ EXPLORE = Path(__file__).parent / "static" / "explore.js"
27
27
  FOCUS = Path(__file__).parent / "static" / "focus.js"
28
28
  FIND = Path(__file__).parent / "static" / "find.js"
29
29
  CHECK = Path(__file__).parent / "static" / "check.js"
30
+ HOME = Path(__file__).parent / "static" / "home.js"
31
+ INGEST = Path(__file__).parent / "static" / "ingest.js"
32
+ FONTS = Path(__file__).parent / "static" / "fonts"
33
+ # the two faces ship inside the package (SIL Open Font License, see the OFL
34
+ # files beside them) and are embedded in the page, so a served page and the
35
+ # offline file look the same and neither loads anything from the network
36
+ FONT_FACES = (
37
+ ("Atkinson Hyperlegible Next", "atkinson-hyperlegible-next-latin.woff2", "400 700"),
38
+ ("JetBrains Mono", "jetbrains-mono-latin.woff2", "400 700"),
39
+ )
40
+ _font_css: str | None = None
41
+
42
+
43
+ def font_css() -> str:
44
+ """@font-face rules with the font files as data URIs, read once."""
45
+ global _font_css
46
+ if _font_css is None:
47
+ import base64
48
+
49
+ rules = []
50
+ for family, name, weights in FONT_FACES:
51
+ path = FONTS / name
52
+ if not path.is_file():
53
+ continue
54
+ data = base64.b64encode(path.read_bytes()).decode("ascii")
55
+ rules.append(
56
+ f'@font-face{{font-family:"{family}";font-style:normal;font-weight:{weights};'
57
+ f'font-display:swap;src:url(data:font/woff2;base64,{data}) format("woff2")}}'
58
+ )
59
+ _font_css = "\n".join(rules)
60
+ return _font_css
61
+
62
+
30
63
  EMBED_BUDGET_ENV = "RIPPLE_EMBED_GRAPH_BYTES"
31
64
  DEFAULT_EMBED_BUDGET = 5_000_000
32
65
  TRUST_CODE = {"verified": "v", "high_confidence": "h", "moderate": "m", "review_required": "r"}
@@ -161,12 +194,15 @@ def _page(data: dict, title: str) -> str:
161
194
  blob = json.dumps(data, separators=(",", ":")).replace("</", "<\\/")
162
195
  html = (
163
196
  TEMPLATE.read_text(encoding="utf-8")
197
+ .replace("/*FONTS*/", font_css())
164
198
  .replace("/*CSS*/", STYLE.read_text(encoding="utf-8"))
165
199
  .replace("/*TWIN*/", TWIN.read_text(encoding="utf-8"))
166
200
  .replace("/*EXPLORE*/", EXPLORE.read_text(encoding="utf-8"))
167
201
  .replace("/*FOCUS*/", FOCUS.read_text(encoding="utf-8"))
168
202
  .replace("/*FIND*/", FIND.read_text(encoding="utf-8"))
169
203
  .replace("/*CHECK*/", CHECK.read_text(encoding="utf-8"))
204
+ .replace("/*HOME*/", HOME.read_text(encoding="utf-8"))
205
+ .replace("/*INGEST*/", INGEST.read_text(encoding="utf-8"))
170
206
  )
171
207
  # one pass: a title that happens to contain "__DATA__" (a column named
172
208
  # so) must not have the data block spliced into it, and the data block
@@ -1,8 +1,9 @@
1
1
  """A file arrives from an external team: what breaks if it is loaded.
2
2
 
3
3
  The file feeds one landing table the graph already knows. Its header is
4
- diffed against the table's declared columns, and every column that is gone
5
- gets the blast radius `ripple breaks` would give it. Header only: no data
4
+ diffed against the table's columns, the ones its yml or an ingested schema
5
+ declares and the ones the project's SQL reads, and every column that is
6
+ gone gets the blast radius `ripple breaks` would give it. Header only: no data
6
7
  row is read, nothing leaves the machine, and a similar name is a hint,
7
8
  never an assumption.
8
9
 
@@ -20,16 +21,21 @@ from ripple.check_file.binding import (
20
21
  BrokenRippleYml,
21
22
  bind,
22
23
  bind_by_header,
24
+ bind_by_style,
23
25
  bind_rule,
26
+ checkable_sources,
24
27
  declared_sources,
28
+ expected_columns,
25
29
  file_map,
26
30
  file_rules,
27
31
  find_source,
28
32
  fits,
29
33
  normalize_for,
34
+ source_reads,
30
35
  table_choices,
31
36
  table_list,
32
37
  )
38
+ from ripple.check_file.header_sense import looks_like_data
33
39
  from ripple.check_file.naming import RULES, rule_or_refuse
34
40
  from ripple.check_file.readers import (
35
41
  DELIMITERS,
@@ -40,19 +46,24 @@ from ripple.check_file.readers import (
40
46
  XML_PART_CAP,
41
47
  CannotCheck,
42
48
  names,
49
+ read_cells,
43
50
  read_header,
44
51
  )
45
52
  from ripple.check_file.report import (
46
- COLUMN_LIST,
47
53
  COMPATIBLE,
48
- HEADER_ONLY,
49
54
  LOOKS_LIKE,
50
55
  MAX_DEPTH,
51
- NO_LAYOUT,
52
- VALUES_NOT_CHECKED,
53
56
  binding_from,
54
57
  check,
55
58
  failure_report,
59
+ )
60
+ from ripple.check_file.text import (
61
+ COLUMN_LIST,
62
+ HEADER_ONLY,
63
+ NO_LAYOUT,
64
+ VALUES_NOT_CHECKED,
65
+ data_row_lines,
66
+ data_row_message,
56
67
  lines,
57
68
  no_binding_lines,
58
69
  sender_message,
@@ -78,24 +89,30 @@ __all__ = [
78
89
  "CannotCheck",
79
90
  "bind",
80
91
  "bind_by_header",
92
+ "bind_by_style",
81
93
  "bind_rule",
82
94
  "binding_from",
83
95
  "check",
96
+ "checkable_sources",
84
97
  "declared_sources",
98
+ "expected_columns",
85
99
  "failure_report",
86
100
  "file_map",
87
101
  "file_rules",
88
102
  "find_source",
89
103
  "fits",
90
104
  "lines",
105
+ "looks_like_data",
91
106
  "names",
92
107
  "no_binding_lines",
93
108
  "normalize_for",
109
+ "read_cells",
94
110
  "read_header",
95
111
  "rule_or_refuse",
96
112
  "run",
97
113
  "run_columns",
98
114
  "sender_message",
115
+ "source_reads",
99
116
  "strict_lines",
100
117
  "table_choices",
101
118
  "table_list",
@@ -130,9 +147,11 @@ def run(
130
147
  layout = {"sheet": sheet, "header_row": header_row if header_row is not None else 1}
131
148
  asked = {"rule": None, "detail": None, "strict": strict}
132
149
  roots = [root, *getattr(project, "dbt_roots", [])]
150
+ style = False
151
+ rule = None
133
152
  try:
134
153
  spelling = rule_or_refuse(normalize)
135
- rule, how = None, None
154
+ how = None
136
155
  if not source:
137
156
  rules = file_rules(roots)
138
157
  rule, how = bind_rule(path, root, rules)
@@ -141,13 +160,17 @@ def run(
141
160
  "sheet": sheet if sheet is not None else rule["sheet"],
142
161
  "header_row": header_row if header_row is not None else rule["header_row"],
143
162
  }
144
- header = read_header(path, encoding, layout["sheet"], layout["header_row"])
163
+ cells = read_cells(path, encoding, layout["sheet"], layout["header_row"])
164
+ # a parquet schema's names are authoritative, even numeric ones
165
+ if path.suffix.lower() != ".parquet" and looks_like_data(cells):
166
+ return _data_row(path.name, layout, asked, rule)
167
+ header = names(cells, path.name)
145
168
  if source:
146
169
  table, how = source, "--source"
147
170
  elif rule:
148
171
  table = rule["table"]
149
172
  else:
150
- found, how, tied = bind_by_header(header, graph)
173
+ found, how, tied, style = bind_by_style(header, graph)
151
174
  if found is None:
152
175
  text = no_binding_lines(path.name, header, graph, tied)
153
176
  return failure_report(path.name, text, table_choices(header, graph), asked, layout)
@@ -165,11 +188,36 @@ def run(
165
188
  if not source:
166
189
  raise
167
190
  spelling = "none"
168
- return check(
169
- graph, table, header, path.name, how, noun, binding_from(how, strict), layout, spelling
170
- )
191
+ binding = binding_from(how, strict, style)
192
+ report = check(graph, table, header, path.name, how, noun, binding, layout, spelling)
193
+ report["pattern"] = rule["pattern"] if rule else None
194
+ return report
171
195
  except (CannotCheck, OSError) as e:
172
- return failure_report(path.name, [f"Cannot check: {e}"], binding=asked, layout=layout)
196
+ return _held(path.name, [f"Cannot check: {e}"], asked, layout, rule)
197
+
198
+
199
+ def _held(
200
+ file_name: str, text: list[str], asked: dict, layout: dict, rule: dict | None, message=None
201
+ ) -> dict:
202
+ """A failure report that keeps the files: pattern it bound by, if any."""
203
+ return failure_report(
204
+ file_name,
205
+ text,
206
+ binding=asked,
207
+ layout=layout,
208
+ table=rule["table"] if rule else None,
209
+ pattern=rule["pattern"] if rule else None,
210
+ message=message,
211
+ )
212
+
213
+
214
+ def _data_row(file_name: str, layout: dict, asked: dict, rule: dict | None) -> dict:
215
+ """The held report for a header row that reads as data, its cells left out."""
216
+ row = layout["header_row"]
217
+ text = data_row_lines(file_name, row)
218
+ report = _held(file_name, text, asked, layout, rule, data_row_message(file_name, row))
219
+ report["fix_by"] = "sender"
220
+ return report
173
221
 
174
222
 
175
223
  def run_columns(
@@ -187,13 +235,14 @@ def run_columns(
187
235
  file name there is no files: pattern to match."""
188
236
  layout = {"sheet": None, "header_row": None}
189
237
  asked = {"rule": None, "detail": None, "strict": strict}
238
+ style = False
190
239
  try:
191
240
  spelling = rule_or_refuse(normalize)
192
241
  header = names(columns, COLUMN_LIST)
193
242
  if source:
194
243
  table, how = source, "--source"
195
244
  else:
196
- found, how, tied = bind_by_header(header, graph)
245
+ found, how, tied, style = bind_by_style(header, graph)
197
246
  if found is None:
198
247
  text = no_binding_lines(COLUMN_LIST, header, graph, tied, file=False)
199
248
  return failure_report(
@@ -211,7 +260,7 @@ def run_columns(
211
260
  COLUMN_LIST,
212
261
  how,
213
262
  noun,
214
- binding_from(how, strict),
263
+ binding_from(how, strict, style),
215
264
  layout,
216
265
  spelling,
217
266
  )