knack-elt 0.2.1__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.
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.3
2
+ Name: knack-elt
3
+ Version: 0.2.1
4
+ Summary: Generic ELT pipeline: extract any Knack application into DuckDB or MotherDuck with dlt, keeping full SCD2 history.
5
+ Requires-Dist: dlt[duckdb]>=1.30.0
6
+ Requires-Dist: httpx[http2]>=0.28.1
7
+ Requires-Dist: knack-sleuth>=0.7.1
8
+ Requires-Dist: pydantic-settings>=2.15.0
9
+ Requires-Dist: rich>=15.0.0
10
+ Requires-Dist: typer>=0.27.1
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown
13
+
14
+ # KnackELT
15
+
16
+ **Get your data out of Knack and into a real database — on a schedule, read-only, with full history.**
17
+
18
+ Knack is a good place to *run* a business and a poor place to *remember* one. Every plan caps
19
+ how many records you can hold, there is no SQL and no aggregates, and once a record is deleted
20
+ it is gone.
21
+
22
+ KnackELT copies every record out through Knack's own REST API and keeps **every version of every
23
+ row**. Nothing is ever overwritten, so the warehouse can still answer questions about records
24
+ your app no longer has. It is built on [dlt](https://dlthub.com), and it never writes back to
25
+ Knack.
26
+
27
+ ## How it fits together
28
+
29
+ ```mermaid
30
+ flowchart TB
31
+ subgraph src["SOURCE OF RECORD"]
32
+ app["<b>Knack App</b><br/>where your team works"]
33
+ api["<b>Knack REST API</b><br/>Knack's own data API"]
34
+ app --> api
35
+ end
36
+
37
+ subgraph ing["INGESTION — this repo"]
38
+ elt["<b>KnackELT</b><br/>pulls every record,<br/>never writes back"]
39
+ end
40
+
41
+ subgraph wh["DATA WAREHOUSE / DATABASE"]
42
+ hist["<b>Complete history</b><br/>every version of every record —<br/>including ones deleted in Knack"]
43
+ rep["<b>Reporting tables</b><br/>tidied into a shape you can<br/>filter, sort and add up"]
44
+ hist -->|"modeled for reporting"| rep
45
+ end
46
+
47
+ subgraph bi["BI / DATA TOOLS"]
48
+ dash["<b>Dashboards</b><br/>look up, drill down"]
49
+ adhoc["<b>Ad-hoc + export</b><br/>new questions,<br/>Excel and CSV out"]
50
+ end
51
+
52
+ cron["<b>Scheduled run</b><br/>daily cron or CI job"]
53
+ backup["<b>Offsite backup</b> — optional<br/>S3-compatible object storage:<br/>a third copy, outside both<br/>Knack and the warehouse"]
54
+
55
+ api -->|"read-only"| elt
56
+ cron -.->|"triggers"| elt
57
+ elt -->|"keeps every version"| hist
58
+ rep -->|"SQL"| dash
59
+ rep -->|"SQL"| adhoc
60
+ hist -.->|"optional"| backup
61
+
62
+ classDef keep stroke:#d97706,stroke-width:3px
63
+ classDef opt stroke-dasharray:5 5
64
+ class hist keep
65
+ class backup opt
66
+ linkStyle 4 stroke:#d97706,stroke-width:2px
67
+ ```
68
+
69
+ **This repo is the `INGESTION` box.** Everything flows one way: KnackELT reads through the same
70
+ REST API your app already exposes, so it cannot alter or break anything in Knack. The amber box
71
+ is the point of the exercise — your app deletes records to stay under its limit, and the
72
+ warehouse keeps them anyway.
73
+
74
+ The other boxes are deliberately generic. KnackELT loads into anything
75
+ [dlt supports as a destination](https://dlthub.com/docs/dlt-ecosystem/destinations/), and any
76
+ BI tool that speaks SQL to that destination will do. For a concrete, working combination of all
77
+ four — MotherDuck, dbt and Preset, orchestrated by a daily GitHub Actions job — see
78
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
79
+
80
+ ## What it does
81
+
82
+ - **Discovers your schema.** Reads the Knack application metadata and builds a resource per
83
+ object, so there is no table list to maintain. Add an object in Knack and the next run picks
84
+ it up.
85
+ - **Gives you readable column names.** `field_43` becomes `event_name`, slugified from the field
86
+ label you already chose in the builder. Knack's own row id is loaded as `record_id`, so a
87
+ field you named `id` keeps the `id` column it was named for.
88
+ - **Cleans what the API hands back.** Empty strings become `NULL` in numeric fields, boolean
89
+ fields get the default declared in Knack, and malformed JSON becomes `NULL` instead of
90
+ failing the load.
91
+ - **Keeps history.** Loads with dlt's SCD2 merge strategy keyed on the Knack record id, so an
92
+ edit retires the old row and appends a new one. Tables are kept flat
93
+ (`max_table_nesting=0`) — one table per Knack object, no nested child tables.
94
+
95
+ ## Quick start
96
+
97
+ Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
98
+
99
+ ```bash
100
+ uv sync
101
+
102
+ export KNACK_APP_ID=your_app_id
103
+ export KNACK_API_KEY=your_rest_api_key
104
+
105
+ uv run knack-elt run-pipeline --app-id "$KNACK_APP_ID"
106
+ ```
107
+
108
+ Names are derived from your app's slug, so a second app never lands on the first one's tables:
109
+ database `knack_{slug}_data`, dataset `{slug}`, pipeline `knack_{slug}_pipeline`.
110
+
111
+ ### Destinations
112
+
113
+ `--destination local` (the default) writes a DuckDB file — nothing to sign up for, so a fresh
114
+ clone can be pointed at a Knack app and produce a queryable warehouse immediately. The file
115
+ lands at `./tests/data/knack_{slug}_data.duckdb` unless you pass `--db-path`; the resolved
116
+ path is printed on every run.
117
+
118
+ ```bash
119
+ uv run knack-elt run-pipeline --app-id "$KNACK_APP_ID" --db-path ~/knack.duckdb
120
+ uv run knack-elt run-pipeline --app-id "$KNACK_APP_ID" --destination motherduck
121
+ ```
122
+
123
+ `--destination motherduck` loads to `md:///knack_{slug}_data` and needs `motherduck_api_key`
124
+ in the environment. Both destinations also write the run's `_load_info` and `_trace` tables.
125
+
126
+ ### Other flags
127
+
128
+ | Flag | What it does |
129
+ | --- | --- |
130
+ | `--api-key` | Knack REST API key, if you would rather not set `KNACK_API_KEY` |
131
+ | `--refresh-metadata` | Re-fetch app metadata instead of reusing knack-sleuth's 24h on-disk cache |
132
+ | `--skip-unreadable` | Log and continue past objects that fail *before yielding any row* (typically no read permission). An object that fails partway through still aborts the run — loading a partial batch would retire live SCD2 rows as if the missing records had been deleted in Knack. |
133
+
134
+ ## Configuration
135
+
136
+ Read from the environment or a `.env` file via `pydantic-settings`
137
+ ([`src/knack_elt/config.py`](src/knack_elt/config.py)):
138
+
139
+ | Variable | Purpose |
140
+ | --- | --- |
141
+ | `KNACK_APP_ID` | Knack application id — also the default for `--app-id` |
142
+ | `KNACK_API_KEY` | Knack REST API key, sent as `X-Knack-REST-API-Key` |
143
+ | `motherduck_api_key` | MotherDuck token, when the destination is MotherDuck |
144
+
145
+ ## Querying what you get
146
+
147
+ Because loads are SCD2, a record's history is several rows sharing one `record_id`, tagged with
148
+ `_dlt_valid_from` and `_dlt_valid_to`. Two flags are worth deriving up front — conflating them
149
+ is the most common way to get a wrong answer:
150
+
151
+ ```sql
152
+ with flagged as (
153
+ select
154
+ *,
155
+ row_number() over (partition by record_id order by _dlt_valid_from desc) = 1
156
+ as latest_version, -- one row per record
157
+ _dlt_valid_to is null as is_live_in_knack -- still in the app?
158
+ from your_dataset.some_table
159
+ )
160
+ select * from flagged where latest_version
161
+ ```
162
+
163
+ A record deleted in Knack survives only as a *retired* row, so filtering on
164
+ `_dlt_valid_to is null` alone silently drops exactly the history you built the warehouse for.
165
+ And aggregating without `latest_version` double-counts, because every past version is still a
166
+ row. The [architecture doc](docs/ARCHITECTURE.md#4-scd2-row-lifecycle) works through both.
167
+
168
+ > **One caveat on `is_live_in_knack`.** If an object returns *zero* records, dlt has nothing to
169
+ > load for that table and the merge never runs, so rows loaded earlier keep `_dlt_valid_to is
170
+ > null` and still read as live. Emptying an object in Knack is therefore invisible to the flag —
171
+ > a table whose row count stops moving is worth checking against the app.
172
+
173
+ ## Documentation
174
+
175
+ - **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — the reference architecture in plain language
176
+ and in technical detail, the pipeline internals, a run sequence, and the SCD2 row model with
177
+ the query patterns it requires. Also available as a [PDF](docs/ARCHITECTURE.pdf).
178
+
179
+ The PDF is generated from the markdown rather than maintained alongside it. After editing
180
+ the diagrams, rebuild it with `uv run scripts/build_architecture_pdf.py` (needs node and
181
+ Chrome) so the two don't drift apart.
182
+
183
+ ## Related
184
+
185
+ - [dlt](https://dlthub.com) — the load framework this is built on
186
+ - `knack-sleuth` — Knack application metadata models and schema export, used here to read your
187
+ app's structure
188
+
189
+ ## License
190
+
191
+ GPL-3.0. See [LICENSE](LICENSE).
@@ -0,0 +1,178 @@
1
+ # KnackELT
2
+
3
+ **Get your data out of Knack and into a real database — on a schedule, read-only, with full history.**
4
+
5
+ Knack is a good place to *run* a business and a poor place to *remember* one. Every plan caps
6
+ how many records you can hold, there is no SQL and no aggregates, and once a record is deleted
7
+ it is gone.
8
+
9
+ KnackELT copies every record out through Knack's own REST API and keeps **every version of every
10
+ row**. Nothing is ever overwritten, so the warehouse can still answer questions about records
11
+ your app no longer has. It is built on [dlt](https://dlthub.com), and it never writes back to
12
+ Knack.
13
+
14
+ ## How it fits together
15
+
16
+ ```mermaid
17
+ flowchart TB
18
+ subgraph src["SOURCE OF RECORD"]
19
+ app["<b>Knack App</b><br/>where your team works"]
20
+ api["<b>Knack REST API</b><br/>Knack's own data API"]
21
+ app --> api
22
+ end
23
+
24
+ subgraph ing["INGESTION — this repo"]
25
+ elt["<b>KnackELT</b><br/>pulls every record,<br/>never writes back"]
26
+ end
27
+
28
+ subgraph wh["DATA WAREHOUSE / DATABASE"]
29
+ hist["<b>Complete history</b><br/>every version of every record —<br/>including ones deleted in Knack"]
30
+ rep["<b>Reporting tables</b><br/>tidied into a shape you can<br/>filter, sort and add up"]
31
+ hist -->|"modeled for reporting"| rep
32
+ end
33
+
34
+ subgraph bi["BI / DATA TOOLS"]
35
+ dash["<b>Dashboards</b><br/>look up, drill down"]
36
+ adhoc["<b>Ad-hoc + export</b><br/>new questions,<br/>Excel and CSV out"]
37
+ end
38
+
39
+ cron["<b>Scheduled run</b><br/>daily cron or CI job"]
40
+ backup["<b>Offsite backup</b> — optional<br/>S3-compatible object storage:<br/>a third copy, outside both<br/>Knack and the warehouse"]
41
+
42
+ api -->|"read-only"| elt
43
+ cron -.->|"triggers"| elt
44
+ elt -->|"keeps every version"| hist
45
+ rep -->|"SQL"| dash
46
+ rep -->|"SQL"| adhoc
47
+ hist -.->|"optional"| backup
48
+
49
+ classDef keep stroke:#d97706,stroke-width:3px
50
+ classDef opt stroke-dasharray:5 5
51
+ class hist keep
52
+ class backup opt
53
+ linkStyle 4 stroke:#d97706,stroke-width:2px
54
+ ```
55
+
56
+ **This repo is the `INGESTION` box.** Everything flows one way: KnackELT reads through the same
57
+ REST API your app already exposes, so it cannot alter or break anything in Knack. The amber box
58
+ is the point of the exercise — your app deletes records to stay under its limit, and the
59
+ warehouse keeps them anyway.
60
+
61
+ The other boxes are deliberately generic. KnackELT loads into anything
62
+ [dlt supports as a destination](https://dlthub.com/docs/dlt-ecosystem/destinations/), and any
63
+ BI tool that speaks SQL to that destination will do. For a concrete, working combination of all
64
+ four — MotherDuck, dbt and Preset, orchestrated by a daily GitHub Actions job — see
65
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
66
+
67
+ ## What it does
68
+
69
+ - **Discovers your schema.** Reads the Knack application metadata and builds a resource per
70
+ object, so there is no table list to maintain. Add an object in Knack and the next run picks
71
+ it up.
72
+ - **Gives you readable column names.** `field_43` becomes `event_name`, slugified from the field
73
+ label you already chose in the builder. Knack's own row id is loaded as `record_id`, so a
74
+ field you named `id` keeps the `id` column it was named for.
75
+ - **Cleans what the API hands back.** Empty strings become `NULL` in numeric fields, boolean
76
+ fields get the default declared in Knack, and malformed JSON becomes `NULL` instead of
77
+ failing the load.
78
+ - **Keeps history.** Loads with dlt's SCD2 merge strategy keyed on the Knack record id, so an
79
+ edit retires the old row and appends a new one. Tables are kept flat
80
+ (`max_table_nesting=0`) — one table per Knack object, no nested child tables.
81
+
82
+ ## Quick start
83
+
84
+ Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
85
+
86
+ ```bash
87
+ uv sync
88
+
89
+ export KNACK_APP_ID=your_app_id
90
+ export KNACK_API_KEY=your_rest_api_key
91
+
92
+ uv run knack-elt run-pipeline --app-id "$KNACK_APP_ID"
93
+ ```
94
+
95
+ Names are derived from your app's slug, so a second app never lands on the first one's tables:
96
+ database `knack_{slug}_data`, dataset `{slug}`, pipeline `knack_{slug}_pipeline`.
97
+
98
+ ### Destinations
99
+
100
+ `--destination local` (the default) writes a DuckDB file — nothing to sign up for, so a fresh
101
+ clone can be pointed at a Knack app and produce a queryable warehouse immediately. The file
102
+ lands at `./tests/data/knack_{slug}_data.duckdb` unless you pass `--db-path`; the resolved
103
+ path is printed on every run.
104
+
105
+ ```bash
106
+ uv run knack-elt run-pipeline --app-id "$KNACK_APP_ID" --db-path ~/knack.duckdb
107
+ uv run knack-elt run-pipeline --app-id "$KNACK_APP_ID" --destination motherduck
108
+ ```
109
+
110
+ `--destination motherduck` loads to `md:///knack_{slug}_data` and needs `motherduck_api_key`
111
+ in the environment. Both destinations also write the run's `_load_info` and `_trace` tables.
112
+
113
+ ### Other flags
114
+
115
+ | Flag | What it does |
116
+ | --- | --- |
117
+ | `--api-key` | Knack REST API key, if you would rather not set `KNACK_API_KEY` |
118
+ | `--refresh-metadata` | Re-fetch app metadata instead of reusing knack-sleuth's 24h on-disk cache |
119
+ | `--skip-unreadable` | Log and continue past objects that fail *before yielding any row* (typically no read permission). An object that fails partway through still aborts the run — loading a partial batch would retire live SCD2 rows as if the missing records had been deleted in Knack. |
120
+
121
+ ## Configuration
122
+
123
+ Read from the environment or a `.env` file via `pydantic-settings`
124
+ ([`src/knack_elt/config.py`](src/knack_elt/config.py)):
125
+
126
+ | Variable | Purpose |
127
+ | --- | --- |
128
+ | `KNACK_APP_ID` | Knack application id — also the default for `--app-id` |
129
+ | `KNACK_API_KEY` | Knack REST API key, sent as `X-Knack-REST-API-Key` |
130
+ | `motherduck_api_key` | MotherDuck token, when the destination is MotherDuck |
131
+
132
+ ## Querying what you get
133
+
134
+ Because loads are SCD2, a record's history is several rows sharing one `record_id`, tagged with
135
+ `_dlt_valid_from` and `_dlt_valid_to`. Two flags are worth deriving up front — conflating them
136
+ is the most common way to get a wrong answer:
137
+
138
+ ```sql
139
+ with flagged as (
140
+ select
141
+ *,
142
+ row_number() over (partition by record_id order by _dlt_valid_from desc) = 1
143
+ as latest_version, -- one row per record
144
+ _dlt_valid_to is null as is_live_in_knack -- still in the app?
145
+ from your_dataset.some_table
146
+ )
147
+ select * from flagged where latest_version
148
+ ```
149
+
150
+ A record deleted in Knack survives only as a *retired* row, so filtering on
151
+ `_dlt_valid_to is null` alone silently drops exactly the history you built the warehouse for.
152
+ And aggregating without `latest_version` double-counts, because every past version is still a
153
+ row. The [architecture doc](docs/ARCHITECTURE.md#4-scd2-row-lifecycle) works through both.
154
+
155
+ > **One caveat on `is_live_in_knack`.** If an object returns *zero* records, dlt has nothing to
156
+ > load for that table and the merge never runs, so rows loaded earlier keep `_dlt_valid_to is
157
+ > null` and still read as live. Emptying an object in Knack is therefore invisible to the flag —
158
+ > a table whose row count stops moving is worth checking against the app.
159
+
160
+ ## Documentation
161
+
162
+ - **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — the reference architecture in plain language
163
+ and in technical detail, the pipeline internals, a run sequence, and the SCD2 row model with
164
+ the query patterns it requires. Also available as a [PDF](docs/ARCHITECTURE.pdf).
165
+
166
+ The PDF is generated from the markdown rather than maintained alongside it. After editing
167
+ the diagrams, rebuild it with `uv run scripts/build_architecture_pdf.py` (needs node and
168
+ Chrome) so the two don't drift apart.
169
+
170
+ ## Related
171
+
172
+ - [dlt](https://dlthub.com) — the load framework this is built on
173
+ - `knack-sleuth` — Knack application metadata models and schema export, used here to read your
174
+ app's structure
175
+
176
+ ## License
177
+
178
+ GPL-3.0. See [LICENSE](LICENSE).
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "knack-elt"
3
+ version = "0.2.1"
4
+ description = "Generic ELT pipeline: extract any Knack application into DuckDB or MotherDuck with dlt, keeping full SCD2 history."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "dlt[duckdb]>=1.30.0",
9
+ "httpx[http2]>=0.28.1",
10
+ "knack-sleuth>=0.7.1",
11
+ "pydantic-settings>=2.15.0",
12
+ "rich>=15.0.0",
13
+ "typer>=0.27.1",
14
+ ]
15
+
16
+ [project.scripts]
17
+ knack-elt = "knack_elt.cli:cli"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.9.5,<0.13.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [tool.ruff]
24
+ target-version = "py313"
25
+
26
+ [tool.ruff.lint]
27
+ select = [
28
+ "E",
29
+ "F",
30
+ "I",
31
+ "UP",
32
+ "B",
33
+ "SIM",
34
+ "RUF",
35
+ ]
36
+ ignore = ["E501"]
37
+
38
+ [tool.ruff.lint.per-file-ignores]
39
+ "src/knack_elt/cli.py" = ["B008"]
40
+
41
+ [tool.bumpversion]
42
+ current_version = "0.2.1"
43
+ commit = true
44
+ tag = true
45
+
46
+ [[tool.bumpversion.files]]
47
+ filename = "pyproject.toml"
48
+ search = 'version = "{current_version}"'
49
+ replace = 'version = "{new_version}"'
50
+
51
+ [[tool.bumpversion.files]]
52
+ filename = "src/knack_elt/__init__.py"
53
+ search = '__version__ = "{current_version}"'
54
+ replace = '__version__ = "{new_version}"'
55
+
56
+ [dependency-groups]
57
+ dev = [
58
+ "bump-my-version>=1.5.1",
59
+ "pytest>=9.1.1",
60
+ "ruff>=0.16.4",
61
+ ]
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "knack-elt"
3
+ version = "0.2.1"
4
+ description = "Generic ELT pipeline: extract any Knack application into DuckDB or MotherDuck with dlt, keeping full SCD2 history."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "dlt[duckdb]>=1.30.0",
9
+ "httpx[http2]>=0.28.1",
10
+ "knack-sleuth>=0.7.1",
11
+ "pydantic-settings>=2.15.0",
12
+ "rich>=15.0.0",
13
+ "typer>=0.27.1",
14
+ ]
15
+
16
+
17
+ [project.scripts]
18
+ knack-elt = "knack_elt.cli:cli"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.9.5,<0.13.0"]
22
+ build-backend = "uv_build"
23
+
24
+
25
+ [tool.ruff]
26
+ target-version = "py313"
27
+
28
+ [tool.ruff.lint]
29
+ # Pinned explicitly: ruff's default rule set widens between releases, and CI
30
+ # should fail on our choices rather than on ruff's changing defaults.
31
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
32
+ ignore = [
33
+ "E501", # line length - not enforced, comments here carry reasoning
34
+ ]
35
+
36
+ [tool.ruff.lint.per-file-ignores]
37
+ # Typer's API *is* function calls in argument defaults.
38
+ "src/knack_elt/cli.py" = ["B008"]
39
+
40
+ [tool.bumpversion]
41
+ current_version = "0.2.1"
42
+ commit = true
43
+ tag = true
44
+
45
+ [[tool.bumpversion.files]]
46
+ filename = "pyproject.toml"
47
+ search = 'version = "{current_version}"'
48
+ replace = 'version = "{new_version}"'
49
+
50
+ [[tool.bumpversion.files]]
51
+ filename = "src/knack_elt/__init__.py"
52
+ search = '__version__ = "{current_version}"'
53
+ replace = '__version__ = "{new_version}"'
54
+
55
+
56
+ [dependency-groups]
57
+ dev = [
58
+ "bump-my-version>=1.5.1",
59
+ "pytest>=9.1.1",
60
+ "ruff>=0.16.4",
61
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -0,0 +1,149 @@
1
+ from pathlib import Path
2
+
3
+ import dlt
4
+ import typer
5
+ from knack_sleuth import load_app_metadata
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from knack_elt import __version__
10
+ from knack_elt.config import settings
11
+ from knack_elt.knack_dlt import build_knack_resources, create_rest_client
12
+
13
+ cli = typer.Typer()
14
+ console = Console()
15
+
16
+ DEFAULT_LOCAL_DB_DIR = Path("tests/data")
17
+
18
+
19
+ def version_callback(value: bool):
20
+ """Display version and exit."""
21
+ if value:
22
+ console.print(f"knack-elt version {__version__}")
23
+ raise typer.Exit()
24
+
25
+
26
+ @cli.callback()
27
+ def main(
28
+ version: bool = typer.Option(
29
+ False,
30
+ "--version",
31
+ "-v",
32
+ callback=version_callback,
33
+ is_eager=True,
34
+ help="Show version and exit."
35
+ )
36
+ ):
37
+ pass
38
+
39
+
40
+ @cli.command()
41
+ def run_pipeline(
42
+ app_id: str = typer.Option(
43
+ None,
44
+ "--app-id",
45
+ help="Knack application ID to extract data from. Defaults to $KNACK_APP_ID."
46
+ ),
47
+ api_key: str = typer.Option(
48
+ None,
49
+ "--api-key",
50
+ help="Knack REST API key. Defaults to $KNACK_API_KEY.",
51
+ ),
52
+ destination: str = typer.Option(
53
+ "local",
54
+ "--destination",
55
+ "-d",
56
+ help="Where to load: 'local' (a DuckDB file, no account needed) or 'motherduck'.",
57
+ ),
58
+ db_path: Path = typer.Option(
59
+ None,
60
+ "--db-path",
61
+ help=f"Local DuckDB file. Defaults to ./{DEFAULT_LOCAL_DB_DIR}/knack_{{slug}}_data.duckdb.",
62
+ ),
63
+ refresh_metadata: bool = typer.Option(
64
+ False,
65
+ "--refresh-metadata",
66
+ help="Re-fetch app metadata instead of using knack-sleuth's 24h on-disk cache.",
67
+ ),
68
+ skip_unreadable: bool = typer.Option(
69
+ False,
70
+ "--skip-unreadable",
71
+ help="Log and skip objects that fail before yielding any row (e.g. no read "
72
+ "permission) instead of aborting the run. An object that fails partway "
73
+ "through still aborts: a partial batch would retire live SCD2 rows.",
74
+ ),
75
+ ):
76
+ """Run the ELT pipeline for a Knack application."""
77
+ final_app_id = app_id or settings.knack_app_id
78
+ final_api_key = api_key or settings.knack_api_key
79
+
80
+ if not final_app_id:
81
+ console.print("[bold red]Error:[/bold red] app_id is required. Provide it via --app-id option or set KNACK_APP_ID environment variable.")
82
+ raise typer.Exit(code=1)
83
+
84
+ if not final_api_key:
85
+ console.print("[bold red]Error:[/bold red] a Knack REST API key is required to read records. Provide it via --api-key or set KNACK_API_KEY.")
86
+ raise typer.Exit(code=1)
87
+
88
+ if destination not in ("local", "motherduck"):
89
+ console.print(f"[bold red]Error:[/bold red] unknown destination {destination!r}; expected 'local' or 'motherduck'.")
90
+ raise typer.Exit(code=1)
91
+
92
+ if destination == "motherduck" and not settings.motherduck_api_key:
93
+ console.print("[bold red]Error:[/bold red] --destination motherduck requires motherduck_api_key in the environment or .env.")
94
+ raise typer.Exit(code=1)
95
+
96
+ kn_app = load_app_metadata(app_id=final_app_id, refresh=refresh_metadata).application
97
+
98
+ dest_db_name = f"knack_{kn_app.slug}_data"
99
+ dlt_pipeline_name = f"knack_{kn_app.slug}_pipeline"
100
+ dataset_name = kn_app.slug.replace('-', '_')
101
+
102
+ if destination == "local":
103
+ local_db_path = (db_path or DEFAULT_LOCAL_DB_DIR / f"{dest_db_name}.duckdb").resolve()
104
+ local_db_path.parent.mkdir(parents=True, exist_ok=True)
105
+ dlt_destination = dlt.destinations.duckdb(str(local_db_path))
106
+ destination_label = str(local_db_path)
107
+ else:
108
+ local_db_path = None
109
+ dlt_destination = dlt.destinations.motherduck(
110
+ f"md:///{dest_db_name}?token={settings.motherduck_api_key}"
111
+ )
112
+ destination_label = f"MotherDuck md:///{dest_db_name}"
113
+
114
+ summary = Table(show_header=False, box=None)
115
+ summary.add_column(style="bold")
116
+ summary.add_column(style="cyan bold")
117
+ summary.add_row("App", f"{kn_app.name} ({final_app_id})")
118
+ summary.add_row("Slug", kn_app.slug)
119
+ summary.add_row("Objects", str(len(kn_app.objects)))
120
+ summary.add_row("Destination", destination_label)
121
+ summary.add_row("Dataset", dataset_name)
122
+ summary.add_row("dlt pipeline", dlt_pipeline_name)
123
+ console.print(summary)
124
+
125
+ dlt.config["load.workers"] = 3
126
+ dlt.config["truncate_staging_dataset"] = True
127
+
128
+ knack_dlt_pipeline = dlt.pipeline(
129
+ pipeline_name=dlt_pipeline_name,
130
+ dataset_name=dataset_name,
131
+ dev_mode=False,
132
+ destination=dlt_destination,
133
+ )
134
+
135
+ client = create_rest_client(app_id=final_app_id, api_key=final_api_key)
136
+ load_info = knack_dlt_pipeline.run(
137
+ build_knack_resources(kn_app, client, skip_unreadable=skip_unreadable)
138
+ )
139
+
140
+ console.print(load_info)
141
+ console.print(f"Elapsed: {(load_info.finished_at - load_info.started_at).in_words()}")
142
+
143
+ # Keep the run's own bookkeeping alongside the data.
144
+ knack_dlt_pipeline.run([load_info], table_name="_load_info")
145
+ knack_dlt_pipeline.run([knack_dlt_pipeline.last_trace], table_name="_trace")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ cli()
@@ -0,0 +1,23 @@
1
+ import os
2
+
3
+ from pydantic import Field
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ # Lowercase deliberately: this is the documented variable name and the one
9
+ # existing deployments set. os.environ is case-sensitive, so capitalising it
10
+ # would be a breaking config change.
11
+ motherduck_api_key: str = os.environ.get('motherduck_api_key', '') # noqa: SIM112
12
+ knack_app_id: str = Field(default='', alias='KNACK_APP_ID')
13
+ knack_api_key: str = Field(default='', alias='KNACK_API_KEY')
14
+
15
+ model_config = SettingsConfigDict(
16
+ env_file='.env',
17
+ env_file_encoding='utf-8',
18
+ case_sensitive=False,
19
+ )
20
+
21
+ settings = Settings()
22
+
23
+
@@ -0,0 +1,182 @@
1
+ """dlt resources and transformers for extracting a Knack application into DuckDB/MotherDuck.
2
+
3
+ Nothing in this module is app-specific: every name, object key, and credential is
4
+ passed in by the caller (see `cli.py`).
5
+ """
6
+ import json
7
+ import logging
8
+ from collections.abc import Iterable
9
+
10
+ import dlt
11
+ from dlt.sources.helpers.rest_client import RESTClient
12
+ from dlt.sources.helpers.rest_client.auth import APIKeyAuth
13
+ from dlt.sources.helpers.rest_client.paginators import PageNumberPaginator
14
+ from knack_sleuth import Application
15
+
16
+ from .mapping import create_app_mappings, remap_keys
17
+
18
+ logging.basicConfig(level=logging.INFO)
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Columns the pipeline stamps onto every row for lineage. Underscore-prefixed so a
22
+ # user-defined Knack field can never slugify onto one of them and clobber the value.
23
+ LINEAGE_TABLE_NAME = "_kn_table_name"
24
+ LINEAGE_OBJECT_ID = "_kn_object_id"
25
+
26
+ # Knack returns the row identifier as the top-level key "id". The pipeline renames it to
27
+ # "record_id" and merges on that, which frees the "id" column for a user-defined field of
28
+ # that name (Knack apps commonly have one). Sourcing the key from the payload rather than
29
+ # from Knack's auto-added "Record ID" field means this holds for every app, including ones
30
+ # that predate that field.
31
+ RECORD_KEY = "record_id"
32
+
33
+
34
+ def create_rest_client(app_id: str, api_key: str) -> RESTClient:
35
+ """Build a Knack REST client for a specific application.
36
+
37
+ Both the application id and the API key are explicit: the record endpoints
38
+ authenticate per-app, so they must match the app whose metadata was loaded.
39
+ """
40
+ auth = APIKeyAuth(
41
+ name="X-Knack-REST-API-Key",
42
+ api_key=api_key,
43
+ location="header"
44
+ )
45
+ return RESTClient(
46
+ base_url="https://api.knack.com/v1",
47
+ auth=auth,
48
+ headers={"X-Knack-Application-ID": app_id},
49
+ paginator=PageNumberPaginator(
50
+ base_page=1,
51
+ total_path="total_pages"
52
+ ),
53
+ data_selector="records"
54
+ )
55
+
56
+
57
+ def get_knack_table_data(table_name, object_id, client, json_fields=(), skip_unreadable=False):
58
+ @dlt.resource(name=f"table_{object_id}",
59
+ write_disposition={"disposition": "merge", "strategy": "scd2"},
60
+ primary_key=RECORD_KEY,
61
+ columns={RECORD_KEY: {"merge_key": False}} # to work around a possible bug in DLT
62
+ )
63
+ def table_data():
64
+ logger.info(f"Processing table: {table_name} ({object_id})")
65
+ kn_params = {'rows_per_page': 1000, 'format': 'raw'}
66
+ url = f"/objects/{object_id}/records"
67
+ yielded = 0
68
+ try:
69
+ for page in client.paginate(url, params=kn_params):
70
+ logger.debug(f"Fetched {len(page)} records from {table_name}")
71
+ for row in page:
72
+ if row.get('id') is None:
73
+ logger.warning(f"Missing Primary Key in table {table_name}: {row}")
74
+ continue
75
+ # Rename before anything else so the merge key is set even if a
76
+ # user-defined field also wants the "id" column.
77
+ row[RECORD_KEY] = row.pop('id')
78
+ row[LINEAGE_TABLE_NAME] = table_name
79
+ row[LINEAGE_OBJECT_ID] = object_id
80
+
81
+ row = clean_json_fields(row, json_fields)
82
+ yielded += 1
83
+ yield row
84
+ except Exception as e:
85
+ # Swallowing an error *after* rows were yielded would hand dlt a
86
+ # successful partial extraction, and the SCD2 merge would retire every
87
+ # row missing from that partial batch. Only a zero-yield failure is safe
88
+ # to skip.
89
+ if skip_unreadable and yielded == 0:
90
+ logger.error(f"Skipping unreadable object {table_name} ({object_id}): {e}")
91
+ return
92
+ logger.error(f"Error fetching data for table {table_name}: {e}")
93
+ raise
94
+
95
+ return table_data()
96
+
97
+
98
+ def clean_empty_strings(row, numeric_fields):
99
+ """Converts empty strings to None for specified numeric fields."""
100
+ for field in numeric_fields:
101
+ if field in row and row[field] == "":
102
+ row[field] = None
103
+ return row
104
+
105
+
106
+ def assign_default_values(row, default_values):
107
+ """Assign default values to fields that are None."""
108
+ for fk in row:
109
+ if fk in default_values and (row[fk] is None or row[fk] == ""):
110
+ row[fk] = default_values[fk]
111
+ return row
112
+
113
+
114
+ def clean_json_fields(row, json_fields: Iterable[str] = ()):
115
+ """Ensure JSON-in-string fields are valid, or convert empty/invalid JSON to None.
116
+
117
+ Knack's `format=raw` returns rich fields (file, image, connection) as dicts, not
118
+ JSON strings, so non-string values are left untouched.
119
+ """
120
+ for field in json_fields:
121
+ if field in row:
122
+ value = row[field]
123
+ if not isinstance(value, str):
124
+ continue
125
+ if value.strip() == "":
126
+ row[field] = None # Replace empty JSON with None
127
+ else:
128
+ try:
129
+ json.loads(value) # Validate JSON
130
+ except json.JSONDecodeError:
131
+ row[field] = None # Replace invalid JSON with None
132
+ return row
133
+
134
+
135
+ def get_remap_transformer(table_name, object_id, field_mappings, numeric_fields, default_values):
136
+ @dlt.transformer(name=f"remap_{object_id}", table_name=table_name,
137
+ write_disposition={"disposition": "merge", "strategy": "scd2"},
138
+ primary_key=RECORD_KEY,
139
+ columns={RECORD_KEY: {"merge_key": False}} # to work around a possible bug in DLT
140
+ )
141
+ def remap_knack_field_id_to_name(row):
142
+ # Cleaning runs before the remap, so it matches on raw Knack field keys.
143
+ row = clean_empty_strings(row, numeric_fields)
144
+ row = assign_default_values(row, default_values)
145
+
146
+ field_mapping = field_mappings.get(object_id)
147
+ if field_mapping:
148
+ row = remap_keys(row, field_mapping)
149
+ else:
150
+ logger.info(f"No field mapping found for object_id {object_id}. Keeping original field names.")
151
+ return row
152
+
153
+ return remap_knack_field_id_to_name
154
+
155
+
156
+ @dlt.source(max_table_nesting=0)
157
+ def build_knack_resources(kn_app: Application, client: RESTClient, skip_unreadable: bool = False):
158
+ """One resource+transformer pair per Knack object, chained with the pipe operator."""
159
+ resources = []
160
+ field_mappings, _object_mappings, numeric_fields, default_values = create_app_mappings(kn_app)
161
+
162
+ # Resource names key off obj.key (globally unique in Knack); destination table
163
+ # names come from the object name, which is NOT guaranteed unique, so dedupe.
164
+ seen_tables = {}
165
+ for obj in kn_app.objects:
166
+ table_name = obj.name
167
+ if table_name in seen_tables:
168
+ table_name = f"{obj.name}_{obj.key}"
169
+ logger.warning(
170
+ f"Duplicate object name {obj.name!r} ({obj.key} and {seen_tables[obj.name]}); "
171
+ f"loading it as {table_name!r}"
172
+ )
173
+ seen_tables.setdefault(obj.name, obj.key)
174
+
175
+ table_resource = get_knack_table_data(table_name, obj.key, client, skip_unreadable=skip_unreadable)
176
+ transformer_resource = get_remap_transformer(
177
+ table_name, obj.key, field_mappings, numeric_fields, default_values
178
+ )
179
+ resources.append(table_resource | transformer_resource)
180
+
181
+ logger.info(f"Built {len(resources)} resources for {kn_app.name}")
182
+ return resources
@@ -0,0 +1,135 @@
1
+ """
2
+ The following functions are used to create a field mapping and remap keys for a JSON record based on
3
+ knack metadata for objects.
4
+
5
+
6
+ Goal is to keep this as lightweight and simple as possible. To lean into dlt functionality where possible.
7
+
8
+ The input to these functions is the Application Metadata object from the Knack API, (https://api.knack.com/v1/applications/{app_id}))
9
+ """
10
+ import logging
11
+ import re
12
+ from typing import Any
13
+
14
+ from knack_sleuth.models import Application
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # Field types whose values are numeric (or, for date_time, non-textual) and so must
20
+ # have empty strings nulled before load — otherwise the column types as VARCHAR.
21
+ # The aggregate types (sum/min/max/average/count) are connection roll-ups.
22
+ NUMERIC_FIELD_TYPES = [
23
+ 'number', 'currency', 'link', 'date_time', 'auto_increment',
24
+ 'count', 'sum', 'min', 'max', 'average', 'equation', 'rating',
25
+ ]
26
+
27
+
28
+ def slugify_field_name(field_name: str) -> str:
29
+ """Lowercase snake_case a Knack field name. May return '' for a name with no
30
+ ASCII alphanumerics (e.g. "%" or a fully non-Latin name) - callers must handle it."""
31
+ return re.sub(r'[^a-z0-9]+', '_', field_name.lower()).strip('_')
32
+
33
+
34
+ def create_app_mappings(app_metadata: Application) -> tuple[
35
+ dict[Any, dict[Any, Any]], dict[Any, Any], list[str | Any], dict[Any, Any]]:
36
+ """
37
+ Creates both field mappings and object mappings from the Knack app metadata.
38
+
39
+ Uses KnackAppMetadata Pydantic model for validated, type-safe parsing.
40
+
41
+ Returns:
42
+ - field_mappings: A dictionary of dictionaries. The outer dictionary keys are object_ids,
43
+ and the inner dictionary maps original field keys to new slugified field names.
44
+ - object_mappings: A dictionary mapping table names to object_ids.
45
+ - numeric_fields: List of field identifiers that should be treated as numeric.
46
+ - default_values: Dictionary of default values for fields (primarily boolean fields).
47
+ """
48
+
49
+ # Columns the pipeline owns. A user-defined field slugifying onto one of these would
50
+ # silently replace it, so it is renamed <singular>_<name> instead.
51
+ #
52
+ # 'id' is deliberately NOT reserved: the pipeline renames Knack's row id to
53
+ # 'record_id' (see knack_dlt.RECORD_KEY), so a user field named "ID" keeps the plain
54
+ # 'id' column it was named for.
55
+ #
56
+ # 'record_id' IS reserved, because Knack now auto-adds a short_text field named
57
+ # "Record ID" to every object holding a copy of the row id (verified live 2026-08-25
58
+ # across three apps; 3,562 Illinois records, all non-blank and all equal to 'id').
59
+ # Left unreserved it would slugify onto the merge key and overwrite it. Reserving it
60
+ # costs one redundant <singular>_record_id column per table, which is preferable to a
61
+ # schema whose shape depends on whether any row's values happen to diverge.
62
+ #
63
+ # Not covered: user objects also return account_status, approval_status, utility_key,
64
+ # profile_keys and profile_keys_raw. Renaming e.g. an "Account Status" field on a
65
+ # non-user object would be gratuitous, so those are left alone.
66
+ restricted_field_names = ['record_id']
67
+ field_mappings = {}
68
+ object_mappings = {}
69
+ default_values = {}
70
+ numeric_fields = []
71
+
72
+ for obj in app_metadata.objects:
73
+ object_id = obj.key
74
+ object_name = obj.name
75
+
76
+ # Get singular form safely with fallback
77
+ singular = obj.inflections.singular if obj.inflections else object_name
78
+
79
+ # Create object mapping
80
+ object_mappings[object_name] = object_id
81
+
82
+ # Create field mapping for this object
83
+ field_mappings[object_id] = {}
84
+ used_slugs = {}
85
+
86
+ for field in obj.fields:
87
+ field_key = field.key
88
+ field_name = field.name
89
+
90
+ # Slugify field name. Two distinct Knack fields can legally slugify to
91
+ # the same name ("Total ($)" and "Total (%)" both -> "total"), and a name
92
+ # with no ASCII alphanumerics slugifies to "". Either case would collapse
93
+ # columns in remap_keys, where the last field silently wins - so fall back
94
+ # to the Knack field key, which is unique app-wide.
95
+ new_key = slugify_field_name(field_name)
96
+ if not new_key:
97
+ new_key = field_key
98
+ # Compare the *slug*, not the raw name: Knack's auto-added field is named
99
+ # "Record ID", which lowercases to "record id" and only becomes "record_id"
100
+ # after slugification.
101
+ if new_key in restricted_field_names:
102
+ new_key = slugify_field_name(f"{singular} {new_key}") or f"{field_key}_{new_key}"
103
+ if new_key in used_slugs:
104
+ collided_with = used_slugs[new_key]
105
+ new_key = f"{new_key}_{field_key}"
106
+ logger.warning(
107
+ f"Field name {field_name!r} ({field_key}) on {object_name!r} slugifies onto "
108
+ f"{collided_with}'s column; loading it as {new_key!r} instead."
109
+ )
110
+ used_slugs[new_key] = field_key
111
+ field_mappings[object_id][field_key] = new_key
112
+
113
+ # Track numeric fields
114
+ if field.type in NUMERIC_FIELD_TYPES:
115
+ numeric_fields.append(new_key)
116
+ numeric_fields.append(field_key)
117
+ numeric_fields.append(field_name)
118
+
119
+ # Handle boolean fields with defaults
120
+ if field.type == 'boolean' and field.format and hasattr(field.format, '__dict__'):
121
+ # Access format as Pydantic model with extra fields allowed
122
+ format_dict = field.format.model_dump()
123
+ if 'default' in format_dict:
124
+ field_default_value = format_dict['default']
125
+ default_values[field_key] = field_default_value
126
+ default_values[new_key] = field_default_value
127
+ default_values[field_name] = field_default_value
128
+
129
+ return field_mappings, object_mappings, numeric_fields, default_values
130
+
131
+
132
+ def remap_keys(record: dict[str, Any], field_mapping: dict[str, str]) -> dict[str, Any]:
133
+ """Remaps the keys of a single record using the provided field mapping."""
134
+ return {field_mapping.get(key, key): value for key, value in record.items()}
135
+