Skip to content

feat: python-csv node kind (contract-only CSV loader + validation header check) - #42

Merged
carolsimone merged 14 commits into
mainfrom
feat/python-csv-nodes
Aug 21, 2026
Merged

feat: python-csv node kind (contract-only CSV loader + validation header check)#42
carolsimone merged 14 commits into
mainfrom
feat/python-csv-nodes

Conversation

@carolsimone

Copy link
Copy Markdown
Owner

Adds a third node kind, python-csv: a contract-only node (no user script) that loads a CSV from s3:// or https:// into its declared warehouse table, validated at the release gate by an empty-table build plus a CSV header check.

What's in it

  • csv_source.py — URI grammar (s3:///https:// only), presence-only header rule, CsvSourceReader port (dependency-free).
  • csv_readers/ — S3 (boto3, reuses make_s3_client) and HTTPS (urllib, ranged) adapters + reader_for composition edge.
  • Contract kind field with per-kind rules: python-csv forbids script, requires reads exactly {csv: <uri>}; source_hash = sha256(uri), shared_code_hash = "".
  • Merge/wire: kind on every wire entry, uri-based source_hash, no closure/lint for csv.
  • Harness: produce_csv + run_node dispatch on node.kind; conform/load path shared.
  • Validation runner: optional csv_source in build_from_columns — ranged header fetch + presence check, csv_header_extra_columns warning on extras.
  • Template example_csv.yml, README node-kinds docs, boundary-contract.md kind paragraph.

Testing

Real backends, no stubs: minio (S3), a live stdlib HTTP server, real postgres. Full suite 459 passed / 0 skipped (3 image-marked deselected); ruff + mypy clean. Integration tests run in CI (-m integration after the postgres stack is up).

Wire-contract note

kind is additive with default python-model; contract_version stays 1. Because kind enters config_hash, existing python-model nodes re-version once on the v0.4.0 deploy — a deliberate one-time churn; manifest-controller fold-parity is preserved.

Deploy order (do NOT merge out of sequence)

The continuo control-plane PR pins runtime image v0.4.0. Merge this, tag/publish v0.4.0 images, THEN merge the continuo PR. continuo-engine-contract stays pinned ==0.7.0.

Known follow-ups (non-blocking, filed for later)

  • HTTPS: a Range-honouring server + object sized an exact multiple of 64KB with no newline → unhandled ClientError on the boundary probe (obscure, fails loud).
  • Spec Error-classification table wording (run-time extras obey extra_columns, default raise) — fix in the continuo spec/arch docs.

🤖 Generated with Claude Code

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
…st minio and a live HTTP server

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
… extend/overflow tests

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
…rced

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
…sh, no closure

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
…in build_from_columns

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
…un warning; docs(boundary): kind field

Final fix wave from the Phase-A python-csv whole-branch review:

- FIX 1: HttpsCsvSourceReader.fetch_header_line no longer loops forever
  re-appending an identical full body when a server ignores Range and
  answers 200. It now checks the response status: a 200 (not 206) is
  terminal on the first pass regardless of size or newline presence,
  fixing a false MAX_HEADER_BYTES overflow for a no-newline body sized
  between HEADER_PROBE_BYTES and MAX_HEADER_BYTES.
- FIX 6: validation/runner.py raises a clear ValueError naming the
  csv_source when fetch_header_line returns an empty header line,
  instead of an opaque StopIteration from csv.reader. Discovered while
  writing the real-minio test that a 0-byte S3 object's first Range
  probe raises botocore InvalidRange rather than returning an empty
  body, so S3CsvSourceReader.fetch_header_line now also treats an
  InvalidRange error on the first probe as an empty object.
- FIX 7: csv_loader.produce_csv now logs the same structured
  csv_header_extra_columns warning the validation runner emits, giving
  extra_columns: drop spec parity between the validation and run paths.
- FIX 2: collapse the "reads must be exactly {csv: <uri>}" error message
  into one f-string; message text unchanged.
- FIX 3: add a test covering the existing exit-2 guard for a non-string
  csv_source in the candidate spec.
- docs(boundary-contract): document the additive `kind` wire field
  (default python-model; python-csv is a contract-only node whose reads
  is exactly {csv: <uri>} and whose source_hash is sha256 of that uri).

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>

@carolsimone carolsimone left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSV producer can corrupt or reject valid values through premature type inference, and the HTTPS reader introduces security and resource-exhaustion paths. Several validation edge cases also bypass the intended error taxonomy or release checks.

Comment thread continuo_python_runtime/csv_loader.py Outdated
try:
with tempfile.TemporaryDirectory() as tmp:
dest = active_reader.fetch(uri, Path(tmp) / "source.csv")
table = pyarrow.csv.read_csv(dest)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve lexical CSV values before conformance - When a declared TEXT/VARCHAR column contains numeric-looking values such as 00123, read_csv infers int64 first and conform() loads 123; valid DECIMAL values are similarly inferred as floats and then rejected as lossy casts. Parse declared columns as strings or with an explicit conversion strategy so conformance receives the original values.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: pyarrow's default inference read 00123 as int64 and NUMERIC values as float64 (rejected by conform()'s lossy-cast guard). Fixed in be30a0e — csv_loader.py now passes output_columns as read_csv's ConvertOptions.column_types, so every declared column is parsed directly as its target Arrow type (full mapping via the existing types.arrow_type/parse_sql_type, not just VARCHAR/TEXT — pyarrow's CSV reader handles decimal128/date32/timestamp fine as explicit convert targets). New tests cover the 00123-VARCHAR and NUMERIC(10,2) cases end to end.

end = start + HEADER_PROBE_BYTES - 1
req = urllib.request.Request(
uri.raw, headers={"Range": f"bytes={start}-{end}"})
with urllib.request.urlopen(req) as resp: # noqa: S310 — scheme gated by parse_csv_uri

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Revalidate HTTPS schemes after redirects - When an accepted HTTPS endpoint redirects to HTTP or FTP, urllib follows it automatically, so both the header probe and full fetch bypass the parse_csv_uri scheme restriction. This permits plaintext downgrade and access to HTTP-only internal targets; use a redirect handler that only permits HTTPS destinations.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: urllib followed a redirect regardless of scheme, so an https source could downgrade to http. Fixed in d1bf4a0 — installed a _HttpsOnlyRedirectHandler that raises unless the redirect target itself starts with https://, used by both urlopen calls via a shared opener. Tested end to end against a REACHABLE downgrade target (not just an unreachable host) so the test actually proves the pre-fix code followed the redirect, not merely that some exception occurred.

req = urllib.request.Request(
uri.raw, headers={"Range": f"bytes={start}-{end}"})
with urllib.request.urlopen(req) as resp: # noqa: S310 — scheme gated by parse_csv_uri
body = resp.read()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Bound header reads when servers ignore Range - When an HTTPS server ignores Range and returns 200, this unbounded read() buffers the entire CSV before the status is inspected. A multi-gigabyte source can therefore exhaust the validation process even though only its header is needed; stream only through the newline or MAX_HEADER_BYTES + 1.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: resp.read() ran unconditionally, before the status check, so a range-ignored (200) response buffered the whole object. Fixed in d1bf4a0 — the range-ignored path now reads via a bounded chunked helper that stops at the first newline or MAX_HEADER_BYTES, so a multi-gigabyte body is never buffered in full.

raise
buf += body
if b"\n" in buf:
return buf.split(b"\n", 1)[0].rstrip(b"\r").decode("utf-8")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Enforce the header-size limit before returning - If the first newline arrives after MAX_HEADER_BYTES, this branch returns the oversized header before checking the buffer length. The same ordering exists in the HTTPS reader, so headers larger than the documented 1 MiB limit are accepted whenever they contain a newline; validate the pre-newline length before returning.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and it applied to https.py too as you noted. Fixed in d1bf4a0 — extracted extract_header_line() (in csv_source.py, the dependency-free port module) which measures the resolved line itself (bytes up to the first newline) against MAX_HEADER_BYTES before returning, instead of checking only the intermediate accumulated-buffer length. Both s3.py and https.py now use it. New test: a header line that terminates in a newline only after exceeding the limit — previously returned as an oversized 'success', now raises.

start += HEADER_PROBE_BYTES

def fetch(self, uri: CsvUri, dest: Path) -> Path:
with urllib.request.urlopen(uri.raw) as resp, open(dest, "wb") as f: # noqa: S310

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Add finite timeouts to HTTPS fetches - When an HTTPS source accepts a connection but stalls while sending headers or data, both urlopen calls use the effectively unbounded default timeout, allowing validation or a scheduled node run to hang indefinitely. Pass an explicit finite timeout to the header and full-object requests.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d1bf4a0 — added a module-level _TIMEOUT_SECONDS = 30 constant, passed to both urlopen calls (header probe and full fetch) via the shared opener.

f"{csv_source}"
)
declared = [c["name"] for c in spec["output_columns"]]
extras = check_header(next(csv.reader([header_line])), declared)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Normalize BOMs before validating CSV headers - For a UTF-8-BOM CSV, PyArrow strips the BOM and the runtime sees order_id, but the header readers decode with plain UTF-8 and csv.reader produces U+FEFForder_id. The release gate consequently rejects a source the runtime can load; decode the first header as utf-8-sig or strip the BOM before check_header.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Fixed in d1bf4a0 — both readers now decode header bytes as utf-8-sig everywhere a header line is produced (in the shared extract_header_line() and the two no-newline-short-body return paths), so a BOM'd source's first column matches the declared name. New BOM test for both readers.

raise ContractError(f"{label}: unknown key(s) {sorted(unknown)}")

kind = raw.get("kind", "python-model")
if kind not in KINDS:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Type-check kind before set membership - When malformed YAML supplies an unhashable kind such as kind: [python-csv], membership in the frozenset raises a bare TypeError before the intended ContractError. This escapes CLI error handling and produces a traceback instead of a validation failure; reject non-string kinds first.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: kind: [python-csv] raised a bare TypeError from the frozenset membership test on an unhashable value, before ContractError. Fixed in d4044ac — check isinstance(kind, str) first (short-circuits before the membership test). New parametrized test covers list/dict/int/bool/None.

Raises ValueError for anything else (http:// included) so contract
validation fails at lint/parse time, never at run time.
"""
if uri.startswith("s3://"):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject non-string CSV URIs explicitly - For a contract such as reads: {csv: 123}, this call raises AttributeError from startswith() rather than ValueError or TypeError, so the loader wrapper does not convert it to ContractError and validation crashes. Check that the URI is a string before examining its prefix.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: reads: {csv: 123} raised AttributeError from .startswith(), uncaught by loader.py's except (ValueError, TypeError). Fixed in d1bf4a0 — parse_csv_uri now raises ValueError for a non-string uri up front. New parametrized test (including a bytes value, which raised a different TypeError than list/dict/int did on the old code).

sys.exit(2)
config = raw_config or {}
csv_source = spec.get("csv_source", "")
if csv_source and not isinstance(csv_source, str):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject falsey non-string csv_source values - If a candidate spec contains csv_source: false, 0, [], or {}, the truthiness guard skips both the type error and the later header check. A malformed python-csv candidate can therefore pass promotion without validating its source; validate the field type independently of its truthiness.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: csv_source: 0/false/[]/{} skipped the truthiness-gated check entirely, so no type error and no header check. Fixed in d4044ac — gate on 'csv_source' in spec (presence) instead of truthiness. New parametrized test (0, false, [], {}) asserts exit 2 for all four.

from continuo_python_runtime.csv_readers.https import HttpsCsvSourceReader
from continuo_python_runtime.csv_readers.s3 import S3CsvSourceReader

pytestmark = pytest.mark.integration

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep Docker tests out of the default runtime suite - These tests are marked integration, but the documented and CI runtime command only excludes image, so the new MinIO-backed reader and runner tests remain selected and invoke Docker. On the supported local setup without Docker, the standard pre-PR command now errors despite CONTRIBUTING.md lines 41-42 saying Docker is only needed for adapter integration tests; exclude integration tests from that suite or move them to a dedicated job.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — CONTRIBUTING.md's own documented pre-PR command had the same gap. Went with option (a): fixed in 0a1d215. The runtime step's marker is now -m "not image and not integration", and a new 'Tests (runtime, integration)' step runs tests/test_csv_readers_integration.py and tests/test_validation_runner.py under -m integration explicitly on the same runner (docker is available; minio self-provisions via the fixture's own docker run). CONTRIBUTING.md updated to match. Verified nothing is silently dropped: 463 (main) + 30 (integration) + 3 (image, unchanged in images.yml) = 496 total collected.

pyarrow's default type inference in read_csv was the first thing to
interpret a value, ahead of conform(): a VARCHAR column holding 00123
inferred as int64 and conform() wrote back "123", and a NUMERIC column
holding a valid decimal like 10.50 inferred as float64, which
conform()'s own lossy-cast guard then rejected outright.

Pass output_columns as read_csv's ConvertOptions.column_types so every
declared column is parsed once, directly as the type it is declared to
be.

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
…g, BOM

Four independent hardening fixes to the S3/HTTPS csv source readers:

- https.py: an https:// source that redirected to a non-https target was
  followed silently (urllib doesn't care about scheme on redirect),
  downgrading both the header probe and the full fetch to plaintext.
  Install a redirect handler that refuses any target not itself https://.

- https.py: when a server ignores our Range header and answers 200, the
  response is the whole object -- reading it with an unbounded resp.read()
  before even checking the status buffered a multi-gigabyte body in full
  just to inspect its header line. Bound that read to stop at the first
  newline or MAX_HEADER_BYTES.

- s3.py/https.py: a newline that only arrived after the accumulated probe
  buffer had already grown past MAX_HEADER_BYTES was returned as a
  successful (oversized) header line, because the newline check ran before
  the length check. Extracted the shared extract_header_line() (in
  csv_source.py, the dependency-free port module) which measures the
  *resolved line itself* against the limit, and both readers now use it.

- s3.py/https.py: a UTF-8 byte-order mark on the CSV's first byte survived
  a plain "utf-8" decode as a literal U+FEFF prepended to the first column
  name, so a declared column matching the visually-identical name failed
  check_header. Decode as utf-8-sig everywhere a header line is produced.

Also adds an explicit finite timeout (30s) to both HTTPS urlopen calls: an
https source that accepts the connection but stalls on headers or body
would otherwise hang a validation or node run indefinitely.

Tests: test_csv_readers_integration.py gains oversized-line-with-late-
newline and BOM cases for both readers, plus a redirect-to-a-reachable-
downgrade-target test proving the pre-fix code actually followed it
(rather than merely erroring on an unreachable host). test_csv_readers_
https.py is a new unit tier covering the redirect handler and timeout
wiring without a live server.

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
…rash

- contract/loader.py: kind: [python-csv] (or any unhashable kind) raised a
  bare TypeError from `kind not in KINDS` before the intended ContractError.
  Check isinstance(kind, str) first.

- validation/runner.py: the build_from_columns csv_source guard was gated
  on truthiness (`if csv_source and not isinstance(...)`), so csv_source:
  0 / false / [] / {} skipped both the type check and the header check
  below -- a malformed python-csv candidate could pass promotion silently.
  Gate on presence of the key instead.

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
The documented and CI "Tests (runtime)" command only deselected `image`,
so the new minio-backed csv-reader/validation-runner integration tests
(marked `integration`) stayed selected -- the standard pre-PR command from
CONTRIBUTING.md now required Docker despite that doc saying Docker is only
needed for the Postgres/Trino integration tests.

Deselect `integration` there too, and add a dedicated step that runs them
explicitly on the same runner (docker is available; minio self-provisions
via the minio_container fixture's own `docker run`), mirroring how the
Postgres/Trino adapters split their integration tier into a separate job.
Nothing is silently skipped: the new step names both test files.

Signed-off-by: Simone Carolini <simonecarolini.sc@gmail.com>
@carolsimone carolsimone self-assigned this Aug 21, 2026
@carolsimone
carolsimone merged commit b93b5c8 into main Aug 21, 2026
13 checks passed
@carolsimone
carolsimone deleted the feat/python-csv-nodes branch August 25, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant