Contract-driven DAS-layer ingestion CLI built on dlt
Project description
das-cli
Contract-driven ingestion for the raw→staging boundary, built on dlt.
Built on the DAS (Data As System) layer concept and contract-driven philosophy from Daana's writing — The rise of the model-driven data engineer and Contract-driven data transformation.
What it is
das-cli lands raw source data into a compressed JSONL landing zone, then promotes it into a contract-enforced warehouse (DuckDB, DuckLake, or Delta). ODCS v3 contracts are the single source of truth for schema, types, and keys — schemas are frozen and projected, not inferred at load time.
It is scoped to the DAS layer: lossless landing and contract-projected staging. It is not an ELT modeling tool. Transformations, business logic, and downstream modeling belong in the layers above (dbt, SQLMesh, etc.).
How it works
das-cli works in three steps — a one-time bootstrap, then two pipelines per ingest:
-
Bootstrap a contract — point das-cli at an OData service or OpenAPI spec; it emits an editable ODCS v3 contract per resource, plus a
_source.ymlfor connection details. (ADR-0005, ADR-0006, ADR-0015.) -
P1 — land — extract raw records into a compressed JSONL landing zone under
./landing/, hive-partitioned by extract date. Each row is stored as a_data: jsonblob — lossless, no schema applied. (ADR-0002, ADR-0003.) -
P2 — promote — project the JSONL blobs into contract-enforced DuckDB staging tables. Schemas are frozen against the ODCS contract. Loads are append-only with row-hash change detection: each row's hash is compared against the latest hash per primary key in the destination, and only changed rows are appended. (ADR-0004, ADR-0008, ADR-0012.)
Quickstart
uv tool install das-layer # or: pip install das-layer / pipx install das-layer
mkdir my-data && cd my-data
das init
das source add adventureworks \
--type odata \
--url https://demodata.grapecity.com/adventureworks/odata/v1
das contract bootstrap adventureworks
das ingest adventureworks
For a SQL source (Oracle / SQL Server / Postgres / MySQL / SQLite), pass a
SQLAlchemy URL with no credentials — das-cli templates env_var(...) for
username / password into _source.yml and writes a matching .env
block. The driver wheel is your responsibility:
uv tool install das-layer --with oracledb # or --with pyodbc
das source add finance \
--type sql \
--url "oracle+oracledb://oracle-host:1521/?service_name=XEPDB1"
# Edit .env: set FINANCE__USERNAME / FINANCE__PASSWORD.
das contract bootstrap finance
das ingest finance
For a CSV source (local files), point das at a directory of CSV files.
das contract bootstrap walks the directory and emits one contract per
file; widen das.spec_ref.path to a glob if you want monthly drops
combined into a single resource (ADR-0022):
das source add accounts \
--type filesystem \
--url ./data/accounts/
das contract bootstrap accounts
# Edit contracts/accounts/*.odcs.yaml — widen `path` to a glob and refine types.
das ingest accounts
For an Excel source (local .xlsx workbooks), point das at a directory
of workbooks. das contract bootstrap enumerates each workbook's
sheets and emits one contract per (workbook, sheet). Each contract's
das.spec_ref.path is the workbook filename; sheet selects which
sheet to read. Use inject: {col: sheet} if you want the sheet name
projected as a column (ADR-0023).
uv tool install das-layer --with openpyxl
das source add q1-exports \
--type filesystem \
--url ./data/q1-exports/
das contract bootstrap q1-exports
# Edit contracts/q1-exports/*.odcs.yaml — set primary keys, refine types.
das ingest q1-exports
For an XML source (local files), provide an XPath to identify each
record element. The reader streams matched elements via lxml's
iterparse; column names come from the element's attributes + direct
child elements. Namespaced documents are supported by declaring
namespaces in the contract's das.spec_ref.
uv tool install das-layer --with lxml
das source add catalog \
--type filesystem \
--url ./data/catalog/
das contract bootstrap catalog --xpath '//customer'
# Edit contracts/catalog/*.odcs.yaml — set primary keys, refine types.
das ingest catalog
For a QVD source (local Qlik .qvd exports), point das at a directory
of QVD files. Unlike csv/excel/xml — where bootstrap defaults all
column types to string — QVD's embedded metadata gives das the real
types up front (integer/number/string/date/timestamp), so you usually
only need to set primary keys post-bootstrap.
uv tool install das-layer --with pyqvd
das source add finance \
--type filesystem \
--url ./data/finance/
das contract bootstrap finance
# Edit contracts/finance/*.odcs.yaml — set primary keys (types already good).
das ingest finance
After das ingest:
- raw JSONL lands in
./landing/, hive-partitioned by extract date - contract-enforced staging tables live in
./das.duckdb
Example workspace
After das init and das source add --type odata ..., the workspace looks
like this — the das.yaml is the workspace config; _source.yml is the
per-source connection; the .odcs.yaml file is the contract you'd typically
hand-edit after das contract bootstrap.
.
├── das.yaml
├── .env
└── contracts/
└── adventureworks/
├── _source.yml
└── products.odcs.yaml
das.yaml — workspace config written by das init. (For DuckLake or
Delta targets, see ADR-0016 / ADR-0017.)
target:
destination: duckdb
credentials:
database: "{{ env_var('DAS_TARGET_DB', './das.duckdb') }}"
landing:
bucket_url: "{{ env_var('DAS_LANDING_URL', './landing') }}"
credentials: {}
contracts:
root: ./contracts
dataset:
prefix: das # set to null (or omit) for no prefix; dataset = source name
defaults:
write_disposition: append
change_detection: row_hash
schema_contract:
tables: evolve
columns: freeze
data_type: freeze
contracts/adventureworks/_source.yml — per-source connection written
by das source add. (ADR-0005 covers the contract/source split; ADR-0007
covers secrets via env_var.)
type: odata
endpoint: https://demodata.grapecity.com/adventureworks/odata/v1
spec:
kind: odata
metadata_url: https://demodata.grapecity.com/adventureworks/odata/v1/$metadata
auth:
type: none
ownership:
team: data-platform
contracts/adventureworks/products.odcs.yaml — ODCS v3 contract,
post-edit. Defines the projected schema, primary key, and a das.cursor
for incremental extraction. (ADR-0011, ADR-0014 cover the das.*
namespace.)
apiVersion: v3.0.0
kind: DataContract
id: adventureworks.products
name: Products
version: 0.1.0
status: draft
info: { title: Products, owner: data-platform }
schema:
- name: products
physicalName: products
physicalType: table
properties:
- { name: product_id, physicalName: ProductId, logicalType: integer,
physicalType: bigint, required: true, primaryKey: true }
- { name: name, physicalName: Name, logicalType: string, required: true }
- { name: modified_at, physicalName: ModifiedDate, logicalType: timestamp }
customProperties:
- { property: das.cursor, value: modified_at }
Other source shapes
Only _source.yml varies per source type. The das.yaml workspace config
and the contract shape are the same regardless of source.
OpenAPI / REST — produced by das source add --type openapi --url https://api.example.com/v1:
type: openapi
endpoint: https://api.example.com/v1
spec:
kind: openapi
swagger_url: https://api.example.com/v1/swagger.json
auth:
type: none
ownership:
team: data-platform
Resource contracts under OpenAPI sources need an extra das.endpoint
custom property so the extractor knows which path to hit and how to
unwrap the response. (ADR-0014, ADR-0015.)
customProperties:
- property: das.endpoint
value:
path: /Customers
response_path: $.value
SQL — produced by das source add --type sql --url "oracle+oracledb://oracle-host:1521/?service_name=XEPDB1". Credentials
are templated into the URL via env_var; the matching .env block is
written by the same command. (ADR-0018.)
type: sql
endpoint: "oracle+oracledb://{{ env_var('FINANCE__USERNAME') }}:{{ env_var('FINANCE__PASSWORD') }}@oracle-host:1521/?service_name=XEPDB1"
spec:
kind: sql
auth:
type: none
ownership:
team: data-platform
Concepts
- Contract — an ODCS v3
YAML file per resource. Defines schema, types, and primary keys. Edited by
hand after
bootstrap; frozen on ingest. (ADR-0005, ADR-0012.) _source.yml— per-source connection config (type, URL, auth). Kept separate from contracts so resource definitions stay portable. (ADR-0005.)das.*custom properties — the namespace for das-cli's extensions to ODCS, includingdas.endpoint,das.hash_exclude, and load-date hints. (ADR-0011, ADR-0014.)- Landing zone —
./landing/<source>/<resource>/_extracted_on=YYYY-MM-DD/, compressed JSONL, hive-partitioned. The lossless raw record. (ADR-0002, ADR-0013.) - P1 / P2 pipelines — extract (source → landing) and load (landing → DuckDB). One P1 per source, one shared P2. (ADR-0002, ADR-0008.)
- Secrets — referenced in
_source.ymlvia{{ env_var("...") }}. No.dlt/secrets.toml. (ADR-0007.)
Sources & destinations
das-cli is built on dlt, so in principle any dlt source or destination should work. The e2e suite verifies edges, not the full matrix: each source is exercised against DuckDB, and each destination is exercised against AdventureWorks (OData). A row below means the corresponding edge has an end-to-end test against a real instance.
Verified sources (against DuckDB)
| Source | Notes |
|---|---|
| OData v2 / v4 | OData metadata bootstrap (ADR-0006) |
| OpenAPI / REST | Per-resource das.endpoint (ADR-0014, 0015) |
| Oracle (oracledb) | SQLAlchemy reflection (ADR-0018); Oracle Free |
| SQL Server (pyodbc) | SQLAlchemy reflection (ADR-0018); ODBC Driver 18 |
| CSV (local) | Filesystem source via dlt (ADR-0022); glob across files |
| Excel (local .xlsx) | openpyxl read-only streaming (ADR-0022); one resource per sheet |
| XML (local) | lxml iterparse streaming (ADR-0022); xpath-driven records |
| QVD (Qlik export) | pyqvd chunked streaming (ADR-0022); types inferred from header |
Verified destinations (against OData / AdventureWorks)
| Destination | Notes |
|---|---|
| DuckDB | Default; in-process, no container |
| DuckLake | Local DuckDB-file catalog (ADR-0016) |
| Delta | Local filesystem + Azure ADLS Gen2 + OneLake (write paths; read-back via sql_client not yet wired for OneLake) — ADR-0017, ADR-0020 |
Want a combination verified?
Open an issue describing the source or destination you'd like to use. "Verified" means there's an end-to-end edge test against a real instance — source verified against DuckDB, or destination verified against OData. We don't fan out the full N×M matrix; if you need a specific (source, destination) pair exercised, file an issue and we'll discuss whether it warrants its own test.
Development
uv sync
uv run pytest # unit + integration
uv run pytest -m e2e # opt-in; hits public AdventureWorks/Northwind APIs
uv run pre-commit run --all-files
For architectural context (decision records, design specs, implementation
plans), see the Documentation section below and CLAUDE.md.
Documentation
docs/adrs/— Architecture Decision Records: why is it built this way?docs/superpowers/specs/— design specs: what's planned / shipped?docs/superpowers/plans/— implementation plans: how is it being built?CLAUDE.md— project rules and ADR lifecycle policy: what are the rules?
License
See LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file das_layer-0.1.2.tar.gz.
File metadata
- Download URL: das_layer-0.1.2.tar.gz
- Upload date:
- Size: 710.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8672dadecb6fc84a19178cae47ab58fcf7d014cbf2d4ae37823ee67a4898495e
|
|
| MD5 |
3fcdb4dc2999d1f76b3f71958a2ce568
|
|
| BLAKE2b-256 |
4d69abf9051ddb7111fc3525f8b626a5b1f0f4b9a1594af06d1c32e7f8ee3809
|
Provenance
The following attestation bundles were made for das_layer-0.1.2.tar.gz:
Publisher:
release.yml on mattiasthalen/das-cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
das_layer-0.1.2.tar.gz -
Subject digest:
8672dadecb6fc84a19178cae47ab58fcf7d014cbf2d4ae37823ee67a4898495e - Sigstore transparency entry: 1574160745
- Sigstore integration time:
-
Permalink:
mattiasthalen/das-cli@86268342dd14796309d1cb3b00e1740e08031108 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/mattiasthalen
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@86268342dd14796309d1cb3b00e1740e08031108 -
Trigger Event:
push
-
Statement type:
File details
Details for the file das_layer-0.1.2-py3-none-any.whl.
File metadata
- Download URL: das_layer-0.1.2-py3-none-any.whl
- Upload date:
- Size: 79.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
08a7d724a09c394c78edcfeae32d83198ba7405fe04c3e5961740d246f0f4bba
|
|
| MD5 |
9c25445203e9cfed34c5737f757bd089
|
|
| BLAKE2b-256 |
d056a5750418be51a44503717713f7a34fd3beb802f79dd5dd24e3fc7bfca294
|
Provenance
The following attestation bundles were made for das_layer-0.1.2-py3-none-any.whl:
Publisher:
release.yml on mattiasthalen/das-cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
das_layer-0.1.2-py3-none-any.whl -
Subject digest:
08a7d724a09c394c78edcfeae32d83198ba7405fe04c3e5961740d246f0f4bba - Sigstore transparency entry: 1574160760
- Sigstore integration time:
-
Permalink:
mattiasthalen/das-cli@86268342dd14796309d1cb3b00e1740e08031108 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/mattiasthalen
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@86268342dd14796309d1cb3b00e1740e08031108 -
Trigger Event:
push
-
Statement type: