continuo-python-runtime 0.3.1__py3-none-any.whl → 0.4.1__py3-none-any.whl

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.
@@ -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:
@@ -0,0 +1,68 @@
1
+ """continuo_python_runtime/csv_loader.py
2
+
3
+ Producer for python-csv nodes: materialize the declared table from the csv
4
+ source alone. Everything from conform() down (type coercion, extra_columns
5
+ policy, ensure_table, transactional load) is the existing harness path —
6
+ this module only turns the contract entry into a pyarrow Table.
7
+ """
8
+ import logging
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ import pyarrow.csv # type: ignore[import-untyped]
13
+
14
+ from continuo_python_runtime.contract.model import Node
15
+ from continuo_python_runtime.csv_readers import reader_for
16
+ from continuo_python_runtime.csv_source import CsvSourceReader, parse_csv_uri
17
+ from continuo_python_runtime.errors import LoadError
18
+ from continuo_python_runtime.types import arrow_type, parse_sql_type
19
+
20
+ logger = logging.getLogger("continuo_python_runtime.csv_loader")
21
+
22
+
23
+ def produce_csv(node: Node, reader: CsvSourceReader | None = None) -> "pyarrow.Table":
24
+ """Fetch node.reads['csv'] and parse it (RFC4180 defaults) into a Table.
25
+
26
+ The caller conforms the result to output_columns exactly as for a script
27
+ node, so declared types — not csv inference — decide the warehouse schema.
28
+
29
+ ``output_columns`` names/types are also passed to ``read_csv`` itself as
30
+ its convert schema (via ``ConvertOptions.column_types``): pyarrow's default
31
+ type inference is otherwise the *first* place a value gets interpreted,
32
+ and it can destroy the very lexical value ``conform()`` is supposed to
33
+ preserve -- a VARCHAR column holding ``00123`` infers as int64 and
34
+ conform() writes back ``"123"``, and a NUMERIC column holding a valid
35
+ decimal like ``10.50`` infers as float64, which conform()'s own
36
+ lossy-cast guard then rejects outright. Reading every declared column
37
+ directly as its target Arrow type sidesteps both: the value is parsed
38
+ once, as the type it is actually declared to be.
39
+ """
40
+ uri = parse_csv_uri(node.reads["csv"])
41
+ active_reader = reader if reader is not None else reader_for(uri)
42
+ column_types = {
43
+ col.name: arrow_type(parse_sql_type(col.type)) for col in node.output_columns
44
+ }
45
+ convert_options = pyarrow.csv.ConvertOptions(column_types=column_types)
46
+ try:
47
+ with tempfile.TemporaryDirectory() as tmp:
48
+ dest = active_reader.fetch(uri, Path(tmp) / "source.csv")
49
+ table = pyarrow.csv.read_csv(dest, convert_options=convert_options)
50
+ except LoadError:
51
+ raise
52
+ except Exception as exc:
53
+ raise LoadError(f"csv fetch failed for {node.relation}: {exc}") from exc
54
+ declared = {col.name for col in node.output_columns}
55
+ extras = set(table.column_names) - declared
56
+ if extras:
57
+ # Spec parity with the validation runner's csv_source header check
58
+ # (continuo_python_runtime/validation/runner.py): extra_columns: drop
59
+ # silently discards these at conform() time, so this structured
60
+ # warning is the only place the RUN path surfaces which columns were
61
+ # dropped.
62
+ logger.warning(
63
+ "csv_header_extra_columns node=%s columns=%s — columns present in the "
64
+ "csv but not declared in output_columns; they will not be loaded",
65
+ node.relation, sorted(extras))
66
+ logger.info("csv source %s: %d rows, columns=%s",
67
+ uri.raw, table.num_rows, table.column_names)
68
+ return table
@@ -0,0 +1,17 @@
1
+ """continuo_python_runtime/csv_readers/__init__.py"""
2
+ from continuo_python_runtime.csv_source import CsvSourceReader, CsvUri
3
+ from continuo_python_runtime.csv_readers.https import HttpsCsvSourceReader
4
+ from continuo_python_runtime.csv_readers.s3 import S3CsvSourceReader
5
+
6
+
7
+ def reader_for(uri: CsvUri) -> CsvSourceReader:
8
+ """Composition edge: pick the adapter for the parsed scheme.
9
+
10
+ parse_csv_uri already constrains uri.scheme to "s3" or "https", but the
11
+ dispatch stays explicit (rather than an s3/else fallback) so a scheme
12
+ added to the parser without a matching adapter fails loudly here too."""
13
+ if uri.scheme == "s3":
14
+ return S3CsvSourceReader()
15
+ if uri.scheme == "https":
16
+ return HttpsCsvSourceReader()
17
+ raise ValueError(f"unsupported csv scheme: {uri.scheme!r}")
@@ -0,0 +1,118 @@
1
+ """continuo_python_runtime/csv_readers/https.py"""
2
+ import shutil
3
+ import urllib.error
4
+ import urllib.request
5
+ from pathlib import Path
6
+
7
+ from continuo_python_runtime.csv_source import (
8
+ HEADER_PROBE_BYTES,
9
+ MAX_HEADER_BYTES,
10
+ CsvSourceReader,
11
+ CsvUri,
12
+ extract_header_line,
13
+ )
14
+
15
+ # Both urlopen calls below must never hang forever: a source that accepts the
16
+ # TCP connection but stalls on headers or body would otherwise wedge a
17
+ # validation run or a scheduled node run indefinitely.
18
+ _TIMEOUT_SECONDS = 30
19
+
20
+ # Chunk size for the bounded read used when a server ignores our Range header
21
+ # and answers 200: reading in chunks this small lets fetch_header_line stop
22
+ # at the first newline (or MAX_HEADER_BYTES) without ever buffering a
23
+ # multi-gigabyte body just to inspect its first line.
24
+ _READ_CHUNK_BYTES = 65_536
25
+
26
+
27
+ class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
28
+ """Refuses to follow a redirect whose target is not itself https://.
29
+
30
+ urllib follows redirects automatically, and by default does not care
31
+ what scheme the target uses -- an https:// source that redirects to
32
+ http:// (or any other scheme) would otherwise silently downgrade both
33
+ the header probe and the full fetch to plaintext, defeating
34
+ parse_csv_uri's https-only restriction.
35
+ """
36
+
37
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
38
+ if not newurl.lower().startswith("https://"):
39
+ raise urllib.error.HTTPError(
40
+ newurl, code,
41
+ f"refusing to follow redirect to non-https URL: {newurl}",
42
+ headers, fp,
43
+ )
44
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
45
+
46
+
47
+ _opener = urllib.request.build_opener(_HttpsOnlyRedirectHandler)
48
+
49
+
50
+ def _read_bounded(resp, limit: int) -> bytes:
51
+ """Read ``resp`` in small chunks, stopping at the first newline or once
52
+ more than ``limit`` bytes have been buffered, whichever comes first.
53
+
54
+ Used for the range-ignored (status 200) path, where the server's
55
+ response is the whole object: an unbounded ``resp.read()`` there would
56
+ buffer a multi-gigabyte body in full just to read its header line.
57
+ """
58
+ buf = b""
59
+ while True:
60
+ chunk = resp.read(_READ_CHUNK_BYTES)
61
+ if not chunk:
62
+ return buf
63
+ buf += chunk
64
+ if b"\n" in buf or len(buf) > limit:
65
+ return buf
66
+
67
+
68
+ class HttpsCsvSourceReader(CsvSourceReader):
69
+ """Reads a csv source over HTTPS (public URLs; no auth in v1). Mirrors
70
+ S3CsvSourceReader's probe-and-extend strategy: each ranged request is
71
+ independent, so a server that ignores Range and answers 200 (not 206)
72
+ is handled too -- its response is the whole object, so it is treated
73
+ as terminal on the first pass regardless of whether it contains a
74
+ newline or how large it is, rather than being re-fetched and
75
+ re-appended pass after pass."""
76
+
77
+ def fetch_header_line(self, uri: CsvUri) -> str:
78
+ start = 0
79
+ buf = b""
80
+ while True:
81
+ end = start + HEADER_PROBE_BYTES - 1
82
+ req = urllib.request.Request(
83
+ uri.raw, headers={"Range": f"bytes={start}-{end}"})
84
+ with _opener.open(req, timeout=_TIMEOUT_SECONDS) as resp: # noqa: S310 — scheme gated by parse_csv_uri and _HttpsOnlyRedirectHandler
85
+ range_honoured = resp.status == 206
86
+ if range_honoured:
87
+ body = resp.read()
88
+ else:
89
+ # The server ignored our Range header and returned the
90
+ # entire object (status 200): bound the read itself so a
91
+ # multi-gigabyte body is never buffered in full, and
92
+ # this response is terminal regardless of its size --
93
+ # every retry would re-fetch the identical full body, so
94
+ # looping would only re-append it pass after pass and
95
+ # eventually trip a false MAX_HEADER_BYTES overflow.
96
+ body = _read_bounded(resp, MAX_HEADER_BYTES)
97
+ if not range_honoured:
98
+ return extract_header_line(body, uri.raw)
99
+ buf += body
100
+ if b"\n" in buf:
101
+ return extract_header_line(buf, uri.raw)
102
+ if len(body) < HEADER_PROBE_BYTES: # whole object read, no newline
103
+ return buf.rstrip(b"\r").decode("utf-8-sig")
104
+ if len(buf) > MAX_HEADER_BYTES:
105
+ raise ValueError(
106
+ f"csv header line exceeds {MAX_HEADER_BYTES} bytes: {uri.raw}")
107
+ start += HEADER_PROBE_BYTES
108
+
109
+ def fetch(self, uri: CsvUri, dest: Path) -> Path:
110
+ with (
111
+ _opener.open(uri.raw, timeout=_TIMEOUT_SECONDS) as resp, # noqa: S310 — scheme gated by parse_csv_uri and _HttpsOnlyRedirectHandler
112
+ open(dest, "wb") as f,
113
+ ):
114
+ shutil.copyfileobj(resp, f)
115
+ return dest
116
+
117
+
118
+ assert issubclass(HttpsCsvSourceReader, CsvSourceReader)
@@ -0,0 +1,57 @@
1
+ """continuo_python_runtime/csv_readers/s3.py"""
2
+ from pathlib import Path
3
+
4
+ from botocore.exceptions import ClientError # type: ignore[import-untyped]
5
+
6
+ from continuo_python_runtime.csv_source import (
7
+ HEADER_PROBE_BYTES,
8
+ MAX_HEADER_BYTES,
9
+ CsvSourceReader,
10
+ CsvUri,
11
+ extract_header_line,
12
+ )
13
+ from continuo_python_runtime.validation.s3 import make_s3_client
14
+
15
+
16
+ class S3CsvSourceReader(CsvSourceReader):
17
+ """Reads a csv source from S3. Reuses make_s3_client so S3_ENDPOINT_URL
18
+ (minio, localstack) and boto3's own credential chain behave identically
19
+ to the validation runner's existing S3 access."""
20
+
21
+ def fetch_header_line(self, uri: CsvUri) -> str:
22
+ client = make_s3_client()
23
+ start = 0
24
+ buf = b""
25
+ while True:
26
+ end = start + HEADER_PROBE_BYTES - 1
27
+ try:
28
+ body = client.get_object(
29
+ Bucket=uri.bucket, Key=uri.key, Range=f"bytes={start}-{end}"
30
+ )["Body"].read()
31
+ except ClientError as exc:
32
+ if start == 0 and exc.response.get("Error", {}).get("Code") == "InvalidRange":
33
+ # A Range request on byte 0 is unsatisfiable only when the
34
+ # object itself is 0 bytes long: treat a 0-byte csv source
35
+ # as an empty header line rather than a hard failure --
36
+ # the caller (validation/runner.py) raises a clear error
37
+ # for an empty header line.
38
+ return ""
39
+ raise
40
+ buf += body
41
+ if b"\n" in buf:
42
+ return extract_header_line(buf, uri.raw)
43
+ if len(body) < HEADER_PROBE_BYTES: # whole object read, no newline
44
+ return buf.rstrip(b"\r").decode("utf-8-sig")
45
+ if len(buf) > MAX_HEADER_BYTES:
46
+ raise ValueError(
47
+ f"csv header line exceeds {MAX_HEADER_BYTES} bytes: {uri.raw}")
48
+ start += HEADER_PROBE_BYTES
49
+
50
+ def fetch(self, uri: CsvUri, dest: Path) -> Path:
51
+ client = make_s3_client()
52
+ with open(dest, "wb") as f:
53
+ client.download_fileobj(uri.bucket, uri.key, f)
54
+ return dest
55
+
56
+
57
+ assert issubclass(S3CsvSourceReader, CsvSourceReader)
@@ -0,0 +1,96 @@
1
+ """CSV-source rules shared by the run harness and the validation runner:
2
+ the URI grammar, the header-conformance rule, and the reader port both
3
+ consumers depend on. Dependency-free — adapters that do I/O live in
4
+ continuo_python_runtime/csv_readers/ and implement CsvSourceReader.
5
+ """
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ HEADER_PROBE_BYTES = 65_536 # first ranged fetch when probing for the header line
11
+ MAX_HEADER_BYTES = 1_048_576 # a header line longer than this is a failure
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class CsvUri:
16
+ scheme: str # "s3" | "https"
17
+ raw: str
18
+ bucket: str = "" # s3 only
19
+ key: str = "" # s3 only
20
+
21
+
22
+ def parse_csv_uri(uri: str) -> CsvUri:
23
+ """Parse a csv source URI. Accepts exactly s3://bucket/key and https://...
24
+
25
+ Raises ValueError for anything else (http:// included), and for a
26
+ non-string ``uri`` (e.g. a contract's `reads: {csv: 123}`) rather than
27
+ letting `.startswith()` raise a bare AttributeError/TypeError -- callers
28
+ (the contract loader) catch ValueError to turn this into a ContractError,
29
+ so a malformed uri fails at lint/parse time, never at run time.
30
+ """
31
+ if not isinstance(uri, str):
32
+ raise ValueError(f"csv uri must be a string, got {type(uri).__name__}: {uri!r}")
33
+ if uri.startswith("s3://"):
34
+ bucket, _, key = uri[len("s3://"):].partition("/")
35
+ if not bucket or not key:
36
+ raise ValueError(f"invalid s3 csv uri (missing bucket or key): {uri!r}")
37
+ return CsvUri(scheme="s3", raw=uri, bucket=bucket, key=key)
38
+ if uri.startswith("https://"):
39
+ remainder = uri[len("https://"):]
40
+ host, _, _ = remainder.partition("/")
41
+ if host:
42
+ return CsvUri(scheme="https", raw=uri)
43
+ raise ValueError(
44
+ f"invalid csv uri {uri!r}: must be s3://bucket/key or https://..."
45
+ )
46
+
47
+
48
+ def extract_header_line(data: bytes, source_desc: str) -> str:
49
+ """Return the first line of ``data`` (no trailing newline), decoded as
50
+ utf-8-sig so a UTF-8 byte-order mark on the CSV's first byte does not end
51
+ up prepended to the first column name.
52
+
53
+ Shared by every :class:`CsvSourceReader` adapter's ``fetch_header_line``:
54
+ the check must run on the *resolved line itself* (the bytes up to, and
55
+ including the absence of, the first newline), not merely on an
56
+ intermediate buffer length -- a newline that only arrives after the
57
+ accumulated buffer has already grown past ``MAX_HEADER_BYTES`` must still
58
+ be rejected as oversized, not returned as a successful (if enormous)
59
+ header line.
60
+
61
+ Raises:
62
+ ValueError: If the line exceeds ``MAX_HEADER_BYTES``.
63
+ """
64
+ line = data.split(b"\n", 1)[0] if b"\n" in data else data
65
+ if len(line) > MAX_HEADER_BYTES:
66
+ raise ValueError(f"csv header line exceeds {MAX_HEADER_BYTES} bytes: {source_desc}")
67
+ return line.rstrip(b"\r").decode("utf-8-sig")
68
+
69
+
70
+ def check_header(header_cols: list[str], declared_cols: list[str]) -> set[str]:
71
+ """Presence-only header conformance: every declared column must appear in
72
+ the CSV header, in any order. Returns the set of header columns NOT
73
+ declared (extras) so callers can surface them as a warning.
74
+
75
+ Raises ValueError naming every missing declared column.
76
+ """
77
+ header = set(header_cols)
78
+ missing = [c for c in declared_cols if c not in header]
79
+ if missing:
80
+ raise ValueError(f"csv header missing declared column(s): {sorted(missing)}")
81
+ return header - set(declared_cols)
82
+
83
+
84
+ class CsvSourceReader(ABC):
85
+ """Port for reading a csv source. Implemented by csv_readers adapters;
86
+ consumed by the run harness (full fetch) and the validation runner
87
+ (header line only). The dependency arrow runs adapter -> this port."""
88
+
89
+ @abstractmethod
90
+ def fetch_header_line(self, uri: CsvUri) -> str:
91
+ """Return the CSV's first line (no trailing newline). Raises on an
92
+ unreachable source or a header longer than MAX_HEADER_BYTES."""
93
+
94
+ @abstractmethod
95
+ def fetch(self, uri: CsvUri, dest: Path) -> Path:
96
+ """Stream the full object to dest and return dest."""
@@ -31,6 +31,8 @@ from continuo_python_runtime.context import RunContext
31
31
  from continuo_python_runtime.contract.loader import load_contract_dir
32
32
  from continuo_python_runtime.contract.model import Node
33
33
  from continuo_python_runtime.contract.paths import resolve_script_path
34
+ from continuo_python_runtime.csv_loader import produce_csv
35
+ from continuo_python_runtime.csv_source import CsvSourceReader
34
36
  from continuo_python_runtime.errors import ContractError, HarnessError, LoadError, ScriptError
35
37
 
36
38
  logger = logging.getLogger("continuo_python_runtime.harness")
@@ -206,9 +208,16 @@ def _validate_config_early(adapter: Any, node: Node) -> None:
206
208
  ) from exc
207
209
 
208
210
 
209
- def run_node(env: Mapping[str, str], adapter: Any = None) -> int:
211
+ def run_node(
212
+ env: Mapping[str, str], adapter: Any = None, reader: CsvSourceReader | None = None
213
+ ) -> int:
210
214
  """Run a single node end-to-end and print exactly one sentinel result block.
211
215
 
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.
220
+
212
221
  Returns 0 on success, 1 on any :class:`HarnessError`.
213
222
  """
214
223
  node_id = env.get("NODE_ID") or ""
@@ -246,13 +255,15 @@ def run_node(env: Mapping[str, str], adapter: Any = None) -> int:
246
255
 
247
256
  _validate_config_early(active_adapter, node)
248
257
 
249
- with contextlib.redirect_stdout(sys.stderr):
250
- module = load_script(node, app_root)
251
-
252
- ctx = RunContext(node, active_adapter)
253
- raw_result = _execute_script(module, ctx)
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)
254
266
 
255
- table = to_arrow(raw_result)
256
267
  conformed = conform(table, node.output_columns, node.extra_columns)
257
268
 
258
269
  columns = [
@@ -8,10 +8,16 @@ Dispatches on ``VALIDATION_OP`` env var (default ``build_from_sql``):
8
8
  - ``build_from_columns``: for python nodes, which have no SELECT to shape their output
9
9
  from. Fetch the node's validation spec JSON from S3 (``CANDIDATE_SPEC_URI`` —
10
10
  ``{"reads": [sql, ...], "output_columns": [{"name","type","nullable"}, ...],
11
- "config": {...}}``; ``config`` is optional and defaults to ``{}``), bind-check every
12
- declared read against the candidate schema so an upstream that
13
- dropped a column the script reads fails the release gate, then materialize the
14
- output table empty from the declared typed columns and the declared physical layout.
11
+ "config": {...}, "csv_source": "s3://..." | "https://..."}``; ``config`` and
12
+ ``csv_source`` are both optional. When ``csv_source`` is set, its header row is
13
+ fetched (without downloading the full object) and checked against the declared
14
+ output columns: a declared column missing from the header fails the release gate,
15
+ while a header column not declared only logs a ``csv_header_extra_columns``
16
+ warning (it is silently dropped at load time). ``config`` defaults to ``{}``.
17
+ Every declared read is bind-checked against the candidate schema so an upstream
18
+ that dropped a column the script reads fails the release gate, then the output
19
+ table is materialized empty from the declared typed columns and the declared
20
+ physical layout.
15
21
 
16
22
  The engine adapter is discovered from the single installed
17
23
  ``continuo_engine.adapters`` entry point — each runner image installs exactly one.
@@ -19,6 +25,7 @@ stdout is reserved exclusively for the runner's one structured ``result_block``,
19
25
  printed as its last line; all diagnostics go to stderr via the ``logging`` module.
20
26
  A non-zero exit marks the node failed.
21
27
  """
28
+ import csv
22
29
  import json
23
30
  import logging
24
31
  import os
@@ -29,6 +36,8 @@ from continuo_engine_contract.port import ( # type: ignore[import-untyped]
29
36
  AdapterDiscoveryError,
30
37
  discover_adapter,
31
38
  )
39
+ from continuo_python_runtime.csv_readers import reader_for
40
+ from continuo_python_runtime.csv_source import check_header, parse_csv_uri
32
41
  from continuo_python_runtime.validation import s3
33
42
 
34
43
  logger = logging.getLogger("validation_runner")
@@ -126,6 +135,7 @@ def main() -> None:
126
135
  prod_schema = None
127
136
  spec: dict | None = None
128
137
  config: dict = {}
138
+ csv_source: str = ""
129
139
  if op in _NODE_OPS:
130
140
  table = _require("TABLE_NAME")
131
141
  unique_id = _node_id() or f"model.{table}"
@@ -175,6 +185,16 @@ def main() -> None:
175
185
  print(result.result_block("error", msg, unique_id=unique_id), flush=True)
176
186
  sys.exit(2)
177
187
  config = raw_config or {}
188
+ csv_source = spec.get("csv_source", "")
189
+ if "csv_source" in spec and not isinstance(csv_source, str):
190
+ # Checked by presence, not truthiness: `csv_source: 0` / `false` /
191
+ # `[]` / `{}` are all non-string values a malformed spec could
192
+ # carry, and a truthiness guard would silently skip both this
193
+ # type check and the header check below for every one of them.
194
+ msg = f"candidate spec 'csv_source' must be a string, got {type(csv_source).__name__}"
195
+ logger.error("%s", msg)
196
+ print(result.result_block("error", msg, unique_id=unique_id), flush=True)
197
+ sys.exit(2)
178
198
  else:
179
199
  prod_schema = _require("PROD_SCHEMA")
180
200
  elif op in _SCHEMA_OPS:
@@ -220,6 +240,21 @@ def main() -> None:
220
240
  adapter.build_empty_from_sql(schema, table, candidate_sql)
221
241
  elif op == "build_from_columns":
222
242
  assert spec is not None, "spec must be set for build_from_columns"
243
+ if csv_source:
244
+ csv_uri = parse_csv_uri(csv_source)
245
+ header_line = reader_for(csv_uri).fetch_header_line(csv_uri)
246
+ if not header_line:
247
+ raise ValueError(
248
+ "csv source has no header line (empty or unreadable): "
249
+ f"{csv_source}"
250
+ )
251
+ declared = [c["name"] for c in spec["output_columns"]]
252
+ extras = check_header(next(csv.reader([header_line])), declared)
253
+ if extras:
254
+ logger.warning(
255
+ "csv_header_extra_columns node=%s columns=%s — columns present in the "
256
+ "csv but not declared in output_columns; they will not be loaded",
257
+ unique_id, sorted(extras))
223
258
  for read_sql in spec.get("reads", []):
224
259
  adapter.check_binds(read_sql)
225
260
  adapter.build_empty_from_columns(schema, table, spec["output_columns"], config)
@@ -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.1
4
4
  Summary: Runtime harness, contract tooling, and CI lint for Continuo python nodes.
5
5
  Author: Simone Carolini
6
6
  Maintainer: Simone Carolini
@@ -11,11 +11,11 @@ Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Requires-Python: >=3.14
14
- Requires-Dist: boto3==1.43.59
15
- Requires-Dist: continuo-engine-contract==0.7.1
16
- Requires-Dist: pyarrow==25.0.0
14
+ Requires-Dist: boto3==1.43.74
15
+ Requires-Dist: continuo-engine-contract==0.7.3
16
+ Requires-Dist: pyarrow==25.0.1
17
17
  Requires-Dist: pyyaml==6.0.3
18
- Requires-Dist: sqlglot==30.15.0
18
+ Requires-Dist: sqlglot==30.17.0
19
19
  Description-Content-Type: text/markdown
20
20
 
21
21
  # Continuo Python Runtime
@@ -32,7 +32,7 @@ and register the release with Continuo.
32
32
 
33
33
  ## What this repo is
34
34
 
35
- Four artifacts come out of this repository:
35
+ Five artifacts come out of this repository:
36
36
 
37
37
  - **The `continuo-python-runtime` PyPI package** — the `continuo-runtime` CLI
38
38
  (`validate` / `merge` / `hash` / `lint` / `run` / `validation-op`) and the
@@ -41,6 +41,13 @@ Four artifacts come out of this repository:
41
41
  - **The `continuo-engine-contract` PyPI package** — the `WarehouseAdapter`
42
42
  port, the contract schema, the shared SQL/type/config guards, and the
43
43
  sentinel result-block format. Adapter authors outside this repo pin it.
44
+ - **The two engine-adapter PyPI packages** (`continuo-postgres-adapter`,
45
+ `continuo-trino-adapter`) — one `WarehouseAdapter` implementation per
46
+ warehouse engine, each published independently under the same tag. A
47
+ domain repo normally never installs these directly (the engine image
48
+ already has the matching one baked in); they exist as standalone PyPI
49
+ packages for the "build your own container" shape (see below) and for
50
+ third-party adapter authors to reference.
44
51
  - **Per-engine base images**, one per warehouse engine
45
52
  (`continuo-python-runtime-postgres`, `continuo-python-runtime-trino`), that
46
53
  domain repos build `FROM`. Each image bakes in the runtime and a single
@@ -50,10 +57,11 @@ Four artifacts come out of this repository:
50
57
  - **`template/`** — a copy-ready domain repo: `Dockerfile`, `contracts/`,
51
58
  `scripts/`, and the `release.yml` CI/CD workflow.
52
59
 
53
- One `vX.Y.Z` git tag releases all of it: `publish-pypi.yml` builds both
54
- distributions into a single `dist/` and publishes them together, and
55
- `images.yml` builds and pushes both engine images multi-arch under the same
56
- tag.
60
+ One `vX.Y.Z` git tag releases all of it: `publish-pypi.yml` builds all four
61
+ PyPI distributions into a single `dist/` and publishes them together, and
62
+ `images.yml` builds and pushes both engine images — each installing its
63
+ matching pinned adapter version from that same release — multi-arch under
64
+ the same tag.
57
65
 
58
66
  ### What this repo owns
59
67
 
@@ -70,22 +78,27 @@ validation-side port, adapter class, entry-point group, or image. One
70
78
  | --- | --- | --- | --- |
71
79
  | `continuo-python-runtime` | `continuo_python_runtime` | this repo (root) | Harness (CLI, `conform()`, `RunContext`, error taxonomy) **and** the validation runner (`continuo-runtime validation-op`). Published to PyPI. |
72
80
  | `continuo-engine-contract` | `continuo_engine_contract` | this repo, `contract/` | The `WarehouseAdapter` port, contract schema, the SQL/type/config guards adapters must run, and the result-block format. Published to PyPI. |
73
- | `continuo-python-runtime-postgres` | `continuo_python_runtime_postgres` | this repo, `adapters/postgres/` | `PostgresAdapter` — one class, both roles. **Not published to PyPI** — built from source into the image. |
74
- | `continuo-python-runtime-trino` | `continuo_python_runtime_trino` | this repo, `adapters/trino/` | `TrinoAdapter` — one class, both roles, for Trino/Iceberg. **Not published to PyPI** — built from source into the image. |
81
+ | `continuo-postgres-adapter` | `continuo_postgres_adapter` | this repo, `adapters/postgres/` | `PostgresAdapter` — one class, both roles. Published to PyPI. |
82
+ | `continuo-trino-adapter` | `continuo_trino_adapter` | this repo, `adapters/trino/` | `TrinoAdapter` — one class, both roles, for Trino/Iceberg. Published to PyPI. |
75
83
 
76
84
  All four are uv workspace members (`[tool.uv.workspace]` in the root
77
85
  `pyproject.toml`), so `uv sync --all-packages --all-groups` at the repo root
78
86
  installs everything for local development.
79
87
 
80
- **Only `continuo-python-runtime` and `continuo-engine-contract` are published
81
- to PyPI.** The two engine adapters are built **from source into the engine
82
- images**: `Dockerfile.postgres` and `Dockerfile.trino` install them out of the
83
- build context, so each image ships exactly one adapter and the runtime
84
- discovers it through the `continuo_engine.adapters` entry-point group at run
85
- time. Nothing installs them from an index — the harness package does not
86
- depend on them, and domain repos get their adapter by building `FROM` a
87
- published base image. They are still built, type-checked, and tested by CI on
88
- every change.
88
+ **All four packages in the table above are published to PyPI**, under the
89
+ same `vX.Y.Z` tag. The two engine images then **install the matching pinned
90
+ adapter version from PyPI** — `Dockerfile.postgres` installs
91
+ `continuo-postgres-adapter==X.Y.Z`, `Dockerfile.trino` installs
92
+ `continuo-trino-adapter==X.Y.Z` — rather than building it from this repo's
93
+ source tree, so each image still ships exactly one adapter and the runtime
94
+ still discovers it through the `continuo_engine.adapters` entry-point group
95
+ at run time. The image **name** (`continuo-python-runtime-<engine>`) and the
96
+ adapter's pip **distribution** name (`continuo-<engine>-adapter`) are two
97
+ different artifacts of the same adapter — same engine, same version, same
98
+ runtime behavior, different packaging; see "Build your own container" below
99
+ for a build shape that installs the pip package directly instead of `FROM`
100
+ the image. All four packages are still built, type-checked, and tested by CI
101
+ on every change.
89
102
 
90
103
  ### The result block is a frozen wire contract
91
104
 
@@ -114,7 +127,9 @@ the Go parser has not been taught is a production outage, not a refactor.
114
127
  login` step to `release.yml`.
115
128
  5. Write a contract file under `contracts/` (see
116
129
  `template/contracts/example.yml`) and a script under `scripts/` that
117
- implements `run(ctx)` (see `template/scripts/example.py`).
130
+ implements `run(ctx)` (see `template/scripts/example.py`). A node that
131
+ only needs to land a csv file needs no script at all — see
132
+ `template/contracts/example_csv.yml` and "Node kinds" below.
118
133
  6. Push to `main`. The `release.yml` workflow lints the scripts, validates
119
134
  and merges the contracts, builds and pushes the image, uploads the merged
120
135
  contract to S3, and POSTs the release.
@@ -141,6 +156,30 @@ The runtime image does not re-run this gate, so a read that passes here is
141
156
  not re-judged under a different grammar in production. See
142
157
  `docs/boundary-contract.md` §13.1.
143
158
 
159
+ ## Node kinds
160
+
161
+ A contract node's `kind:` field selects how the node produces its rows.
162
+ Every rule below (`extra_columns`, `output_columns`, "Conform rules") applies
163
+ to both kinds identically — `kind` only changes how the pre-conform table is
164
+ produced, never how it is checked or written.
165
+
166
+ - **`python-model`** (the default; the field may be omitted) — a script node.
167
+ It requires `script:` and a `reads:` map of one or more named SQL queries,
168
+ as described in "The script API" below.
169
+ - **`python-csv`** — a contract-only node: it has no script and its `reads:`
170
+ map must be exactly `{csv: <uri>}`, where the uri is `s3://bucket/key` or
171
+ an `https://` url (`http://` is rejected at validate time, not run time).
172
+ The harness fetches the file, parses it with RFC 4180 defaults, and feeds
173
+ the result straight into `conform()` — declared `output_columns` types
174
+ decide the warehouse schema, not whatever pyarrow infers from the csv.
175
+ Because there is no script, `script:` is a forbidden key for this kind;
176
+ `continuo-runtime validate`/`merge`/`lint` reject one that sets it. The
177
+ csv's header row must contain every declared output column (checked again,
178
+ independently, at release time before promotion); columns present in the
179
+ header but not declared are governed by the same `extra_columns` policy as
180
+ a script node's output — `raise` (default) fails the run, `warn` drops
181
+ them and logs a warning. See `template/contracts/example_csv.yml`.
182
+
144
183
  ## The script API
145
184
 
146
185
  A node script is a Python file with exactly one required entry point:
@@ -236,26 +275,46 @@ A domain repo picks its warehouse engine by which base image it builds
236
275
  `FROM`:
237
276
 
238
277
  ```dockerfile
239
- FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.3.1
278
+ FROM ghcr.io/carolsimone/continuo-python-runtime-postgres:v0.4.1
240
279
  # or
241
- FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.3.1
280
+ FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.4.1
242
281
  ```
243
282
 
244
283
  The engine is part of the image **name**; the tag is the bare version, so
245
284
  Continuo's Helm chart can pin an image as `<name>:vX.Y.Z@sha256:<digest>`.
246
285
 
247
- Each image bakes in exactly one `WarehouseAdapter` for that engine — installed
248
- from this repo's `adapters/postgres/` or `adapters/trino/` package (see the
249
- table above) — registered under the `continuo_engine.adapters` entry-point
250
- group (entry names `postgres` / `trino`). The runtime discovers it via
251
- `discover_adapter()` at run time, so a single image serves every node in the
252
- service and the release-time validation Job for it. The executor injects the
253
- warehouse connection as environment variables (engine-native, e.g.
286
+ Each image bakes in exactly one `WarehouseAdapter` for that engine — the
287
+ pinned PyPI version of the `continuo-postgres-adapter` or
288
+ `continuo-trino-adapter` package built from this repo's `adapters/postgres/`
289
+ or `adapters/trino/` source (see the table above) — registered under the
290
+ `continuo_engine.adapters` entry-point group (entry names `postgres` /
291
+ `trino`). The runtime discovers it via `discover_adapter()` at run time, so a
292
+ single image serves every node in the service and the release-time
293
+ validation Job for it. The executor injects the warehouse connection as
294
+ environment variables (engine-native, e.g.
254
295
  `POSTGRES_HOST`/`POSTGRES_DB`/`POSTGRES_USER`) plus the node-selection
255
296
  environment (`NODE_ID`, `TABLE_NAME`, `TARGET_SCHEMA`, and optionally
256
297
  `CONTRACT_DIR`/`APP_ROOT`) that `continuo-runtime run` reads to dispatch the
257
298
  right node's script.
258
299
 
300
+ ### Build your own container
301
+
302
+ A domain repo does not have to build `FROM` the published engine image.
303
+ `template/` ships two Dockerfiles for the two build shapes (see
304
+ `template/README.md` § "Choosing a base" for the full comparison):
305
+
306
+ - **Shape 1 — `template/Dockerfile`** — `FROM` the published engine image
307
+ (`continuo-python-runtime-<engine>`), as shown above. Simplest; the image
308
+ already has the runtime and adapter installed and pinned.
309
+ - **Shape 2 — `template/Dockerfile.pip`** — your own base image, installing
310
+ `continuo-python-runtime` and one `continuo-<engine>-adapter` from PyPI
311
+ via a hash-locked `requirements.lock` (`template/requirements.lock`). Use
312
+ this when you must control the base image yourself.
313
+
314
+ Both shapes end up running the same runtime and the same adapter version;
315
+ which one you pick only changes who controls the base OS layer underneath
316
+ them.
317
+
259
318
  ## Further reading
260
319
 
261
320
  - `docs/superpowers/specs/2026-07-31-python-runtime-design.md` — this
@@ -0,0 +1,29 @@
1
+ continuo_python_runtime/__init__.py,sha256=otNaXEPsTtRXJV5FLtDUsFO8VD4OC-MLAzwhtimHfOE,70
2
+ continuo_python_runtime/cli.py,sha256=X4JBC4FDpgFOeDdtBbeX9GTq1XTuJCghDpemlR_CPvE,5925
3
+ continuo_python_runtime/closure.py,sha256=6e-IAQp8Lieb0bGKP1F6JE4TgVBJ_COTGXRUult2xeI,12991
4
+ continuo_python_runtime/conform.py,sha256=a9CKCA-gT9Zerzr6c5H8aByTFOUd6uBMo02sNKSDnXg,9199
5
+ continuo_python_runtime/context.py,sha256=K-9XXYlQ4OZ250rCOq6EJNq7fg_D5sK6tOcu7DLZfVg,1856
6
+ continuo_python_runtime/csv_loader.py,sha256=a5Hwu-YIzIiko8X1_vqShsnzjs2-OsKEUSCHLXg_dio,3346
7
+ continuo_python_runtime/csv_source.py,sha256=7g8Tu6ieiJLvWH4Ep4nwYlOdGkjeePr12K5UHuO8TNc,4136
8
+ continuo_python_runtime/errors.py,sha256=nfZzfBhQ2ajm7USAWKKaGY2uUlh8eNjNPNYYuaOJgZE,1032
9
+ continuo_python_runtime/harness.py,sha256=BBJBmmiQ922WVmPi0Xh7CFnXjNxKefPey0YOEHercjs,12634
10
+ continuo_python_runtime/hashing.py,sha256=70iXQb0CmjGqniTPofXY6WapydQ7aK2XOk5dzpEf-zk,3354
11
+ continuo_python_runtime/lint.py,sha256=M59UtCHHVQnJKNolIkvkRk_AgGAFvxD9zkKAMMK-7GI,12984
12
+ continuo_python_runtime/types.py,sha256=_ssxNE2Lq5782n9_Ihs-u_6ybCCy8XaL6iYbE35tBug,5057
13
+ continuo_python_runtime/contract/__init__.py,sha256=S79x6j_P0CmaSuCGzeWoK1U0507KvD2MZXwQl9DFWMM,37
14
+ continuo_python_runtime/contract/loader.py,sha256=oUer1sbaxhUMNQVUuqO5j5vbQCrgwuHOWJzGe0MHSLg,16973
15
+ continuo_python_runtime/contract/merge.py,sha256=36awQV8cHGcQJv6XJGC_mC5MuyRS5-7XTVTLw0sjMgE,6903
16
+ continuo_python_runtime/contract/model.py,sha256=L7GwSYKrh6Z0J1c1zWFHQw8GzYUUzzWycuBw5Qq5Dfg,1017
17
+ continuo_python_runtime/contract/paths.py,sha256=SRn70pJkDAIpXw0IcMU2iNKPKvct8w_SZP2ec-lV91M,1458
18
+ continuo_python_runtime/csv_readers/__init__.py,sha256=9TKvjC37C77uRaJQZuO0--zVj2OvuNwpJE6nWx7j2S8,806
19
+ continuo_python_runtime/csv_readers/https.py,sha256=tMJ9k5F0b9SAydpon0ZlOznfjvjhF2BBLltyVmvRneE,4998
20
+ continuo_python_runtime/csv_readers/s3.py,sha256=zmvHs3TxlN2alPKrdaHzPyREmkve2nBiAsHRXXe1mE0,2246
21
+ continuo_python_runtime/validation/__init__.py,sha256=hz5oGXsoaUoygR0_pBby9PS6cEfrre518EE0HGB58eE,79
22
+ continuo_python_runtime/validation/runner.py,sha256=AwYbiFrQubxSm1vfxJJpnp7xfekviUW2anw4LRQQ4Rc,13062
23
+ continuo_python_runtime/validation/s3.py,sha256=q8nRrT9R7L1-M4dSlDnkL7CUJMPmq86MHY_UKYFlEf8,1785
24
+ continuo_python_runtime-0.4.1.dist-info/METADATA,sha256=EjG8OIYrN1O51KOsUxIA0Ln6zpsWdaLJheUcCaRkqD8,18811
25
+ continuo_python_runtime-0.4.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
26
+ continuo_python_runtime-0.4.1.dist-info/entry_points.txt,sha256=ZvIwSm9f9B9ZmZMf6CyF5Zt4QONn8rqaIk-u-B1CF2Y,70
27
+ continuo_python_runtime-0.4.1.dist-info/licenses/LICENSE,sha256=hfXfFCk-8gnps4YvHA6bjCNaSYqOrq2gAdqkXYycAQc,11345
28
+ continuo_python_runtime-0.4.1.dist-info/licenses/NOTICE,sha256=ykgYyQkAMx3uas90FZHzt0aTca9IgJQCLpZ4d74Pl3U,465
29
+ continuo_python_runtime-0.4.1.dist-info/RECORD,,
@@ -1,24 +0,0 @@
1
- continuo_python_runtime/__init__.py,sha256=otNaXEPsTtRXJV5FLtDUsFO8VD4OC-MLAzwhtimHfOE,70
2
- continuo_python_runtime/cli.py,sha256=X4JBC4FDpgFOeDdtBbeX9GTq1XTuJCghDpemlR_CPvE,5925
3
- continuo_python_runtime/closure.py,sha256=6e-IAQp8Lieb0bGKP1F6JE4TgVBJ_COTGXRUult2xeI,12991
4
- continuo_python_runtime/conform.py,sha256=a9CKCA-gT9Zerzr6c5H8aByTFOUd6uBMo02sNKSDnXg,9199
5
- continuo_python_runtime/context.py,sha256=K-9XXYlQ4OZ250rCOq6EJNq7fg_D5sK6tOcu7DLZfVg,1856
6
- continuo_python_runtime/errors.py,sha256=nfZzfBhQ2ajm7USAWKKaGY2uUlh8eNjNPNYYuaOJgZE,1032
7
- continuo_python_runtime/harness.py,sha256=1b_ze2_AJ9vncY-yj88TgJylVzKwGw1-idxHoaYAymE,12101
8
- continuo_python_runtime/hashing.py,sha256=70iXQb0CmjGqniTPofXY6WapydQ7aK2XOk5dzpEf-zk,3354
9
- continuo_python_runtime/lint.py,sha256=M59UtCHHVQnJKNolIkvkRk_AgGAFvxD9zkKAMMK-7GI,12984
10
- continuo_python_runtime/types.py,sha256=_ssxNE2Lq5782n9_Ihs-u_6ybCCy8XaL6iYbE35tBug,5057
11
- continuo_python_runtime/contract/__init__.py,sha256=S79x6j_P0CmaSuCGzeWoK1U0507KvD2MZXwQl9DFWMM,37
12
- continuo_python_runtime/contract/loader.py,sha256=WeKngAe6VH-WxgRTVdCERPMdsGBszJM9RkanUTTs9-w,15611
13
- continuo_python_runtime/contract/merge.py,sha256=XCCeE2vJUm0KA60d2XsHpjRh5fIdVfFu_HsBzEyUWow,6505
14
- continuo_python_runtime/contract/model.py,sha256=2hKI04M0WwvTkOy9eDGl2dRZgY3bAuJYktAG-ICI-AU,936
15
- continuo_python_runtime/contract/paths.py,sha256=SRn70pJkDAIpXw0IcMU2iNKPKvct8w_SZP2ec-lV91M,1458
16
- continuo_python_runtime/validation/__init__.py,sha256=hz5oGXsoaUoygR0_pBby9PS6cEfrre518EE0HGB58eE,79
17
- continuo_python_runtime/validation/runner.py,sha256=zHs5CR3Wr9512sh6eY9hpN8NG3weJPYUFRDPJf2BNnc,10875
18
- continuo_python_runtime/validation/s3.py,sha256=q8nRrT9R7L1-M4dSlDnkL7CUJMPmq86MHY_UKYFlEf8,1785
19
- continuo_python_runtime-0.3.1.dist-info/METADATA,sha256=CbkAD-RaAJiq31POHm89uDUFgQEYQMN2DCCfwhAJqVo,15416
20
- continuo_python_runtime-0.3.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
21
- continuo_python_runtime-0.3.1.dist-info/entry_points.txt,sha256=ZvIwSm9f9B9ZmZMf6CyF5Zt4QONn8rqaIk-u-B1CF2Y,70
22
- continuo_python_runtime-0.3.1.dist-info/licenses/LICENSE,sha256=hfXfFCk-8gnps4YvHA6bjCNaSYqOrq2gAdqkXYycAQc,11345
23
- continuo_python_runtime-0.3.1.dist-info/licenses/NOTICE,sha256=ykgYyQkAMx3uas90FZHzt0aTca9IgJQCLpZ4d74Pl3U,465
24
- continuo_python_runtime-0.3.1.dist-info/RECORD,,