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.
Files changed (129) hide show
  1. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/ci.yml +8 -2
  2. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/release.yml +6 -0
  3. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/CONTRIBUTING.md +7 -4
  4. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/PKG-INFO +31 -5
  5. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/README.md +29 -3
  6. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/pyproject.toml +1 -1
  7. continuo_python_runtime-0.4.0/adapters/postgres/tests/conftest.py +114 -0
  8. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/tests/test_integration_runtime_postgres.py +79 -0
  9. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/pyproject.toml +1 -1
  10. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/contract/loader.py +66 -31
  11. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/contract/merge.py +14 -6
  12. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/contract/model.py +2 -0
  13. continuo_python_runtime-0.4.0/continuo_python_runtime/csv_loader.py +68 -0
  14. continuo_python_runtime-0.4.0/continuo_python_runtime/csv_readers/__init__.py +17 -0
  15. continuo_python_runtime-0.4.0/continuo_python_runtime/csv_readers/https.py +118 -0
  16. continuo_python_runtime-0.4.0/continuo_python_runtime/csv_readers/s3.py +57 -0
  17. continuo_python_runtime-0.4.0/continuo_python_runtime/csv_source.py +96 -0
  18. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/harness.py +18 -7
  19. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/validation/runner.py +39 -4
  20. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/pyproject.toml +1 -1
  21. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/docs/boundary-contract.md +24 -1
  22. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/pyproject.toml +2 -2
  23. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/template/Dockerfile +1 -1
  24. continuo_python_runtime-0.4.0/template/contracts/example_csv.yml +17 -0
  25. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/conftest.py +97 -0
  26. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/contract/test_loader.py +59 -0
  27. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/contract/test_merge.py +54 -1
  28. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/contract/test_model.py +15 -1
  29. continuo_python_runtime-0.4.0/tests/test_contract_loader.py +32 -0
  30. continuo_python_runtime-0.4.0/tests/test_csv_loader.py +116 -0
  31. continuo_python_runtime-0.4.0/tests/test_csv_readers_https.py +107 -0
  32. continuo_python_runtime-0.4.0/tests/test_csv_readers_integration.py +302 -0
  33. continuo_python_runtime-0.4.0/tests/test_csv_source.py +64 -0
  34. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_harness.py +54 -0
  35. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_template.py +9 -5
  36. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_validation_runner.py +206 -4
  37. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/uv.lock +2 -2
  38. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.dockerignore +0 -0
  39. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/CODEOWNERS +0 -0
  40. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  41. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  42. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  43. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  44. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/dependabot.yml +0 -0
  45. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/dco.yml +0 -0
  46. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/images.yml +0 -0
  47. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/publish-pypi.yml +0 -0
  48. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.github/workflows/security.yml +0 -0
  49. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.gitignore +0 -0
  50. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/.gitleaks.toml +0 -0
  51. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/CHANGELOG.md +0 -0
  52. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  53. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/DCO +0 -0
  54. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/Dockerfile.postgres +0 -0
  55. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/Dockerfile.trino +0 -0
  56. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/LICENSE +0 -0
  57. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/NOTICE +0 -0
  58. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/SECURITY.md +0 -0
  59. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/README.md +0 -0
  60. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/continuo_python_runtime_postgres/__init__.py +0 -0
  61. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/continuo_python_runtime_postgres/adapter.py +0 -0
  62. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/tests/__init__.py +0 -0
  63. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/tests/test_adapter_postgres_validation.py +0 -0
  64. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/tests/test_adapter_runtime_postgres.py +0 -0
  65. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/postgres/tests/test_integration_postgres_validation.py +0 -0
  66. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/README.md +0 -0
  67. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/continuo_python_runtime_trino/__init__.py +0 -0
  68. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/continuo_python_runtime_trino/adapter.py +0 -0
  69. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/tests/__init__.py +0 -0
  70. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/tests/test_adapter_runtime_trino.py +0 -0
  71. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/tests/test_adapter_trino_validation.py +0 -0
  72. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/tests/test_integration_runtime_trino.py +0 -0
  73. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/adapters/trino/tests/test_integration_trino_validation.py +0 -0
  74. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/__init__.py +0 -0
  75. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/cli.py +0 -0
  76. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/closure.py +0 -0
  77. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/conform.py +0 -0
  78. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/context.py +0 -0
  79. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/contract/__init__.py +0 -0
  80. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/contract/paths.py +0 -0
  81. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/errors.py +0 -0
  82. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/hashing.py +0 -0
  83. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/lint.py +0 -0
  84. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/types.py +0 -0
  85. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/validation/__init__.py +0 -0
  86. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/continuo_python_runtime/validation/s3.py +0 -0
  87. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/LICENSE +0 -0
  88. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/NOTICE +0 -0
  89. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/README.md +0 -0
  90. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/__init__.py +0 -0
  91. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/config.py +0 -0
  92. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/port.py +0 -0
  93. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/result.py +0 -0
  94. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/sql.py +0 -0
  95. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/continuo_engine_contract/types.py +0 -0
  96. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/__init__.py +0 -0
  97. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/test_config.py +0 -0
  98. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/test_contract_types.py +0 -0
  99. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/test_port.py +0 -0
  100. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/test_result.py +0 -0
  101. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/contract/tests/test_sql.py +0 -0
  102. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/docs/superpowers/plans/2026-07-31-python-runtime.md +0 -0
  103. {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
  104. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/docs/superpowers/specs/2026-07-31-python-runtime-design.md +0 -0
  105. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/scripts/security-scan.sh +0 -0
  106. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/template/.github/workflows/release.yml +0 -0
  107. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/template/README.md +0 -0
  108. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/template/contracts/example.yml +0 -0
  109. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/template/scripts/example.py +0 -0
  110. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/__init__.py +0 -0
  111. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/contract/__init__.py +0 -0
  112. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/smoke/node_smoke/contracts/smoke.yml +0 -0
  113. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/smoke/node_smoke/scripts/smoke.py +0 -0
  114. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/smoke/postgres-stack/docker-compose.yml +0 -0
  115. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/smoke/trino-stack/catalog/iceberg.properties +0 -0
  116. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/smoke/trino-stack/docker-compose.yml +0 -0
  117. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_cli.py +0 -0
  118. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_closure.py +0 -0
  119. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_conform.py +0 -0
  120. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_context.py +0 -0
  121. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_contract_pin_consistency.py +0 -0
  122. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_errors.py +0 -0
  123. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_hashing.py +0 -0
  124. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_image_smoke_validation.py +0 -0
  125. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_lint.py +0 -0
  126. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_no_legacy_names.py +0 -0
  127. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_package.py +0 -0
  128. {continuo_python_runtime-0.3.1 → continuo_python_runtime-0.4.0}/tests/test_types.py +0 -0
  129. {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)
@@ -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 need Docker and are not required for most changes — see
68
- `.github/workflows/ci.yml` for how CI stands them up if you want to run them locally.
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.1
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.1
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.3.1
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.3.1
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.3.1
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.3.1
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
@@ -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.1",
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)]
@@ -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.1",
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", "script")
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
- script = raw["script"]
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 not isinstance(reads, dict) or not reads:
204
- raise ContractError(
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.{name}' must be a non-empty SQL string"
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
- ensure_single_read(sql, dialect)
220
- except (ValueError, TokenError) as exc:
221
- # ensure_single_read's own message is phrased for check_binds
222
- # (its only other caller today), so it's wrapped rather than
223
- # surfaced bare here. TokenError is also caught: an unterminated
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.{name}' must be a single read query ({exc})"
235
- ) from exc
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
- script_path = resolve_script_path(node.script, repo_root, context=node.relation)
132
- script_bytes = script_path.read_bytes()
133
- closure = resolve_closure(script_path, repo_root)
134
- member_bytes = [member.read_bytes() for member in closure]
135
- _lint_node_closure(node, repo_root, script_path, script_bytes, closure, member_bytes)
136
- entry.update(hash_parts(entry, script_bytes, member_bytes))
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: