continuo-python-runtime 0.3.1__tar.gz → 0.4.0__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.
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/ci.yml +8 -2
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/release.yml +6 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/CONTRIBUTING.md +7 -4
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/PKG-INFO +31 -5
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/README.md +29 -3
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/pyproject.toml +1 -1
- continuo_python_runtime-0.4.0/adapters/postgres/tests/conftest.py +114 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/tests/test_integration_runtime_postgres.py +79 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/pyproject.toml +1 -1
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/contract/loader.py +66 -31
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/contract/merge.py +14 -6
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/contract/model.py +2 -0
- continuo_python_runtime-0.4.0/continuo_python_runtime/csv_loader.py +68 -0
- continuo_python_runtime-0.4.0/continuo_python_runtime/csv_readers/__init__.py +17 -0
- continuo_python_runtime-0.4.0/continuo_python_runtime/csv_readers/https.py +118 -0
- continuo_python_runtime-0.4.0/continuo_python_runtime/csv_readers/s3.py +57 -0
- continuo_python_runtime-0.4.0/continuo_python_runtime/csv_source.py +96 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/harness.py +18 -7
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/validation/runner.py +39 -4
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/pyproject.toml +1 -1
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/docs/boundary-contract.md +24 -1
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/pyproject.toml +2 -2
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/template/Dockerfile +1 -1
- continuo_python_runtime-0.4.0/template/contracts/example_csv.yml +17 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/conftest.py +97 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/contract/test_loader.py +59 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/contract/test_merge.py +54 -1
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/contract/test_model.py +15 -1
- continuo_python_runtime-0.4.0/tests/test_contract_loader.py +32 -0
- continuo_python_runtime-0.4.0/tests/test_csv_loader.py +116 -0
- continuo_python_runtime-0.4.0/tests/test_csv_readers_https.py +107 -0
- continuo_python_runtime-0.4.0/tests/test_csv_readers_integration.py +302 -0
- continuo_python_runtime-0.4.0/tests/test_csv_source.py +64 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_harness.py +54 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_template.py +9 -5
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_validation_runner.py +206 -4
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/uv.lock +2 -2
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.dockerignore +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/CODEOWNERS +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/dependabot.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/dco.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/images.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/publish-pypi.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/security.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.gitignore +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.gitleaks.toml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/CHANGELOG.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/DCO +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/Dockerfile.postgres +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/Dockerfile.trino +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/LICENSE +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/NOTICE +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/SECURITY.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/README.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/continuo_python_runtime_postgres/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/continuo_python_runtime_postgres/adapter.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/tests/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/tests/test_adapter_postgres_validation.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/tests/test_adapter_runtime_postgres.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/tests/test_integration_postgres_validation.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/README.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/continuo_python_runtime_trino/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/continuo_python_runtime_trino/adapter.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/tests/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/tests/test_adapter_runtime_trino.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/tests/test_adapter_trino_validation.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/tests/test_integration_runtime_trino.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/tests/test_integration_trino_validation.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/cli.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/closure.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/conform.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/context.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/contract/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/contract/paths.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/errors.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/hashing.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/lint.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/types.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/validation/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/validation/s3.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/LICENSE +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/NOTICE +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/README.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/config.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/port.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/result.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/sql.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/types.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/test_config.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/test_contract_types.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/test_port.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/test_result.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/test_sql.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/docs/superpowers/plans/2026-07-31-python-runtime.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/docs/superpowers/plans/2026-08-07-step-3b-config-three-part-hash.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/docs/superpowers/specs/2026-07-31-python-runtime-design.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/scripts/security-scan.sh +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/template/.github/workflows/release.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/template/README.md +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/template/contracts/example.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/template/scripts/example.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/contract/__init__.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/smoke/node_smoke/contracts/smoke.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/smoke/node_smoke/scripts/smoke.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/smoke/postgres-stack/docker-compose.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/smoke/trino-stack/catalog/iceberg.properties +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/smoke/trino-stack/docker-compose.yml +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_cli.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_closure.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_conform.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_context.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_contract_pin_consistency.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_errors.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_hashing.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_image_smoke_validation.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_lint.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_no_legacy_names.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_package.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_types.py +0 -0
- {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_validation_s3.py +0 -0
|
@@ -24,9 +24,15 @@ jobs:
|
|
|
24
24
|
run: uv run --package continuo-python-runtime-trino mypy adapters/trino/continuo_python_runtime_trino
|
|
25
25
|
# `-m "not image"` deselects tests/test_image_smoke_validation.py, which
|
|
26
26
|
# needs a built engine image and the env naming it. Those tests run in
|
|
27
|
-
# images.yml's smoke jobs, where an image actually exists.
|
|
27
|
+
# images.yml's smoke jobs, where an image actually exists. `and not
|
|
28
|
+
# integration` deselects the csv-reader/validation-runner tests that
|
|
29
|
+
# need a real minio backend (started via `docker run` by the
|
|
30
|
+
# `minio_container` fixture, not docker-compose) -- those run in the
|
|
31
|
+
# dedicated step below, on the same runner, where docker is available.
|
|
28
32
|
- name: Tests (runtime)
|
|
29
|
-
run: uv run pytest --cov=continuo_python_runtime -m "not image" -v
|
|
33
|
+
run: uv run pytest --cov=continuo_python_runtime -m "not image and not integration" -v
|
|
34
|
+
- name: Tests (runtime, integration)
|
|
35
|
+
run: uv run pytest tests/test_csv_readers_integration.py tests/test_validation_runner.py -m integration -v
|
|
30
36
|
- name: Tests (contract)
|
|
31
37
|
run: uv run pytest contract/tests -v
|
|
32
38
|
- name: Tests (adapter units)
|
{continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/release.yml
RENAMED
|
@@ -49,6 +49,12 @@ jobs:
|
|
|
49
49
|
SHA: ${{ github.sha }}
|
|
50
50
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
51
51
|
steps:
|
|
52
|
+
# Needed for the CHANGELOG.md extraction step below — this job has no
|
|
53
|
+
# other source of the repo's files. Its absence went unnoticed through
|
|
54
|
+
# review because this job only triggers on push:tags, never on
|
|
55
|
+
# pull_request, so no PR's CI ever actually executes it.
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
|
|
52
58
|
- name: Wait for publish-pypi.yml and images.yml to finish on this commit
|
|
53
59
|
run: |
|
|
54
60
|
set -euo pipefail
|
|
@@ -39,7 +39,8 @@ repository; please do not add headers to new files.
|
|
|
39
39
|
## Development setup
|
|
40
40
|
|
|
41
41
|
Prerequisites: Python 3.14+, [uv](https://docs.astral.sh/uv/), and Docker (only needed
|
|
42
|
-
for the Postgres/Trino integration tests
|
|
42
|
+
for the Postgres/Trino integration tests and the csv-reader/validation-runner
|
|
43
|
+
integration tests, which start a real minio backend via `docker run`).
|
|
43
44
|
|
|
44
45
|
```bash
|
|
45
46
|
uv sync --all-packages --all-groups
|
|
@@ -58,14 +59,16 @@ uv run mypy continuo_python_runtime
|
|
|
58
59
|
uv run mypy contract/continuo_engine_contract
|
|
59
60
|
uv run --package continuo-python-runtime-postgres mypy adapters/postgres/continuo_python_runtime_postgres
|
|
60
61
|
uv run --package continuo-python-runtime-trino mypy adapters/trino/continuo_python_runtime_trino
|
|
61
|
-
uv run pytest --cov=continuo_python_runtime -m "not image" -v
|
|
62
|
+
uv run pytest --cov=continuo_python_runtime -m "not image and not integration" -v
|
|
63
|
+
uv run pytest tests/test_csv_readers_integration.py tests/test_validation_runner.py -m integration -v
|
|
62
64
|
uv run pytest contract/tests -v
|
|
63
65
|
uv run pytest adapters/postgres/tests adapters/trino/tests -m "not integration" -v
|
|
64
66
|
```
|
|
65
67
|
|
|
66
68
|
These are exactly what `.github/workflows/ci.yml` runs. Integration tests against a real
|
|
67
|
-
Postgres/Trino stack
|
|
68
|
-
`.github/workflows/ci.yml` for how CI
|
|
69
|
+
Postgres/Trino stack, or against the csv-reader/validation-runner minio backend, need
|
|
70
|
+
Docker and are not required for most changes — see `.github/workflows/ci.yml` for how CI
|
|
71
|
+
stands them up if you want to run them locally.
|
|
69
72
|
|
|
70
73
|
Also run the security scan before opening a pull request that touches dependencies or
|
|
71
74
|
anything that could carry a credential:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: continuo-python-runtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Runtime harness, contract tooling, and CI lint for Continuo python nodes.
|
|
5
5
|
Author: Simone Carolini
|
|
6
6
|
Maintainer: Simone Carolini
|
|
@@ -12,7 +12,7 @@ Classifier: Intended Audience :: Developers
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.14
|
|
13
13
|
Requires-Python: >=3.14
|
|
14
14
|
Requires-Dist: boto3==1.43.59
|
|
15
|
-
Requires-Dist: continuo-engine-contract==0.7.
|
|
15
|
+
Requires-Dist: continuo-engine-contract==0.7.2
|
|
16
16
|
Requires-Dist: pyarrow==25.0.0
|
|
17
17
|
Requires-Dist: pyyaml==6.0.3
|
|
18
18
|
Requires-Dist: sqlglot==30.15.0
|
|
@@ -114,7 +114,9 @@ the Go parser has not been taught is a production outage, not a refactor.
|
|
|
114
114
|
login` step to `release.yml`.
|
|
115
115
|
5. Write a contract file under `contracts/` (see
|
|
116
116
|
`template/contracts/example.yml`) and a script under `scripts/` that
|
|
117
|
-
implements `run(ctx)` (see `template/scripts/example.py`).
|
|
117
|
+
implements `run(ctx)` (see `template/scripts/example.py`). A node that
|
|
118
|
+
only needs to land a csv file needs no script at all — see
|
|
119
|
+
`template/contracts/example_csv.yml` and "Node kinds" below.
|
|
118
120
|
6. Push to `main`. The `release.yml` workflow lints the scripts, validates
|
|
119
121
|
and merges the contracts, builds and pushes the image, uploads the merged
|
|
120
122
|
contract to S3, and POSTs the release.
|
|
@@ -141,6 +143,30 @@ The runtime image does not re-run this gate, so a read that passes here is
|
|
|
141
143
|
not re-judged under a different grammar in production. See
|
|
142
144
|
`docs/boundary-contract.md` §13.1.
|
|
143
145
|
|
|
146
|
+
## Node kinds
|
|
147
|
+
|
|
148
|
+
A contract node's `kind:` field selects how the node produces its rows.
|
|
149
|
+
Every rule below (`extra_columns`, `output_columns`, "Conform rules") applies
|
|
150
|
+
to both kinds identically — `kind` only changes how the pre-conform table is
|
|
151
|
+
produced, never how it is checked or written.
|
|
152
|
+
|
|
153
|
+
- **`python-model`** (the default; the field may be omitted) — a script node.
|
|
154
|
+
It requires `script:` and a `reads:` map of one or more named SQL queries,
|
|
155
|
+
as described in "The script API" below.
|
|
156
|
+
- **`python-csv`** — a contract-only node: it has no script and its `reads:`
|
|
157
|
+
map must be exactly `{csv: <uri>}`, where the uri is `s3://bucket/key` or
|
|
158
|
+
an `https://` url (`http://` is rejected at validate time, not run time).
|
|
159
|
+
The harness fetches the file, parses it with RFC 4180 defaults, and feeds
|
|
160
|
+
the result straight into `conform()` — declared `output_columns` types
|
|
161
|
+
decide the warehouse schema, not whatever pyarrow infers from the csv.
|
|
162
|
+
Because there is no script, `script:` is a forbidden key for this kind;
|
|
163
|
+
`continuo-runtime validate`/`merge`/`lint` reject one that sets it. The
|
|
164
|
+
csv's header row must contain every declared output column (checked again,
|
|
165
|
+
independently, at release time before promotion); columns present in the
|
|
166
|
+
header but not declared are governed by the same `extra_columns` policy as
|
|
167
|
+
a script node's output — `raise` (default) fails the run, `warn` drops
|
|
168
|
+
them and logs a warning. See `template/contracts/example_csv.yml`.
|
|
169
|
+
|
|
144
170
|
## The script API
|
|
145
171
|
|
|
146
172
|
A node script is a Python file with exactly one required entry point:
|
|
@@ -236,9 +262,9 @@ A domain repo picks its warehouse engine by which base image it builds
|
|
|
236
262
|
`FROM`:
|
|
237
263
|
|
|
238
264
|
```dockerfile
|
|
239
|
-
FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.
|
|
265
|
+
FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.4.0
|
|
240
266
|
# or
|
|
241
|
-
FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.
|
|
267
|
+
FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.4.0
|
|
242
268
|
```
|
|
243
269
|
|
|
244
270
|
The engine is part of the image **name**; the tag is the bare version, so
|
|
@@ -94,7 +94,9 @@ the Go parser has not been taught is a production outage, not a refactor.
|
|
|
94
94
|
login` step to `release.yml`.
|
|
95
95
|
5. Write a contract file under `contracts/` (see
|
|
96
96
|
`template/contracts/example.yml`) and a script under `scripts/` that
|
|
97
|
-
implements `run(ctx)` (see `template/scripts/example.py`).
|
|
97
|
+
implements `run(ctx)` (see `template/scripts/example.py`). A node that
|
|
98
|
+
only needs to land a csv file needs no script at all — see
|
|
99
|
+
`template/contracts/example_csv.yml` and "Node kinds" below.
|
|
98
100
|
6. Push to `main`. The `release.yml` workflow lints the scripts, validates
|
|
99
101
|
and merges the contracts, builds and pushes the image, uploads the merged
|
|
100
102
|
contract to S3, and POSTs the release.
|
|
@@ -121,6 +123,30 @@ The runtime image does not re-run this gate, so a read that passes here is
|
|
|
121
123
|
not re-judged under a different grammar in production. See
|
|
122
124
|
`docs/boundary-contract.md` §13.1.
|
|
123
125
|
|
|
126
|
+
## Node kinds
|
|
127
|
+
|
|
128
|
+
A contract node's `kind:` field selects how the node produces its rows.
|
|
129
|
+
Every rule below (`extra_columns`, `output_columns`, "Conform rules") applies
|
|
130
|
+
to both kinds identically — `kind` only changes how the pre-conform table is
|
|
131
|
+
produced, never how it is checked or written.
|
|
132
|
+
|
|
133
|
+
- **`python-model`** (the default; the field may be omitted) — a script node.
|
|
134
|
+
It requires `script:` and a `reads:` map of one or more named SQL queries,
|
|
135
|
+
as described in "The script API" below.
|
|
136
|
+
- **`python-csv`** — a contract-only node: it has no script and its `reads:`
|
|
137
|
+
map must be exactly `{csv: <uri>}`, where the uri is `s3://bucket/key` or
|
|
138
|
+
an `https://` url (`http://` is rejected at validate time, not run time).
|
|
139
|
+
The harness fetches the file, parses it with RFC 4180 defaults, and feeds
|
|
140
|
+
the result straight into `conform()` — declared `output_columns` types
|
|
141
|
+
decide the warehouse schema, not whatever pyarrow infers from the csv.
|
|
142
|
+
Because there is no script, `script:` is a forbidden key for this kind;
|
|
143
|
+
`continuo-runtime validate`/`merge`/`lint` reject one that sets it. The
|
|
144
|
+
csv's header row must contain every declared output column (checked again,
|
|
145
|
+
independently, at release time before promotion); columns present in the
|
|
146
|
+
header but not declared are governed by the same `extra_columns` policy as
|
|
147
|
+
a script node's output — `raise` (default) fails the run, `warn` drops
|
|
148
|
+
them and logs a warning. See `template/contracts/example_csv.yml`.
|
|
149
|
+
|
|
124
150
|
## The script API
|
|
125
151
|
|
|
126
152
|
A node script is a Python file with exactly one required entry point:
|
|
@@ -216,9 +242,9 @@ A domain repo picks its warehouse engine by which base image it builds
|
|
|
216
242
|
`FROM`:
|
|
217
243
|
|
|
218
244
|
```dockerfile
|
|
219
|
-
FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.
|
|
245
|
+
FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.4.0
|
|
220
246
|
# or
|
|
221
|
-
FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.
|
|
247
|
+
FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.4.0
|
|
222
248
|
```
|
|
223
249
|
|
|
224
250
|
The engine is part of the image **name**; the tag is the bare version, so
|
{continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/pyproject.toml
RENAMED
|
@@ -12,7 +12,7 @@ dependencies = [
|
|
|
12
12
|
# of this adapter would otherwise accept whatever contract release is
|
|
13
13
|
# newest. This adapter subclasses the contract's WarehouseAdapter port
|
|
14
14
|
# directly, so the pair must stay the tested one.
|
|
15
|
-
"continuo-engine-contract==0.7.
|
|
15
|
+
"continuo-engine-contract==0.7.2",
|
|
16
16
|
"psycopg2-binary==2.9.11",
|
|
17
17
|
"pyarrow==25.0.0",
|
|
18
18
|
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Shared fixtures for adapters/postgres/tests.
|
|
2
|
+
|
|
3
|
+
``adapters/postgres/tests`` is its own top-level pytest package (it carries
|
|
4
|
+
``__init__.py``, per the root pyproject's import-mode note), so it cannot see
|
|
5
|
+
fixtures declared in the root ``tests/conftest.py`` — pytest only walks
|
|
6
|
+
conftest files up a test's own directory ancestry, and this directory is not
|
|
7
|
+
an ancestor of the root ``tests/`` package. ``minio_container`` is duplicated
|
|
8
|
+
here rather than imported, matching the root fixture byte-for-byte in
|
|
9
|
+
behavior (real minio via a plain ``docker run``, dynamic host port, health
|
|
10
|
+
wait, ``docker rm -f`` teardown) so the postgres-adapter integration suite
|
|
11
|
+
gets the same "real backends, no stubs, no silent skip" guarantee without a
|
|
12
|
+
cross-package import.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
import uuid
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="session")
|
|
26
|
+
def minio_container():
|
|
27
|
+
"""Session-scoped real minio backend, started via a plain `docker run`.
|
|
28
|
+
|
|
29
|
+
No testcontainers/pytest-docker dependency in this repo, so this drives
|
|
30
|
+
docker directly. Publishes minio's 9000 to an EPHEMERAL host port (colima
|
|
31
|
+
may already hold 9000 for another stack) and waits for minio's own
|
|
32
|
+
/minio/health/live endpoint to return 200 before yielding. Any failure —
|
|
33
|
+
docker missing, `docker run` erroring, the health check never turning
|
|
34
|
+
green — raises so the dependent tests error loudly instead of silently
|
|
35
|
+
skipping, per this suite's "real backends, no stubs, no silent skip"
|
|
36
|
+
integration-testing policy.
|
|
37
|
+
"""
|
|
38
|
+
name = f"postgres-adapter-minio-{uuid.uuid4().hex[:12]}"
|
|
39
|
+
try:
|
|
40
|
+
subprocess.run(
|
|
41
|
+
[
|
|
42
|
+
"docker", "run", "-d", "--name", name,
|
|
43
|
+
"-p", "0:9000",
|
|
44
|
+
"-e", "MINIO_ROOT_USER=minioadmin",
|
|
45
|
+
"-e", "MINIO_ROOT_PASSWORD=minioadmin",
|
|
46
|
+
"minio/minio:latest",
|
|
47
|
+
"server", "/data", "--address", ":9000",
|
|
48
|
+
],
|
|
49
|
+
check=True, capture_output=True, text=True, timeout=60,
|
|
50
|
+
)
|
|
51
|
+
except FileNotFoundError as exc:
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
"docker is not available; the postgres adapter's csv integration "
|
|
54
|
+
"test requires a live docker daemon"
|
|
55
|
+
) from exc
|
|
56
|
+
except subprocess.CalledProcessError as exc:
|
|
57
|
+
raise RuntimeError(f"docker run for minio failed: {exc.stderr}") from exc
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
port_out = subprocess.run(
|
|
61
|
+
["docker", "port", name, "9000/tcp"],
|
|
62
|
+
check=True, capture_output=True, text=True, timeout=10,
|
|
63
|
+
).stdout.strip()
|
|
64
|
+
# e.g. "0.0.0.0:54321\n[::]:54321" -- take the first (ipv4) mapping.
|
|
65
|
+
host_port = port_out.splitlines()[0].rsplit(":", 1)[1]
|
|
66
|
+
endpoint = f"http://127.0.0.1:{host_port}"
|
|
67
|
+
|
|
68
|
+
deadline = time.monotonic() + 30
|
|
69
|
+
last_error: Exception | None = None
|
|
70
|
+
healthy = False
|
|
71
|
+
while time.monotonic() < deadline:
|
|
72
|
+
try:
|
|
73
|
+
with urllib.request.urlopen(
|
|
74
|
+
f"{endpoint}/minio/health/live", timeout=2
|
|
75
|
+
) as resp:
|
|
76
|
+
if resp.status == 200:
|
|
77
|
+
healthy = True
|
|
78
|
+
break
|
|
79
|
+
except (urllib.error.URLError, ConnectionError, TimeoutError) as exc:
|
|
80
|
+
last_error = exc
|
|
81
|
+
time.sleep(0.5)
|
|
82
|
+
if not healthy:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"minio container {name} never became healthy at "
|
|
85
|
+
f"{endpoint}/minio/health/live: {last_error}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# make_s3_client() forwards only S3_ENDPOINT_URL and otherwise leaves
|
|
89
|
+
# credentials to boto3's own chain, so the chain needs something to
|
|
90
|
+
# find. This is session-scoped (not monkeypatch, which is
|
|
91
|
+
# function-scoped) -- set directly and restore on teardown.
|
|
92
|
+
# WARNING: any other test that runs while this fixture is active and
|
|
93
|
+
# makes a REAL (non-mocked, non-S3_ENDPOINT_URL-redirected) AWS call
|
|
94
|
+
# would authenticate with these fake minioadmin credentials, not the
|
|
95
|
+
# caller's real ones.
|
|
96
|
+
prior_key = os.environ.get("AWS_ACCESS_KEY_ID")
|
|
97
|
+
prior_secret = os.environ.get("AWS_SECRET_ACCESS_KEY")
|
|
98
|
+
os.environ["AWS_ACCESS_KEY_ID"] = "minioadmin"
|
|
99
|
+
os.environ["AWS_SECRET_ACCESS_KEY"] = "minioadmin"
|
|
100
|
+
try:
|
|
101
|
+
yield (endpoint, "minioadmin", "minioadmin")
|
|
102
|
+
finally:
|
|
103
|
+
if prior_key is None:
|
|
104
|
+
os.environ.pop("AWS_ACCESS_KEY_ID", None)
|
|
105
|
+
else:
|
|
106
|
+
os.environ["AWS_ACCESS_KEY_ID"] = prior_key
|
|
107
|
+
if prior_secret is None:
|
|
108
|
+
os.environ.pop("AWS_SECRET_ACCESS_KEY", None)
|
|
109
|
+
else:
|
|
110
|
+
os.environ["AWS_SECRET_ACCESS_KEY"] = prior_secret
|
|
111
|
+
finally:
|
|
112
|
+
subprocess.run(
|
|
113
|
+
["docker", "rm", "-f", name], capture_output=True, text=True, timeout=30
|
|
114
|
+
)
|
|
@@ -7,10 +7,13 @@ import decimal
|
|
|
7
7
|
import os
|
|
8
8
|
import uuid
|
|
9
9
|
|
|
10
|
+
import boto3
|
|
10
11
|
import psycopg2
|
|
11
12
|
import pyarrow as pa
|
|
12
13
|
import pytest
|
|
14
|
+
import yaml
|
|
13
15
|
|
|
16
|
+
from continuo_python_runtime.harness import run_node
|
|
14
17
|
from continuo_python_runtime_postgres.adapter import PostgresAdapter
|
|
15
18
|
|
|
16
19
|
PG = dict(
|
|
@@ -303,3 +306,79 @@ def test_ensure_schema_generic_failure_rolls_back_and_releases_advisory_lock():
|
|
|
303
306
|
second.commit()
|
|
304
307
|
second.close()
|
|
305
308
|
assert acquired is True
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# --- python-csv node end-to-end: real minio -> run_node -> real postgres ---
|
|
312
|
+
#
|
|
313
|
+
# minio_container is declared in adapters/postgres/tests/conftest.py (this
|
|
314
|
+
# package cannot see the root tests/conftest.py fixture of the same name --
|
|
315
|
+
# see that conftest's module docstring).
|
|
316
|
+
|
|
317
|
+
CSV_BODY = b"order_id,amount\n1,10.5\n2,20.0\n3,5.25\n"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@pytest.fixture(scope="session")
|
|
321
|
+
def csv_minio(minio_container):
|
|
322
|
+
"""Seed a real minio bucket with the csv this test's node reads."""
|
|
323
|
+
endpoint, access, secret = minio_container
|
|
324
|
+
client = boto3.client(
|
|
325
|
+
"s3", endpoint_url=endpoint,
|
|
326
|
+
aws_access_key_id=access, aws_secret_access_key=secret,
|
|
327
|
+
)
|
|
328
|
+
client.create_bucket(Bucket="pg-drops")
|
|
329
|
+
client.put_object(Bucket="pg-drops", Key="orders.csv", Body=CSV_BODY)
|
|
330
|
+
return endpoint
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _csv_contract_dir(tmp_path, schema):
|
|
334
|
+
"""A contract dir with a single python-csv node reading s3://pg-drops/orders.csv."""
|
|
335
|
+
(tmp_path / "contracts").mkdir()
|
|
336
|
+
(tmp_path / "contracts" / "t.yml").write_text(yaml.safe_dump({"nodes": [{
|
|
337
|
+
"schema": schema, "table": "orders_csv", "owner": "m", "schedule": "daily",
|
|
338
|
+
"criticality": "SECONDARY", "kind": "python-csv",
|
|
339
|
+
"reads": {"csv": "s3://pg-drops/orders.csv"},
|
|
340
|
+
"output_columns": [
|
|
341
|
+
{"name": "order_id", "type": "INTEGER", "nullable": False},
|
|
342
|
+
{"name": "amount", "type": "DOUBLE PRECISION"},
|
|
343
|
+
],
|
|
344
|
+
}]}))
|
|
345
|
+
return tmp_path
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@pytest.mark.integration
|
|
349
|
+
def test_run_node_csv_kind_loads_minio_csv_into_postgres(clean_schema, csv_minio, monkeypatch, tmp_path):
|
|
350
|
+
"""run_node on a python-csv node fetches from real minio and writes to real postgres.
|
|
351
|
+
|
|
352
|
+
Exercises the full production path added in this task: harness.run_node
|
|
353
|
+
dispatches on node.kind to csv_loader.produce_csv (no reader/adapter test
|
|
354
|
+
doubles here -- the S3CsvSourceReader from csv_readers.reader_for talks
|
|
355
|
+
to the real minio container, and PostgresAdapter writes to the real
|
|
356
|
+
postgres stack), then conform()/ensure_table()/load() proceed exactly as
|
|
357
|
+
for a python-model node.
|
|
358
|
+
"""
|
|
359
|
+
monkeypatch.setenv("S3_ENDPOINT_URL", csv_minio)
|
|
360
|
+
repo = _csv_contract_dir(tmp_path, clean_schema)
|
|
361
|
+
env = {
|
|
362
|
+
"NODE_ID": f"python-csv.svc.{clean_schema}.orders_csv",
|
|
363
|
+
"TABLE_NAME": "orders_csv",
|
|
364
|
+
"TARGET_SCHEMA": clean_schema,
|
|
365
|
+
"CONTRACT_DIR": str(repo / "contracts"),
|
|
366
|
+
"APP_ROOT": str(repo),
|
|
367
|
+
}
|
|
368
|
+
a = _adapter()
|
|
369
|
+
|
|
370
|
+
assert run_node(env, adapter=a) == 0
|
|
371
|
+
|
|
372
|
+
assert _columns(clean_schema, "orders_csv") == [
|
|
373
|
+
("order_id", "integer", "NO"),
|
|
374
|
+
("amount", "double precision", "YES"),
|
|
375
|
+
]
|
|
376
|
+
assert _count(clean_schema, "orders_csv") == 3
|
|
377
|
+
conn = _conn()
|
|
378
|
+
with conn.cursor() as cur:
|
|
379
|
+
cur.execute(
|
|
380
|
+
f'SELECT order_id, amount FROM "{clean_schema}"."orders_csv" ORDER BY order_id'
|
|
381
|
+
)
|
|
382
|
+
rows = cur.fetchall()
|
|
383
|
+
conn.close()
|
|
384
|
+
assert rows == [(1, 10.5), (2, 20.0), (3, 5.25)]
|
{continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/pyproject.toml
RENAMED
|
@@ -12,7 +12,7 @@ dependencies = [
|
|
|
12
12
|
# of this adapter would otherwise accept whatever contract release is
|
|
13
13
|
# newest. This adapter subclasses the contract's WarehouseAdapter port
|
|
14
14
|
# directly, so the pair must stay the tested one.
|
|
15
|
-
"continuo-engine-contract==0.7.
|
|
15
|
+
"continuo-engine-contract==0.7.2",
|
|
16
16
|
"trino==0.338.0",
|
|
17
17
|
"pyarrow==25.0.0",
|
|
18
18
|
]
|
|
@@ -18,9 +18,11 @@ from sqlglot.errors import TokenError
|
|
|
18
18
|
from continuo_python_runtime.contract.model import (
|
|
19
19
|
CRITICALITIES,
|
|
20
20
|
EXTRA_COLUMNS_POLICIES,
|
|
21
|
+
KINDS,
|
|
21
22
|
Column,
|
|
22
23
|
Node,
|
|
23
24
|
)
|
|
25
|
+
from continuo_python_runtime.csv_source import parse_csv_uri
|
|
24
26
|
from continuo_python_runtime.errors import ContractError
|
|
25
27
|
from continuo_python_runtime.types import parse_sql_type
|
|
26
28
|
|
|
@@ -31,6 +33,7 @@ _ALLOWED_KEYS = {
|
|
|
31
33
|
"owner",
|
|
32
34
|
"schedule",
|
|
33
35
|
"criticality",
|
|
36
|
+
"kind",
|
|
34
37
|
"script",
|
|
35
38
|
"extra_columns",
|
|
36
39
|
"reads",
|
|
@@ -39,7 +42,7 @@ _ALLOWED_KEYS = {
|
|
|
39
42
|
"content_hash",
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
_REQUIRED_STRING_FIELDS = ("schema", "table", "owner", "schedule"
|
|
45
|
+
_REQUIRED_STRING_FIELDS = ("schema", "table", "owner", "schedule")
|
|
43
46
|
|
|
44
47
|
_ALLOWED_OUTPUT_COLUMN_KEYS = {"name", "type", "nullable"}
|
|
45
48
|
|
|
@@ -170,6 +173,12 @@ def parse_node(
|
|
|
170
173
|
if unknown:
|
|
171
174
|
raise ContractError(f"{label}: unknown key(s) {sorted(unknown)}")
|
|
172
175
|
|
|
176
|
+
kind = raw.get("kind", "python-model")
|
|
177
|
+
if not isinstance(kind, str) or kind not in KINDS:
|
|
178
|
+
raise ContractError(
|
|
179
|
+
f"{label}: 'kind' must be one of {sorted(KINDS)}, got {kind!r}"
|
|
180
|
+
)
|
|
181
|
+
|
|
173
182
|
for field in _REQUIRED_STRING_FIELDS:
|
|
174
183
|
value = raw.get(field)
|
|
175
184
|
if not isinstance(value, str) or not value.strip():
|
|
@@ -181,7 +190,21 @@ def parse_node(
|
|
|
181
190
|
table = raw["table"]
|
|
182
191
|
owner = raw["owner"]
|
|
183
192
|
schedule = raw["schedule"]
|
|
184
|
-
|
|
193
|
+
|
|
194
|
+
if kind == "python-csv":
|
|
195
|
+
if "script" in raw:
|
|
196
|
+
raise ContractError(
|
|
197
|
+
f"{label}: 'script' is forbidden for kind python-csv "
|
|
198
|
+
"(csv nodes are contract-only)"
|
|
199
|
+
)
|
|
200
|
+
script = ""
|
|
201
|
+
else:
|
|
202
|
+
raw_script = raw.get("script")
|
|
203
|
+
if not isinstance(raw_script, str) or not raw_script.strip():
|
|
204
|
+
raise ContractError(
|
|
205
|
+
f"{label}: required field 'script' must be a non-empty string"
|
|
206
|
+
)
|
|
207
|
+
script = raw_script
|
|
185
208
|
|
|
186
209
|
criticality = raw.get("criticality")
|
|
187
210
|
if not isinstance(criticality, str) or criticality not in CRITICALITIES:
|
|
@@ -200,39 +223,50 @@ def parse_node(
|
|
|
200
223
|
)
|
|
201
224
|
|
|
202
225
|
reads = raw.get("reads")
|
|
203
|
-
if
|
|
204
|
-
|
|
205
|
-
f"{label}: 'reads' must be a non-empty mapping of name -> SQL"
|
|
206
|
-
)
|
|
207
|
-
for name, sql in reads.items():
|
|
208
|
-
if not isinstance(name, str) or not name.strip():
|
|
209
|
-
raise ContractError(
|
|
210
|
-
f"{label}: 'reads' name {name!r} must be a non-empty string"
|
|
211
|
-
)
|
|
212
|
-
if not isinstance(sql, str) or not sql.strip():
|
|
226
|
+
if kind == "python-csv":
|
|
227
|
+
if not isinstance(reads, dict) or set(reads) != {"csv"}:
|
|
213
228
|
raise ContractError(
|
|
214
|
-
f"{label}: 'reads
|
|
229
|
+
f"{label}: a python-csv node's 'reads' must be exactly {{csv: <uri>}}"
|
|
215
230
|
)
|
|
216
|
-
if not check_reads:
|
|
217
|
-
continue
|
|
218
231
|
try:
|
|
219
|
-
|
|
220
|
-
except (ValueError,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
# string literal or comment fails sqlglot's tokenizer with a
|
|
225
|
-
# TokenError, a SqlglotError sibling of ParseError and not a
|
|
226
|
-
# subclass of ValueError -- despite ensure_single_read's
|
|
227
|
-
# docstring promising every rejection is a ValueError. Only
|
|
228
|
-
# TokenError, not the broader SqlglotError, is caught here: by
|
|
229
|
-
# the time control reaches this point `dialect` has already been
|
|
230
|
-
# validated once in load_contract_dir, so any other SqlglotError
|
|
231
|
-
# a future sqlglot version might raise from this call should
|
|
232
|
-
# surface as itself, not get relabeled as a rejected read.
|
|
232
|
+
parse_csv_uri(reads["csv"])
|
|
233
|
+
except (ValueError, TypeError) as exc:
|
|
234
|
+
raise ContractError(f"{label}: invalid csv uri: {exc}") from exc
|
|
235
|
+
else:
|
|
236
|
+
if not isinstance(reads, dict) or not reads:
|
|
233
237
|
raise ContractError(
|
|
234
|
-
f"{label}: 'reads
|
|
235
|
-
)
|
|
238
|
+
f"{label}: 'reads' must be a non-empty mapping of name -> SQL"
|
|
239
|
+
)
|
|
240
|
+
for name, sql in reads.items():
|
|
241
|
+
if not isinstance(name, str) or not name.strip():
|
|
242
|
+
raise ContractError(
|
|
243
|
+
f"{label}: 'reads' name {name!r} must be a non-empty string"
|
|
244
|
+
)
|
|
245
|
+
if not isinstance(sql, str) or not sql.strip():
|
|
246
|
+
raise ContractError(
|
|
247
|
+
f"{label}: 'reads.{name}' must be a non-empty SQL string"
|
|
248
|
+
)
|
|
249
|
+
if not check_reads:
|
|
250
|
+
continue
|
|
251
|
+
try:
|
|
252
|
+
ensure_single_read(sql, dialect)
|
|
253
|
+
except (ValueError, TokenError) as exc:
|
|
254
|
+
# ensure_single_read's own message is phrased for check_binds
|
|
255
|
+
# (its only other caller today), so it's wrapped rather than
|
|
256
|
+
# surfaced bare here. TokenError is also caught: an unterminated
|
|
257
|
+
# string literal or comment fails sqlglot's tokenizer with a
|
|
258
|
+
# TokenError, a SqlglotError sibling of ParseError and not a
|
|
259
|
+
# subclass of ValueError -- despite ensure_single_read's
|
|
260
|
+
# docstring promising every rejection is a ValueError. Only
|
|
261
|
+
# TokenError, not the broader SqlglotError, is caught here: by
|
|
262
|
+
# the time control reaches this point `dialect` has already
|
|
263
|
+
# been validated once in load_contract_dir, so any other
|
|
264
|
+
# SqlglotError a future sqlglot version might raise from this
|
|
265
|
+
# call should surface as itself, not get relabeled as a
|
|
266
|
+
# rejected read.
|
|
267
|
+
raise ContractError(
|
|
268
|
+
f"{label}: 'reads.{name}' must be a single read query ({exc})"
|
|
269
|
+
) from exc
|
|
236
270
|
|
|
237
271
|
raw_columns = raw.get("output_columns")
|
|
238
272
|
if not isinstance(raw_columns, list) or not raw_columns:
|
|
@@ -297,6 +331,7 @@ def parse_node(
|
|
|
297
331
|
extra_columns=extra_columns,
|
|
298
332
|
config=config,
|
|
299
333
|
content_hash=content_hash,
|
|
334
|
+
kind=kind,
|
|
300
335
|
)
|
|
301
336
|
|
|
302
337
|
|
|
@@ -27,6 +27,7 @@ def node_entry(node: Node) -> dict:
|
|
|
27
27
|
"owner": node.owner,
|
|
28
28
|
"schedule": node.schedule,
|
|
29
29
|
"criticality": node.criticality,
|
|
30
|
+
"kind": node.kind,
|
|
30
31
|
"script": node.script,
|
|
31
32
|
"reads": node.reads,
|
|
32
33
|
"output_columns": [
|
|
@@ -128,12 +129,19 @@ def build_wire_contract(
|
|
|
128
129
|
for node in nodes:
|
|
129
130
|
entry = node_entry(node)
|
|
130
131
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
if node.kind == "python-csv":
|
|
133
|
+
# A csv node has no script and no import closure: its source IS
|
|
134
|
+
# the uri -- new file content at the same uri is new data, not a
|
|
135
|
+
# new node version.
|
|
136
|
+
uri_bytes = node.reads["csv"].encode()
|
|
137
|
+
entry.update(hash_parts(entry, uri_bytes, []))
|
|
138
|
+
else:
|
|
139
|
+
script_path = resolve_script_path(node.script, repo_root, context=node.relation)
|
|
140
|
+
script_bytes = script_path.read_bytes()
|
|
141
|
+
closure = resolve_closure(script_path, repo_root)
|
|
142
|
+
member_bytes = [member.read_bytes() for member in closure]
|
|
143
|
+
_lint_node_closure(node, repo_root, script_path, script_bytes, closure, member_bytes)
|
|
144
|
+
entry.update(hash_parts(entry, script_bytes, member_bytes))
|
|
137
145
|
|
|
138
146
|
wire_nodes.append(entry)
|
|
139
147
|
|
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
|
6
6
|
# Module-level constants
|
|
7
7
|
CRITICALITIES = frozenset({"REGULATORY", "CORE", "SECONDARY"})
|
|
8
8
|
EXTRA_COLUMNS_POLICIES = frozenset({"raise", "warn"})
|
|
9
|
+
KINDS = frozenset({"python-model", "python-csv"})
|
|
9
10
|
CONTRACT_VERSION = 1
|
|
10
11
|
|
|
11
12
|
|
|
@@ -34,6 +35,7 @@ class Node:
|
|
|
34
35
|
extra_columns: str = "raise"
|
|
35
36
|
config: dict[str, Any] = field(default_factory=dict)
|
|
36
37
|
content_hash: str | None = None
|
|
38
|
+
kind: str = "python-model"
|
|
37
39
|
|
|
38
40
|
@property
|
|
39
41
|
def relation(self) -> str:
|