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.
- knack_elt-0.2.1/PKG-INFO +191 -0
- knack_elt-0.2.1/README.md +178 -0
- knack_elt-0.2.1/pyproject.toml +61 -0
- knack_elt-0.2.1/pyproject.toml.orig +61 -0
- knack_elt-0.2.1/src/knack_elt/__init__.py +1 -0
- knack_elt-0.2.1/src/knack_elt/cli.py +149 -0
- knack_elt-0.2.1/src/knack_elt/config.py +23 -0
- knack_elt-0.2.1/src/knack_elt/knack_dlt.py +182 -0
- knack_elt-0.2.1/src/knack_elt/mapping.py +135 -0
knack_elt-0.2.1/PKG-INFO
ADDED
|
@@ -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
|
+
|