continuo-python-runtime 0.5.0__tar.gz → 0.6.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 (139) hide show
  1. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/workflows/images.yml +2 -2
  2. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/CHANGELOG.md +14 -0
  3. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/PKG-INFO +4 -4
  4. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/README.md +3 -3
  5. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/postgres/pyproject.toml +1 -1
  6. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/postgres/tests/conftest.py +12 -2
  7. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/postgres/tests/test_integration_runtime_postgres.py +1 -1
  8. continuo_python_runtime-0.6.0/continuo_python_runtime/contract/kinds.py +109 -0
  9. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/contract/loader.py +16 -63
  10. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/contract/merge.py +26 -13
  11. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/contract/model.py +3 -2
  12. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/harness.py +28 -14
  13. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/docs/boundary-contract.md +3 -3
  14. continuo_python_runtime-0.6.0/image-requirements-postgres.txt +2 -0
  15. continuo_python_runtime-0.6.0/image-requirements-trino.txt +2 -0
  16. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/pyproject.toml +1 -1
  17. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/template/Dockerfile +1 -1
  18. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/conftest.py +12 -2
  19. continuo_python_runtime-0.6.0/tests/contract/test_kinds.py +47 -0
  20. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/contract/test_loader.py +10 -10
  21. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/contract/test_merge.py +1 -1
  22. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/contract/test_model.py +4 -4
  23. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/smoke/trino-stack/docker-compose.yml +14 -3
  24. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_cli.py +1 -1
  25. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_contract_loader.py +8 -3
  26. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_harness.py +2 -2
  27. continuo_python_runtime-0.6.0/tests/test_kind_registries.py +22 -0
  28. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_no_legacy_names.py +10 -2
  29. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/uv.lock +2 -2
  30. continuo_python_runtime-0.5.0/image-requirements-postgres.txt +0 -2
  31. continuo_python_runtime-0.5.0/image-requirements-trino.txt +0 -2
  32. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.dockerignore +0 -0
  33. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/CODEOWNERS +0 -0
  34. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  35. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  36. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  37. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  38. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/dependabot.yml +0 -0
  39. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/workflows/ci.yml +0 -0
  40. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/workflows/dco.yml +0 -0
  41. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/workflows/publish-pypi.yml +0 -0
  42. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/workflows/release.yml +0 -0
  43. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.github/workflows/security.yml +0 -0
  44. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.gitignore +0 -0
  45. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/.gitleaks.toml +0 -0
  46. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/CLAUDE.md +0 -0
  47. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/CODE_OF_CONDUCT.md +0 -0
  48. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/CONTRIBUTING.md +0 -0
  49. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/DCO +0 -0
  50. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/Dockerfile.postgres +0 -0
  51. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/Dockerfile.trino +0 -0
  52. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/LICENSE +0 -0
  53. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/NOTICE +0 -0
  54. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/SECURITY.md +0 -0
  55. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/postgres/README.md +0 -0
  56. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/postgres/continuo_postgres_adapter/__init__.py +0 -0
  57. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/postgres/continuo_postgres_adapter/adapter.py +0 -0
  58. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/postgres/tests/__init__.py +0 -0
  59. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/postgres/tests/test_adapter_postgres_validation.py +0 -0
  60. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/postgres/tests/test_adapter_runtime_postgres.py +0 -0
  61. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/postgres/tests/test_integration_postgres_validation.py +0 -0
  62. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/trino/README.md +0 -0
  63. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/trino/continuo_trino_adapter/__init__.py +0 -0
  64. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/trino/continuo_trino_adapter/adapter.py +0 -0
  65. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/trino/pyproject.toml +0 -0
  66. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/trino/tests/__init__.py +0 -0
  67. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/trino/tests/test_adapter_runtime_trino.py +0 -0
  68. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/trino/tests/test_adapter_trino_validation.py +0 -0
  69. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/trino/tests/test_integration_runtime_trino.py +0 -0
  70. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/adapters/trino/tests/test_integration_trino_validation.py +0 -0
  71. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/__init__.py +0 -0
  72. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/cli.py +0 -0
  73. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/closure.py +0 -0
  74. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/conform.py +0 -0
  75. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/context.py +0 -0
  76. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/contract/__init__.py +0 -0
  77. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/contract/paths.py +0 -0
  78. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/csv_loader.py +0 -0
  79. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/csv_readers/__init__.py +0 -0
  80. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/csv_readers/https.py +0 -0
  81. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/csv_readers/s3.py +0 -0
  82. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/csv_source.py +0 -0
  83. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/errors.py +0 -0
  84. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/hashing.py +0 -0
  85. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/lint.py +0 -0
  86. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/types.py +0 -0
  87. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/validation/__init__.py +0 -0
  88. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/validation/runner.py +0 -0
  89. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/continuo_python_runtime/validation/s3.py +0 -0
  90. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/LICENSE +0 -0
  91. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/NOTICE +0 -0
  92. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/README.md +0 -0
  93. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/continuo_engine_contract/__init__.py +0 -0
  94. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/continuo_engine_contract/config.py +0 -0
  95. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/continuo_engine_contract/port.py +0 -0
  96. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/continuo_engine_contract/result.py +0 -0
  97. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/continuo_engine_contract/sql.py +0 -0
  98. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/continuo_engine_contract/types.py +0 -0
  99. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/pyproject.toml +0 -0
  100. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/tests/__init__.py +0 -0
  101. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/tests/test_config.py +0 -0
  102. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/tests/test_contract_types.py +0 -0
  103. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/tests/test_port.py +0 -0
  104. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/tests/test_result.py +0 -0
  105. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/contract/tests/test_sql.py +0 -0
  106. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/scripts/check_version_bumps.py +0 -0
  107. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/scripts/security-scan.sh +0 -0
  108. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/template/.github/workflows/release.yml +0 -0
  109. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/template/Dockerfile.pip +0 -0
  110. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/template/README.md +0 -0
  111. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/template/contracts/example.yml +0 -0
  112. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/template/contracts/example_csv.yml +0 -0
  113. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/template/requirements.lock +0 -0
  114. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/template/scripts/example.py +0 -0
  115. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/__init__.py +0 -0
  116. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/contract/__init__.py +0 -0
  117. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/smoke/node_smoke/contracts/smoke.yml +0 -0
  118. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/smoke/node_smoke/scripts/smoke.py +0 -0
  119. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/smoke/postgres-stack/docker-compose.yml +0 -0
  120. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/smoke/trino-stack/catalog/iceberg.properties +0 -0
  121. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_adapter_naming.py +0 -0
  122. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_closure.py +0 -0
  123. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_conform.py +0 -0
  124. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_context.py +0 -0
  125. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_contract_pin_consistency.py +0 -0
  126. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_csv_loader.py +0 -0
  127. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_csv_readers_https.py +0 -0
  128. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_csv_readers_integration.py +0 -0
  129. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_csv_source.py +0 -0
  130. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_errors.py +0 -0
  131. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_hashing.py +0 -0
  132. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_image_requirements_sync.py +0 -0
  133. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_image_smoke_validation.py +0 -0
  134. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_lint.py +0 -0
  135. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_package.py +0 -0
  136. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_template.py +0 -0
  137. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_types.py +0 -0
  138. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_validation_runner.py +0 -0
  139. {continuo_python_runtime-0.5.0 → continuo_python_runtime-0.6.0}/tests/test_validation_s3.py +0 -0
@@ -83,7 +83,7 @@ jobs:
83
83
  run: |
84
84
  docker run --rm --network host \
85
85
  -v "$PWD/tests/smoke/node_smoke:/app" \
86
- -e NODE_ID=python-model.smoke.analytics.smoke \
86
+ -e NODE_ID=python-node.smoke.analytics.smoke \
87
87
  -e TABLE_NAME=smoke \
88
88
  -e TARGET_SCHEMA=analytics \
89
89
  -e POSTGRES_HOST=localhost \
@@ -134,7 +134,7 @@ jobs:
134
134
  run: |
135
135
  docker run --rm \
136
136
  -v "$PWD/tests/smoke/node_smoke:/app" \
137
- -e NODE_ID=python-model.smoke.analytics.smoke \
137
+ -e NODE_ID=python-node.smoke.analytics.smoke \
138
138
  -e TABLE_NAME=smoke \
139
139
  -e TARGET_SCHEMA=analytics \
140
140
  -e TRINO_HOST=host.docker.internal \
@@ -5,6 +5,20 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.6.0] - 2026-09-28
9
+
10
+ ### Breaking
11
+
12
+ - The script node kind is named `python-node` (the default when `kind` is
13
+ omitted). A contract declaring `kind: python-model` is rejected at load.
14
+ Re-running `continuo-runtime merge` changes every python node's
15
+ `config_hash`, so each node is re-validated once on its next release.
16
+
17
+ ### Changed
18
+
19
+ - Contract rules, hash inputs and run producers are looked up per kind from
20
+ registries pinned to `KINDS`.
21
+
8
22
  ## [0.5.0] - 2026-09-09
9
23
 
10
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: continuo-python-runtime
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -163,7 +163,7 @@ Every rule below (`extra_columns`, `output_columns`, "Conform rules") applies
163
163
  to both kinds identically — `kind` only changes how the pre-conform table is
164
164
  produced, never how it is checked or written.
165
165
 
166
- - **`python-model`** (the default; the field may be omitted) — a script node.
166
+ - **`python-node`** (the default; the field may be omitted) — a script node.
167
167
  It requires `script:` and a `reads:` map of one or more named SQL queries,
168
168
  as described in "The script API" below.
169
169
  - **`python-csv`** — a contract-only node: it has no script and its `reads:`
@@ -275,9 +275,9 @@ A domain repo picks its warehouse engine by which base image it builds
275
275
  `FROM`:
276
276
 
277
277
  ```dockerfile
278
- FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.5.0
278
+ FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.6.0
279
279
  # or
280
- FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.5.0
280
+ FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.6.0
281
281
  ```
282
282
 
283
283
  The engine is part of the image **name**; the tag is the bare version, so
@@ -143,7 +143,7 @@ Every rule below (`extra_columns`, `output_columns`, "Conform rules") applies
143
143
  to both kinds identically — `kind` only changes how the pre-conform table is
144
144
  produced, never how it is checked or written.
145
145
 
146
- - **`python-model`** (the default; the field may be omitted) — a script node.
146
+ - **`python-node`** (the default; the field may be omitted) — a script node.
147
147
  It requires `script:` and a `reads:` map of one or more named SQL queries,
148
148
  as described in "The script API" below.
149
149
  - **`python-csv`** — a contract-only node: it has no script and its `reads:`
@@ -255,9 +255,9 @@ A domain repo picks its warehouse engine by which base image it builds
255
255
  `FROM`:
256
256
 
257
257
  ```dockerfile
258
- FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.5.0
258
+ FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.6.0
259
259
  # or
260
- FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.5.0
260
+ FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.6.0
261
261
  ```
262
262
 
263
263
  The engine is part of the image **name**; the tag is the bare version, so
@@ -1,5 +1,5 @@
1
1
  [project]
2
- version = "0.2.1"
2
+ version = "0.2.2"
3
3
  name = "continuo-postgres-adapter"
4
4
  description = "Postgres warehouse adapter for Continuo: validation and python-node runtime."
5
5
  authors = [{ name = "Simone Carolini" }]
@@ -43,8 +43,18 @@ def minio_container():
43
43
  "-p", "0:9000",
44
44
  "-e", "MINIO_ROOT_USER=minioadmin",
45
45
  "-e", "MINIO_ROOT_PASSWORD=minioadmin",
46
- "minio/minio:latest",
47
- "server", "/data", "--address", ":9000",
46
+ # ghcr mirror of Bitnami's minio image (Docker Hub's minio/minio
47
+ # is no longer anonymously pullable). Its ENTRYPOINT is
48
+ # Bitnami's own setup script, not the `minio` binary, so it
49
+ # must be overridden for these args to reach `minio` directly;
50
+ # its data dir also has to be Bitnami's own
51
+ # /bitnami/minio/data (world-root-group-writable for the
52
+ # image's non-root user) since plain /data doesn't exist in
53
+ # this image and the binary can't create it at the
54
+ # filesystem root.
55
+ "--entrypoint", "minio",
56
+ "ghcr.io/carolsimone/continuo-minio:2025.7.23",
57
+ "server", "/bitnami/minio/data", "--address", ":9000",
48
58
  ],
49
59
  check=True, capture_output=True, text=True, timeout=60,
50
60
  )
@@ -354,7 +354,7 @@ def test_run_node_csv_kind_loads_minio_csv_into_postgres(clean_schema, csv_minio
354
354
  doubles here -- the S3CsvSourceReader from csv_readers.reader_for talks
355
355
  to the real minio container, and PostgresAdapter writes to the real
356
356
  postgres stack), then conform()/ensure_table()/load() proceed exactly as
357
- for a python-model node.
357
+ for a python-node node.
358
358
  """
359
359
  monkeypatch.setenv("S3_ENDPOINT_URL", csv_minio)
360
360
  repo = _csv_contract_dir(tmp_path, clean_schema)
@@ -0,0 +1,109 @@
1
+ """Per-kind contract rules: which fields a node of each kind must carry.
2
+
3
+ Each kind is one ``KindRules`` entry composed of a script policy and a reads
4
+ rule. Adding a kind means adding one entry to ``RULES``; nothing that parses a
5
+ contract branches on the kind's name.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from continuo_engine_contract.sql import ensure_single_read # type: ignore[import-untyped]
15
+ from sqlglot.errors import TokenError
16
+
17
+ from continuo_python_runtime.contract.model import KINDS
18
+ from continuo_python_runtime.csv_source import parse_csv_uri
19
+ from continuo_python_runtime.errors import ContractError
20
+
21
+
22
+ class ReadsRule(ABC):
23
+ """Validates a node's ``reads`` mapping and returns it unchanged."""
24
+
25
+ @abstractmethod
26
+ def validate(
27
+ self, reads: Any, label: str, *, dialect: str | None, check_reads: bool
28
+ ) -> dict[str, str]: ...
29
+
30
+
31
+ class SqlReads(ReadsRule):
32
+ """A non-empty mapping of read name -> single SELECT."""
33
+
34
+ def validate(
35
+ self, reads: Any, label: str, *, dialect: str | None, check_reads: bool
36
+ ) -> dict[str, str]:
37
+ if not isinstance(reads, dict) or not reads:
38
+ raise ContractError(
39
+ f"{label}: 'reads' must be a non-empty mapping of name -> SQL"
40
+ )
41
+ for name, sql in reads.items():
42
+ if not isinstance(name, str) or not name.strip():
43
+ raise ContractError(
44
+ f"{label}: 'reads' name {name!r} must be a non-empty string"
45
+ )
46
+ if not isinstance(sql, str) or not sql.strip():
47
+ raise ContractError(
48
+ f"{label}: 'reads.{name}' must be a non-empty SQL string"
49
+ )
50
+ if not check_reads:
51
+ continue
52
+ try:
53
+ ensure_single_read(sql, dialect)
54
+ except (ValueError, TokenError) as exc:
55
+ # ensure_single_read's own message is phrased for check_binds
56
+ # (its only other caller today), so it's wrapped rather than
57
+ # surfaced bare here. TokenError is also caught: an unterminated
58
+ # string literal or comment fails sqlglot's tokenizer with a
59
+ # TokenError, a SqlglotError sibling of ParseError and not a
60
+ # subclass of ValueError -- despite ensure_single_read's
61
+ # docstring promising every rejection is a ValueError. Only
62
+ # TokenError, not the broader SqlglotError, is caught here: by
63
+ # the time control reaches this point `dialect` has already
64
+ # been validated once in load_contract_dir, so any other
65
+ # SqlglotError a future sqlglot version might raise from this
66
+ # call should surface as itself, not get relabeled as a
67
+ # rejected read.
68
+ raise ContractError(
69
+ f"{label}: 'reads.{name}' must be a single read query ({exc})"
70
+ ) from exc
71
+ return dict(reads)
72
+
73
+
74
+ class CsvRead(ReadsRule):
75
+ """Exactly ``{csv: <uri>}``, the uri in the grammar ``parse_csv_uri`` accepts."""
76
+
77
+ def validate(
78
+ self, reads: Any, label: str, *, dialect: str | None, check_reads: bool
79
+ ) -> dict[str, str]:
80
+ if not isinstance(reads, dict) or set(reads) != {"csv"}:
81
+ raise ContractError(
82
+ f"{label}: a python-csv node's 'reads' must be exactly {{csv: <uri>}}"
83
+ )
84
+ try:
85
+ parse_csv_uri(reads["csv"])
86
+ except (ValueError, TypeError) as exc:
87
+ raise ContractError(f"{label}: invalid csv uri: {exc}") from exc
88
+ return dict(reads)
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class KindRules:
93
+ """What a node of one kind must declare."""
94
+
95
+ script_required: bool
96
+ reads: ReadsRule
97
+
98
+
99
+ RULES: dict[str, KindRules] = {
100
+ "python-node": KindRules(script_required=True, reads=SqlReads()),
101
+ "python-csv": KindRules(script_required=False, reads=CsvRead()),
102
+ }
103
+
104
+
105
+ def rules_for(kind: object, label: str) -> KindRules:
106
+ """Return the rules for ``kind``; raise ``ContractError`` for any other value."""
107
+ if isinstance(kind, str) and kind in RULES:
108
+ return RULES[kind]
109
+ raise ContractError(f"{label}: 'kind' must be one of {sorted(KINDS)}, got {kind!r}")
@@ -11,18 +11,16 @@ from pathlib import Path
11
11
  from typing import Any
12
12
 
13
13
  import yaml
14
- from continuo_engine_contract.sql import ensure_single_read # type: ignore[import-untyped]
15
14
  from sqlglot import Dialect
16
- from sqlglot.errors import TokenError
17
15
 
16
+ from continuo_python_runtime.contract.kinds import rules_for
18
17
  from continuo_python_runtime.contract.model import (
19
18
  CRITICALITIES,
19
+ DEFAULT_KIND,
20
20
  EXTRA_COLUMNS_POLICIES,
21
- KINDS,
22
21
  Column,
23
22
  Node,
24
23
  )
25
- from continuo_python_runtime.csv_source import parse_csv_uri
26
24
  from continuo_python_runtime.errors import ContractError
27
25
  from continuo_python_runtime.types import parse_sql_type
28
26
 
@@ -173,11 +171,8 @@ def parse_node(
173
171
  if unknown:
174
172
  raise ContractError(f"{label}: unknown key(s) {sorted(unknown)}")
175
173
 
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
- )
174
+ kind = raw.get("kind", DEFAULT_KIND)
175
+ rules = rules_for(kind, label)
181
176
 
182
177
  for field in _REQUIRED_STRING_FIELDS:
183
178
  value = raw.get(field)
@@ -191,20 +186,20 @@ def parse_node(
191
186
  owner = raw["owner"]
192
187
  schedule = raw["schedule"]
193
188
 
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:
189
+ if rules.script_required:
202
190
  raw_script = raw.get("script")
203
191
  if not isinstance(raw_script, str) or not raw_script.strip():
204
192
  raise ContractError(
205
193
  f"{label}: required field 'script' must be a non-empty string"
206
194
  )
207
195
  script = raw_script
196
+ else:
197
+ if "script" in raw:
198
+ raise ContractError(
199
+ f"{label}: 'script' is forbidden for kind {kind} "
200
+ "(it is a contract-only kind)"
201
+ )
202
+ script = ""
208
203
 
209
204
  criticality = raw.get("criticality")
210
205
  if not isinstance(criticality, str) or criticality not in CRITICALITIES:
@@ -222,51 +217,9 @@ def parse_node(
222
217
  f"got {extra_columns!r}"
223
218
  )
224
219
 
225
- reads = raw.get("reads")
226
- if kind == "python-csv":
227
- if not isinstance(reads, dict) or set(reads) != {"csv"}:
228
- raise ContractError(
229
- f"{label}: a python-csv node's 'reads' must be exactly {{csv: <uri>}}"
230
- )
231
- try:
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:
237
- raise ContractError(
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
220
+ reads = rules.reads.validate(
221
+ raw.get("reads"), label, dialect=dialect, check_reads=check_reads
222
+ )
270
223
 
271
224
  raw_columns = raw.get("output_columns")
272
225
  if not isinstance(raw_columns, list) or not raw_columns:
@@ -325,7 +278,7 @@ def parse_node(
325
278
  schedule=schedule,
326
279
  criticality=criticality,
327
280
  script=script,
328
- reads=dict(reads),
281
+ reads=reads,
329
282
  output_columns=tuple(columns),
330
283
  description=description,
331
284
  extra_columns=extra_columns,
@@ -1,5 +1,6 @@
1
1
  """Contract v1 merger: wire contract builder."""
2
2
 
3
+ from collections.abc import Callable
3
4
  from pathlib import Path
4
5
 
5
6
  import yaml
@@ -110,6 +111,29 @@ def _lint_node_closure(
110
111
  )
111
112
 
112
113
 
114
+ def _script_hash_inputs(node: Node, repo_root: Path) -> tuple[bytes, list[bytes]]:
115
+ """A script node's source is its script plus its in-repo import closure,
116
+ every file of which is held to the closure lint."""
117
+ script_path = resolve_script_path(node.script, repo_root, context=node.relation)
118
+ script_bytes = script_path.read_bytes()
119
+ closure = resolve_closure(script_path, repo_root)
120
+ member_bytes = [member.read_bytes() for member in closure]
121
+ _lint_node_closure(node, repo_root, script_path, script_bytes, closure, member_bytes)
122
+ return script_bytes, member_bytes
123
+
124
+
125
+ def _csv_hash_inputs(node: Node, repo_root: Path) -> tuple[bytes, list[bytes]]:
126
+ """A csv node has no script and no import closure: its source is the uri.
127
+ New file content at the same uri is new data, not a new node version."""
128
+ return node.reads["csv"].encode(), []
129
+
130
+
131
+ _HASH_INPUTS: dict[str, Callable[[Node, Path], tuple[bytes, list[bytes]]]] = {
132
+ "python-node": _script_hash_inputs,
133
+ "python-csv": _csv_hash_inputs,
134
+ }
135
+
136
+
113
137
  def build_wire_contract(
114
138
  contract_dir: Path, repo_root: Path, service: str, *, dialect: str | None = None
115
139
  ) -> dict:
@@ -129,19 +153,8 @@ def build_wire_contract(
129
153
  for node in nodes:
130
154
  entry = node_entry(node)
131
155
 
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))
156
+ primary_bytes, member_bytes = _HASH_INPUTS[node.kind](node, repo_root)
157
+ entry.update(hash_parts(entry, primary_bytes, member_bytes))
145
158
 
146
159
  wire_nodes.append(entry)
147
160
 
@@ -6,7 +6,8 @@ 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
+ KINDS = frozenset({"python-node", "python-csv"})
10
+ DEFAULT_KIND = "python-node"
10
11
  CONTRACT_VERSION = 1
11
12
 
12
13
 
@@ -35,7 +36,7 @@ class Node:
35
36
  extra_columns: str = "raise"
36
37
  config: dict[str, Any] = field(default_factory=dict)
37
38
  content_hash: str | None = None
38
- kind: str = "python-model"
39
+ kind: str = DEFAULT_KIND
39
40
 
40
41
  @property
41
42
  def relation(self) -> str:
@@ -1,6 +1,6 @@
1
1
  """Container entrypoint: dispatches a single node's script and writes its output.
2
2
 
3
- ``run_node`` is the sole write sink for a python-model node. It resolves the
3
+ ``run_node`` is the sole write sink for a python-node node. It resolves the
4
4
  node from the contract, loads and executes its script inside a
5
5
  :class:`~continuo_python_runtime.context.RunContext`, conforms the result to
6
6
  the declared schema, and writes it through the runtime adapter. Exactly one
@@ -16,7 +16,7 @@ import logging
16
16
  import os
17
17
  import sys
18
18
  import uuid
19
- from collections.abc import Mapping
19
+ from collections.abc import Callable, Mapping
20
20
  from pathlib import Path
21
21
  from types import ModuleType
22
22
  from typing import Any
@@ -208,15 +208,36 @@ def _validate_config_early(adapter: Any, node: Node) -> None:
208
208
  ) from exc
209
209
 
210
210
 
211
+ def _produce_from_script(
212
+ node: Node, adapter: Any, app_root: Path, reader: CsvSourceReader | None
213
+ ) -> Any:
214
+ """Import the node's script and return its ``run(ctx)`` result as Arrow."""
215
+ with contextlib.redirect_stdout(sys.stderr):
216
+ module = load_script(node, app_root)
217
+ ctx = RunContext(node, adapter)
218
+ return to_arrow(_execute_script(module, ctx))
219
+
220
+
221
+ def _produce_from_csv(
222
+ node: Node, adapter: Any, app_root: Path, reader: CsvSourceReader | None
223
+ ) -> Any:
224
+ """Load the node's declared csv source."""
225
+ return produce_csv(node, reader=reader)
226
+
227
+
228
+ _PRODUCERS: dict[str, Callable[[Node, Any, Path, CsvSourceReader | None], Any]] = {
229
+ "python-node": _produce_from_script,
230
+ "python-csv": _produce_from_csv,
231
+ }
232
+
233
+
211
234
  def run_node(
212
235
  env: Mapping[str, str], adapter: Any = None, reader: CsvSourceReader | None = None
213
236
  ) -> int:
214
237
  """Run a single node end-to-end and print exactly one sentinel result block.
215
238
 
216
- ``reader`` mirrors the ``adapter`` injection seam: when given, it is
217
- threaded into :func:`produce_csv` for a python-csv node instead of
218
- letting that function pick a reader via ``reader_for``. Ignored for a
219
- python-model node.
239
+ ``reader`` is threaded into the python-csv producer instead of letting
240
+ it pick a reader via ``reader_for``; the script producer ignores it.
220
241
 
221
242
  Returns 0 on success, 1 on any :class:`HarnessError`.
222
243
  """
@@ -255,14 +276,7 @@ def run_node(
255
276
 
256
277
  _validate_config_early(active_adapter, node)
257
278
 
258
- if node.kind == "python-csv":
259
- table = produce_csv(node, reader=reader)
260
- else:
261
- with contextlib.redirect_stdout(sys.stderr):
262
- module = load_script(node, app_root)
263
- ctx = RunContext(node, active_adapter)
264
- raw_result = _execute_script(module, ctx)
265
- table = to_arrow(raw_result)
279
+ table = _PRODUCERS[node.kind](node, active_adapter, app_root, reader)
266
280
 
267
281
  conformed = conform(table, node.output_columns, node.extra_columns)
268
282
 
@@ -29,7 +29,7 @@ s3://<bucket>/<service>/<release_id>/contract.yaml
29
29
  the author's contract file didn't declare one. "Optional" describes the
30
30
  contract *file* an author writes, not the merged wire artifact, where
31
31
  every field above is always present.
32
- - **`kind`** is additive: it defaults to `python-model` (a script-backed
32
+ - **`kind`** is additive: it defaults to `python-node` (a script-backed
33
33
  node, everything described above and in §13.4). `python-csv` is the one
34
34
  other value — a **contract-only** node with no script: it declares no
35
35
  Python to run, and its `reads` must be exactly `{csv: <uri>}` (one key,
@@ -51,7 +51,7 @@ s3://<bucket>/<service>/<release_id>/contract.yaml
51
51
  instead of bind-checking a SELECT, so a `python-csv` node still fails the
52
52
  release gate on a missing declared column exactly as a script node fails
53
53
  it on a missing bind. §13.4 below still applies unchanged to
54
- `kind: python-model` nodes.
54
+ `kind: python-node` nodes.
55
55
  - **Reads must be single-statement SELECTs with every table reference
56
56
  schema-qualified** (`analytics.table_a`, never `table_a`) — the resolver
57
57
  raises `UnqualifiedTableReference` and rejects the whole release otherwise
@@ -361,7 +361,7 @@ inject the names below instead:
361
361
 
362
362
  | Variable | Required? | Meaning |
363
363
  | --- | --- | --- |
364
- | `NODE_ID` | **required** | Any identifier whose trailing two dot-separated segments MUST be `<schema>.<table>` of the declared node (e.g. `python-model.<service>.<schema>.<table>`); the harness splits on `.` and matches the last two segments against the merged contract's nodes. |
364
+ | `NODE_ID` | **required** | Any identifier whose trailing two dot-separated segments MUST be `<schema>.<table>` of the declared node (e.g. `python-node.<service>.<schema>.<table>`); the harness splits on `.` and matches the last two segments against the merged contract's nodes. |
365
365
  | `TABLE_NAME` | **required** | The target table name; must match the `table` of the node selected via `NODE_ID`. |
366
366
  | `TARGET_SCHEMA` | **required** | The target schema name; must match the `schema` of the node selected via `NODE_ID`. There is no fallback — `SCHEMA` and `DBT_TARGET_SCHEMA` are **not** recognized. |
367
367
  | `CONTRACT_DIR` | optional | Path to the directory of merged contract YAML files baked into the image. Defaults to `/app/contracts`. |
@@ -0,0 +1,2 @@
1
+ continuo-python-runtime==0.6.0
2
+ continuo-postgres-adapter==0.2.2
@@ -0,0 +1,2 @@
1
+ continuo-python-runtime==0.6.0
2
+ continuo-trino-adapter==0.2.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "continuo-python-runtime"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Runtime harness, contract tooling, and CI lint for Continuo python nodes."
5
5
  authors = [{ name = "Simone Carolini" }]
6
6
  maintainers = [{ name = "Simone Carolini" }]
@@ -1,5 +1,5 @@
1
1
  # Tags are v<version>; one image per engine (continuo-python-runtime-postgres / -trino).
2
- FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.5.0
2
+ FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.6.0
3
3
  COPY contracts/ /app/contracts/
4
4
  COPY scripts/ /app/scripts/
5
5
  # COPY every OTHER directory your scripts import from, too. CI folds a script's
@@ -169,8 +169,18 @@ def minio_container():
169
169
  "-p", "0:9000",
170
170
  "-e", "MINIO_ROOT_USER=minioadmin",
171
171
  "-e", "MINIO_ROOT_PASSWORD=minioadmin",
172
- "minio/minio:latest",
173
- "server", "/data", "--address", ":9000",
172
+ # ghcr mirror of Bitnami's minio image (Docker Hub's minio/minio
173
+ # is no longer anonymously pullable). Its ENTRYPOINT is
174
+ # Bitnami's own setup script, not the `minio` binary, so it
175
+ # must be overridden for these args to reach `minio` directly;
176
+ # its data dir also has to be Bitnami's own
177
+ # /bitnami/minio/data (world-root-group-writable for the
178
+ # image's non-root user) since plain /data doesn't exist in
179
+ # this image and the binary can't create it at the
180
+ # filesystem root.
181
+ "--entrypoint", "minio",
182
+ "ghcr.io/carolsimone/continuo-minio:2025.7.23",
183
+ "server", "/bitnami/minio/data", "--address", ":9000",
174
184
  ],
175
185
  check=True, capture_output=True, text=True, timeout=60,
176
186
  )
@@ -0,0 +1,47 @@
1
+ import pytest
2
+
3
+ from continuo_python_runtime.contract.kinds import RULES, CsvRead, SqlReads, rules_for
4
+ from continuo_python_runtime.contract.model import KINDS
5
+ from continuo_python_runtime.errors import ContractError
6
+
7
+
8
+ def test_every_kind_has_rules():
9
+ assert set(RULES) == KINDS
10
+
11
+
12
+ def test_python_node_requires_a_script_and_sql_reads():
13
+ rules = RULES["python-node"]
14
+ assert rules.script_required is True
15
+ assert isinstance(rules.reads, SqlReads)
16
+
17
+
18
+ def test_python_csv_forbids_a_script_and_reads_one_csv_uri():
19
+ rules = RULES["python-csv"]
20
+ assert rules.script_required is False
21
+ assert isinstance(rules.reads, CsvRead)
22
+
23
+
24
+ @pytest.mark.parametrize("kind", ["PYTHON-NODE", " python-node", "python-model", "", None, 3])
25
+ def test_kind_is_case_and_whitespace_sensitive(kind):
26
+ with pytest.raises(ContractError, match="'kind' must be one of"):
27
+ rules_for(kind, "t.yml (a.b)")
28
+
29
+
30
+ def test_sql_reads_rejects_an_empty_mapping():
31
+ with pytest.raises(ContractError, match="'reads' must be a non-empty mapping of name -> SQL"):
32
+ SqlReads().validate({}, "L", dialect=None, check_reads=True)
33
+
34
+
35
+ def test_sql_reads_skips_the_shape_gate_when_asked():
36
+ reads = {"a": "this is not sql at all"}
37
+ assert SqlReads().validate(reads, "L", dialect=None, check_reads=False) == reads
38
+
39
+
40
+ def test_csv_read_requires_exactly_a_csv_key():
41
+ with pytest.raises(ContractError, match=r"a python-csv node's 'reads' must be exactly \{csv: <uri>\}"):
42
+ CsvRead().validate({"csv": "s3://b/k", "x": "y"}, "L", dialect=None, check_reads=True)
43
+
44
+
45
+ def test_csv_read_validates_the_uri():
46
+ with pytest.raises(ContractError, match="invalid csv uri"):
47
+ CsvRead().validate({"csv": "http://insecure/x.csv"}, "L", dialect=None, check_reads=True)