continuo-python-runtime 0.1.0__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.
- continuo_python_runtime/__init__.py +1 -0
- continuo_python_runtime/cli.py +142 -0
- continuo_python_runtime/conform.py +220 -0
- continuo_python_runtime/context.py +62 -0
- continuo_python_runtime/contract/__init__.py +1 -0
- continuo_python_runtime/contract/loader.py +363 -0
- continuo_python_runtime/contract/merge.py +80 -0
- continuo_python_runtime/contract/model.py +39 -0
- continuo_python_runtime/contract/paths.py +38 -0
- continuo_python_runtime/errors.py +36 -0
- continuo_python_runtime/harness.py +208 -0
- continuo_python_runtime/hashing.py +34 -0
- continuo_python_runtime/lint.py +295 -0
- continuo_python_runtime/types.py +208 -0
- continuo_python_runtime-0.1.0.dist-info/METADATA +193 -0
- continuo_python_runtime-0.1.0.dist-info/RECORD +18 -0
- continuo_python_runtime-0.1.0.dist-info/WHEEL +4 -0
- continuo_python_runtime-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runtime harness and contract tooling for Continuo python nodes."""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""CLI for continuo-runtime: validate, merge, hash, lint, and run contract workflows."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from continuo_python_runtime.contract.loader import load_contract_dir
|
|
10
|
+
from continuo_python_runtime.contract.merge import (
|
|
11
|
+
build_wire_contract,
|
|
12
|
+
write_wire_contract,
|
|
13
|
+
)
|
|
14
|
+
from continuo_python_runtime.errors import HarnessError
|
|
15
|
+
from continuo_python_runtime.lint import lint_paths
|
|
16
|
+
from continuo_python_runtime import harness
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
"""Main CLI entry point with validate, merge, and hash subcommands."""
|
|
23
|
+
# Configure logging once
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
stream=sys.stderr,
|
|
26
|
+
level=logging.INFO,
|
|
27
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
parser = argparse.ArgumentParser(prog="continuo-runtime")
|
|
31
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
32
|
+
|
|
33
|
+
# validate subcommand
|
|
34
|
+
validate_parser = subparsers.add_parser(
|
|
35
|
+
"validate", help="Validate contract directory"
|
|
36
|
+
)
|
|
37
|
+
validate_parser.add_argument("contract_dir", help="Path to contract directory")
|
|
38
|
+
|
|
39
|
+
# merge subcommand
|
|
40
|
+
merge_parser = subparsers.add_parser(
|
|
41
|
+
"merge", help="Merge contracts into wire contract"
|
|
42
|
+
)
|
|
43
|
+
merge_parser.add_argument("contract_dir", help="Path to contract directory")
|
|
44
|
+
merge_parser.add_argument("--service", required=True, help="Service name")
|
|
45
|
+
merge_parser.add_argument("--repo-root", required=True, help="Repository root path")
|
|
46
|
+
merge_parser.add_argument("--out", required=True, help="Output file path")
|
|
47
|
+
|
|
48
|
+
# hash subcommand
|
|
49
|
+
hash_parser = subparsers.add_parser(
|
|
50
|
+
"hash", help="Print relation and hash for each node"
|
|
51
|
+
)
|
|
52
|
+
hash_parser.add_argument("contract_dir", help="Path to contract directory")
|
|
53
|
+
hash_parser.add_argument(
|
|
54
|
+
"--repo-root", required=True, help="Repository root path"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# lint subcommand
|
|
58
|
+
lint_parser = subparsers.add_parser(
|
|
59
|
+
"lint", help="Lint Python scripts for forbidden imports and SQL literals"
|
|
60
|
+
)
|
|
61
|
+
lint_parser.add_argument("path", nargs="+", help="Paths to lint (files or directories)")
|
|
62
|
+
|
|
63
|
+
# run subcommand
|
|
64
|
+
subparsers.add_parser(
|
|
65
|
+
"run", help="Execute a node script (container entrypoint)"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
args = parser.parse_args(argv)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if args.command == "validate":
|
|
72
|
+
return cmd_validate(args.contract_dir)
|
|
73
|
+
elif args.command == "merge":
|
|
74
|
+
return cmd_merge(args.contract_dir, args.service, args.repo_root, args.out)
|
|
75
|
+
elif args.command == "hash":
|
|
76
|
+
return cmd_hash(args.contract_dir, args.repo_root)
|
|
77
|
+
elif args.command == "lint":
|
|
78
|
+
return cmd_lint(args.path)
|
|
79
|
+
elif args.command == "run":
|
|
80
|
+
return cmd_run()
|
|
81
|
+
except HarnessError as exc:
|
|
82
|
+
logger.error("%s", exc)
|
|
83
|
+
return 1
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
logger.error("%s", exc)
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_validate(contract_dir: str) -> int:
|
|
92
|
+
"""Validate contracts in a directory."""
|
|
93
|
+
load_contract_dir(Path(contract_dir))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_merge(contract_dir: str, service: str, repo_root: str, out: str) -> int:
|
|
98
|
+
"""Merge contracts into a wire contract file."""
|
|
99
|
+
doc = build_wire_contract(Path(contract_dir), Path(repo_root), service)
|
|
100
|
+
write_wire_contract(doc, Path(out))
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_hash(contract_dir: str, repo_root: str) -> int:
|
|
105
|
+
"""Print relation and content hash for each node.
|
|
106
|
+
|
|
107
|
+
Reuses build_wire_contract for consistent hashing and error handling.
|
|
108
|
+
Service value is irrelevant to per-node hashes since entries don't include it.
|
|
109
|
+
"""
|
|
110
|
+
# Reuse build_wire_contract for consistent hashing and error handling
|
|
111
|
+
doc = build_wire_contract(Path(contract_dir), Path(repo_root), service="_hash")
|
|
112
|
+
|
|
113
|
+
# Print relation\thash from wire contract nodes (already sorted by relation)
|
|
114
|
+
for node_entry in doc["nodes"]:
|
|
115
|
+
relation = f"{node_entry['schema']}.{node_entry['table']}"
|
|
116
|
+
hash_value = node_entry["content_hash"]
|
|
117
|
+
print(f"{relation}\t{hash_value}")
|
|
118
|
+
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_lint(paths: list[str]) -> int:
|
|
123
|
+
"""Lint Python scripts for forbidden imports and SQL literals.
|
|
124
|
+
|
|
125
|
+
Reports violations to stderr via logging and exits with 1 if any are found.
|
|
126
|
+
"""
|
|
127
|
+
violations = lint_paths([Path(p) for p in paths])
|
|
128
|
+
if violations:
|
|
129
|
+
for violation in violations:
|
|
130
|
+
logger.error("%s", violation)
|
|
131
|
+
return 1
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_run() -> int:
|
|
136
|
+
"""Execute a node script reading configuration from os.environ.
|
|
137
|
+
|
|
138
|
+
This is the container entrypoint: reads NODE_ID, TABLE_NAME, TARGET_SCHEMA,
|
|
139
|
+
CONTRACT_DIR, and APP_ROOT from environment, then calls harness.run_node()
|
|
140
|
+
which produces exactly one sentinel result block.
|
|
141
|
+
"""
|
|
142
|
+
return harness.run_node(os.environ)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Strict output-shape enforcer.
|
|
2
|
+
|
|
3
|
+
``conform()`` guards every python node's write: it enforces the declared
|
|
4
|
+
column set (extra/missing columns), reorders columns to the declared order,
|
|
5
|
+
performs a *strict* cast to the declared Arrow schema (raising rather than
|
|
6
|
+
silently truncating or corrupting data), enforces not-null constraints, and
|
|
7
|
+
checks VARCHAR/CHAR length limits.
|
|
8
|
+
|
|
9
|
+
``to_arrow()`` normalizes whatever a node's ``run()`` returned into a
|
|
10
|
+
``pyarrow.Table``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pyarrow as pa # type: ignore[import-untyped]
|
|
21
|
+
import pyarrow.compute as pc # type: ignore[import-untyped]
|
|
22
|
+
|
|
23
|
+
from continuo_python_runtime.contract.model import Column
|
|
24
|
+
from continuo_python_runtime.errors import ConformError, ScriptError
|
|
25
|
+
from continuo_python_runtime.types import arrow_type, parse_sql_type
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("continuo_python_runtime.conform")
|
|
28
|
+
|
|
29
|
+
# Cast pairs that pyarrow's `safe=True` cast does NOT reject, but that
|
|
30
|
+
# silently corrupt data rather than raising:
|
|
31
|
+
# - floating -> decimal128 rounds to the target scale (e.g. 1.234 -> 1.23)
|
|
32
|
+
# - non-boolean -> boolean coerces truthiness (e.g. 2 -> True) instead of
|
|
33
|
+
# requiring an actual boolean value
|
|
34
|
+
# - timestamp -> date32 truncates the time-of-day component
|
|
35
|
+
# These must be rejected before Arrow ever sees the cast. Safe source types
|
|
36
|
+
# for a decimal target remain integer, decimal, and string (numeric strings
|
|
37
|
+
# still go through Arrow's own overflow/parse checks).
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _reject_lossy_cast(name: str, source: pa.DataType, target: pa.DataType) -> None:
|
|
41
|
+
"""Raise ConformError for source/target type pairs that pyarrow's
|
|
42
|
+
``safe=True`` cast accepts but that are not value-lossless.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
name: The column name (for the error message).
|
|
46
|
+
source: The incoming Arrow type.
|
|
47
|
+
target: The declared target Arrow type.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ConformError: If the pair is a known-lossy cast.
|
|
51
|
+
"""
|
|
52
|
+
if pa.types.is_decimal(target) and pa.types.is_floating(source):
|
|
53
|
+
raise ConformError(
|
|
54
|
+
f"column {name}: cannot cast {source} to {target}; casting a "
|
|
55
|
+
"floating-point column to a decimal type is not lossless "
|
|
56
|
+
"(pyarrow silently rounds to the target scale) -- source must "
|
|
57
|
+
"be an integer, decimal, or string column"
|
|
58
|
+
)
|
|
59
|
+
if pa.types.is_boolean(target) and not pa.types.is_boolean(source):
|
|
60
|
+
raise ConformError(
|
|
61
|
+
f"column {name}: cannot cast {source} to {target}; casting a "
|
|
62
|
+
"non-boolean column to BOOLEAN is not lossless (pyarrow "
|
|
63
|
+
"coerces truthiness instead of requiring an actual boolean "
|
|
64
|
+
"value)"
|
|
65
|
+
)
|
|
66
|
+
if pa.types.is_date(target) and pa.types.is_timestamp(source):
|
|
67
|
+
raise ConformError(
|
|
68
|
+
f"column {name}: cannot cast {source} to {target}; casting a "
|
|
69
|
+
"TIMESTAMP column to DATE is not lossless (pyarrow silently "
|
|
70
|
+
"truncates the time-of-day component)"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def to_arrow(obj: Any) -> pa.Table:
|
|
75
|
+
"""Normalize a node's ``run()`` return value into a ``pyarrow.Table``.
|
|
76
|
+
|
|
77
|
+
Accepts, in order:
|
|
78
|
+
- a ``pyarrow.Table`` (returned as-is, same object identity);
|
|
79
|
+
- a pandas ``DataFrame`` (detected by duck-typing on module/class name,
|
|
80
|
+
so pandas is never imported unless the object actually looks like a
|
|
81
|
+
DataFrame), converted via ``pa.Table.from_pandas(obj,
|
|
82
|
+
preserve_index=False)``. Checked *before* the generic Arrow C stream
|
|
83
|
+
branch below because pandas 3.x DataFrames also implement
|
|
84
|
+
``__arrow_c_stream__``, and exporting through that protocol directly
|
|
85
|
+
can leak the DataFrame's index as an extra column;
|
|
86
|
+
- any other object implementing the Arrow C stream protocol
|
|
87
|
+
(``__arrow_c_stream__``), converted via ``pa.table(obj)``.
|
|
88
|
+
|
|
89
|
+
Anything else raises ``ScriptError``.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
obj: The value returned by a node's ``run()`` function.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
A ``pyarrow.Table``.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
ScriptError: If ``obj`` is not Arrow-convertible.
|
|
99
|
+
"""
|
|
100
|
+
if isinstance(obj, pa.Table):
|
|
101
|
+
return obj
|
|
102
|
+
|
|
103
|
+
# pandas is checked (by duck-typing on module/class name, so pandas is
|
|
104
|
+
# never imported unless the object actually looks like a DataFrame)
|
|
105
|
+
# *before* the generic __arrow_c_stream__ branch below. pandas 3.x
|
|
106
|
+
# DataFrames implement __arrow_c_stream__ themselves, but exporting
|
|
107
|
+
# through that protocol can surface the DataFrame's index as an extra
|
|
108
|
+
# column (e.g. "__index_level_0__"), which the declared-schema/
|
|
109
|
+
# extra-column policy would then reject. Routing pandas DataFrames
|
|
110
|
+
# through `from_pandas(..., preserve_index=False)` avoids that.
|
|
111
|
+
obj_type = type(obj)
|
|
112
|
+
if obj_type.__module__.split(".")[0] == "pandas" and obj_type.__name__ == "DataFrame":
|
|
113
|
+
try:
|
|
114
|
+
import pandas # type: ignore[import-untyped] # noqa: F401
|
|
115
|
+
except ImportError:
|
|
116
|
+
pass
|
|
117
|
+
else:
|
|
118
|
+
return pa.Table.from_pandas(obj, preserve_index=False)
|
|
119
|
+
|
|
120
|
+
if hasattr(obj, "__arrow_c_stream__"):
|
|
121
|
+
return pa.table(obj)
|
|
122
|
+
|
|
123
|
+
raise ScriptError(
|
|
124
|
+
f"run() returned {type(obj).__name__}; expected an Arrow-convertible value"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def conform(
|
|
129
|
+
table: pa.Table, columns: Sequence[Column], extra_columns: str = "raise"
|
|
130
|
+
) -> pa.Table:
|
|
131
|
+
"""Enforce the declared output shape on an Arrow table.
|
|
132
|
+
|
|
133
|
+
Order of checks:
|
|
134
|
+
0. Duplicate column names in the input table (always raises).
|
|
135
|
+
1. Extra-column policy (raise or warn+drop).
|
|
136
|
+
2. Missing columns (always raises).
|
|
137
|
+
3. Select columns in declared order.
|
|
138
|
+
4. Strict cast to the declared Arrow schema.
|
|
139
|
+
5. Not-null enforcement.
|
|
140
|
+
6. VARCHAR/CHAR length enforcement.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
table: The Arrow table produced by a node's ``run()``.
|
|
144
|
+
columns: The declared output columns, in declared order.
|
|
145
|
+
extra_columns: ``"raise"`` (default) to fail on undeclared columns,
|
|
146
|
+
or ``"warn"`` to drop them with a logged warning.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
A new ``pyarrow.Table`` matching the declared schema, column order,
|
|
150
|
+
and constraints.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ConformError: On any structural mismatch, strict-cast failure,
|
|
154
|
+
null violation, or VARCHAR/CHAR overflow.
|
|
155
|
+
ValueError: If ``extra_columns`` is not ``"raise"`` or ``"warn"``.
|
|
156
|
+
"""
|
|
157
|
+
if extra_columns not in ("raise", "warn"):
|
|
158
|
+
raise ValueError(f"extra_columns must be 'raise' or 'warn', got {extra_columns!r}")
|
|
159
|
+
|
|
160
|
+
declared = [c.name for c in columns]
|
|
161
|
+
|
|
162
|
+
counts = Counter(table.column_names)
|
|
163
|
+
dups = sorted(name for name, count in counts.items() if count > 1)
|
|
164
|
+
if dups:
|
|
165
|
+
raise ConformError(f"dataframe has duplicate column(s): {dups}")
|
|
166
|
+
|
|
167
|
+
extra = [n for n in table.column_names if n not in declared]
|
|
168
|
+
if extra:
|
|
169
|
+
if extra_columns == "raise":
|
|
170
|
+
raise ConformError(
|
|
171
|
+
f"dataframe has undeclared column(s): {extra}; declared: {declared}"
|
|
172
|
+
)
|
|
173
|
+
logger.warning("conform: dropping undeclared column(s) %s", extra)
|
|
174
|
+
|
|
175
|
+
missing = [n for n in declared if n not in table.column_names]
|
|
176
|
+
if missing:
|
|
177
|
+
raise ConformError(f"dataframe is missing column(s): {missing}")
|
|
178
|
+
|
|
179
|
+
table = table.select(declared)
|
|
180
|
+
|
|
181
|
+
# The cast target schema is always nullable here: nullability is a
|
|
182
|
+
# *value* constraint (checked below against actual data), not something
|
|
183
|
+
# we want pyarrow's cast to enforce structurally — a non-nullable target
|
|
184
|
+
# field makes `cast()` raise ValueError on any null, before we get a
|
|
185
|
+
# chance to report it as a ConformError with a useful message.
|
|
186
|
+
target = pa.schema(
|
|
187
|
+
[pa.field(c.name, arrow_type(parse_sql_type(c.type)), nullable=True) for c in columns]
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
for field in target:
|
|
191
|
+
_reject_lossy_cast(field.name, table.schema.field(field.name).type, field.type)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
table = table.cast(target, safe=True)
|
|
195
|
+
except (pa.ArrowInvalid, pa.ArrowNotImplementedError, pa.ArrowTypeError, ValueError) as exc:
|
|
196
|
+
raise ConformError(f"strict cast to declared schema failed: {exc}") from exc
|
|
197
|
+
|
|
198
|
+
for col in columns:
|
|
199
|
+
if not col.nullable and table.column(col.name).null_count:
|
|
200
|
+
raise ConformError(
|
|
201
|
+
f"column {col.name} is declared nullable: false but contains nulls"
|
|
202
|
+
)
|
|
203
|
+
sql_t = parse_sql_type(col.type)
|
|
204
|
+
if sql_t.length is not None:
|
|
205
|
+
max_len = pc.max(pc.utf8_length(table.column(col.name))).as_py()
|
|
206
|
+
if max_len is not None and max_len > sql_t.length:
|
|
207
|
+
raise ConformError(
|
|
208
|
+
f"column {col.name}: value length {max_len} exceeds "
|
|
209
|
+
f"{sql_t.base}({sql_t.length})"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Restore each field's declared nullability on the returned schema. This
|
|
213
|
+
# is safe now: nulls have already been checked above against the
|
|
214
|
+
# declared `nullable: false` constraints, so re-casting to a
|
|
215
|
+
# non-nullable field here cannot raise on data we haven't already
|
|
216
|
+
# validated.
|
|
217
|
+
declared_schema = pa.schema(
|
|
218
|
+
[pa.field(c.name, arrow_type(parse_sql_type(c.type)), nullable=c.nullable) for c in columns]
|
|
219
|
+
)
|
|
220
|
+
return table.cast(declared_schema)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Runtime context for executing node reads."""
|
|
2
|
+
|
|
3
|
+
import types
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import pyarrow as pa # type: ignore[import-untyped]
|
|
8
|
+
|
|
9
|
+
from continuo_python_runtime.contract.model import Node
|
|
10
|
+
from continuo_python_runtime.errors import ReadError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from continuo_validation_contract.port import RuntimeAdapter # type: ignore[import-untyped]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RunContext:
|
|
17
|
+
"""Manages declared read access for a node execution."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, node: Node, adapter: "RuntimeAdapter") -> None:
|
|
20
|
+
"""Initialize context with a node and adapter.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
node: The node definition with declared reads.
|
|
24
|
+
adapter: Duck-typed adapter with .fetch(sql) method.
|
|
25
|
+
"""
|
|
26
|
+
self._fetch: Callable[[str], pa.Table] = adapter.fetch
|
|
27
|
+
self._reads: types.MappingProxyType[str, str] = types.MappingProxyType(
|
|
28
|
+
dict(node.reads)
|
|
29
|
+
)
|
|
30
|
+
self._memo: dict[str, pa.Table] = {}
|
|
31
|
+
|
|
32
|
+
def read(self, name: str) -> pa.Table:
|
|
33
|
+
"""Fetch a declared read by name, memoized.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
name: The name of the declared read.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A PyArrow table.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ReadError: If name is not declared, or if adapter.fetch() fails.
|
|
43
|
+
"""
|
|
44
|
+
if name not in self._reads:
|
|
45
|
+
declared = sorted(self._reads.keys())
|
|
46
|
+
raise ReadError(
|
|
47
|
+
f"undeclared read {name!r}, declared: {declared}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if name in self._memo:
|
|
51
|
+
return self._memo[name]
|
|
52
|
+
|
|
53
|
+
sql = self._reads[name]
|
|
54
|
+
try:
|
|
55
|
+
result = self._fetch(sql)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
raise ReadError(
|
|
58
|
+
f"declared read {name!r} failed: {exc}"
|
|
59
|
+
) from exc
|
|
60
|
+
|
|
61
|
+
self._memo[name] = result
|
|
62
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Contract model and validation."""
|