continuo-python-runtime 0.1.0__py3-none-any.whl → 0.3.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/cli.py +38 -9
- continuo_python_runtime/closure.py +268 -0
- continuo_python_runtime/context.py +2 -2
- continuo_python_runtime/contract/loader.py +155 -129
- continuo_python_runtime/contract/merge.py +86 -9
- continuo_python_runtime/contract/model.py +3 -1
- continuo_python_runtime/harness.py +97 -10
- continuo_python_runtime/hashing.py +78 -23
- continuo_python_runtime/lint.py +40 -26
- continuo_python_runtime/types.py +32 -96
- continuo_python_runtime/validation/__init__.py +1 -0
- continuo_python_runtime/validation/runner.py +245 -0
- continuo_python_runtime/validation/s3.py +45 -0
- {continuo_python_runtime-0.1.0.dist-info → continuo_python_runtime-0.3.0.dist-info}/METADATA +108 -43
- continuo_python_runtime-0.3.0.dist-info/RECORD +22 -0
- {continuo_python_runtime-0.1.0.dist-info → continuo_python_runtime-0.3.0.dist-info}/WHEEL +1 -1
- continuo_python_runtime-0.1.0.dist-info/RECORD +0 -18
- {continuo_python_runtime-0.1.0.dist-info → continuo_python_runtime-0.3.0.dist-info}/entry_points.txt +0 -0
continuo_python_runtime/cli.py
CHANGED
|
@@ -30,11 +30,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
30
30
|
parser = argparse.ArgumentParser(prog="continuo-runtime")
|
|
31
31
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
32
32
|
|
|
33
|
+
dialect_help = (
|
|
34
|
+
"sqlglot dialect the reads are authored in (e.g. postgres, trino); "
|
|
35
|
+
"defaults to sqlglot's dialect-neutral parser."
|
|
36
|
+
)
|
|
37
|
+
|
|
33
38
|
# validate subcommand
|
|
34
39
|
validate_parser = subparsers.add_parser(
|
|
35
40
|
"validate", help="Validate contract directory"
|
|
36
41
|
)
|
|
37
42
|
validate_parser.add_argument("contract_dir", help="Path to contract directory")
|
|
43
|
+
validate_parser.add_argument("--dialect", default=None, help=dialect_help)
|
|
38
44
|
|
|
39
45
|
# merge subcommand
|
|
40
46
|
merge_parser = subparsers.add_parser(
|
|
@@ -44,6 +50,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
44
50
|
merge_parser.add_argument("--service", required=True, help="Service name")
|
|
45
51
|
merge_parser.add_argument("--repo-root", required=True, help="Repository root path")
|
|
46
52
|
merge_parser.add_argument("--out", required=True, help="Output file path")
|
|
53
|
+
merge_parser.add_argument("--dialect", default=None, help=dialect_help)
|
|
47
54
|
|
|
48
55
|
# hash subcommand
|
|
49
56
|
hash_parser = subparsers.add_parser(
|
|
@@ -53,6 +60,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
53
60
|
hash_parser.add_argument(
|
|
54
61
|
"--repo-root", required=True, help="Repository root path"
|
|
55
62
|
)
|
|
63
|
+
hash_parser.add_argument("--dialect", default=None, help=dialect_help)
|
|
56
64
|
|
|
57
65
|
# lint subcommand
|
|
58
66
|
lint_parser = subparsers.add_parser(
|
|
@@ -65,19 +73,32 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
65
73
|
"run", help="Execute a node script (container entrypoint)"
|
|
66
74
|
)
|
|
67
75
|
|
|
76
|
+
# validation-op subcommand
|
|
77
|
+
subparsers.add_parser(
|
|
78
|
+
"validation-op",
|
|
79
|
+
help="Execute one blue/green validation operation (container command; "
|
|
80
|
+
"driven by VALIDATION_OP and the engine Secret env)",
|
|
81
|
+
)
|
|
82
|
+
|
|
68
83
|
args = parser.parse_args(argv)
|
|
69
84
|
|
|
70
85
|
try:
|
|
71
86
|
if args.command == "validate":
|
|
72
|
-
return cmd_validate(args.contract_dir)
|
|
87
|
+
return cmd_validate(args.contract_dir, args.dialect)
|
|
73
88
|
elif args.command == "merge":
|
|
74
|
-
return cmd_merge(
|
|
89
|
+
return cmd_merge(
|
|
90
|
+
args.contract_dir, args.service, args.repo_root, args.out, args.dialect
|
|
91
|
+
)
|
|
75
92
|
elif args.command == "hash":
|
|
76
|
-
return cmd_hash(args.contract_dir, args.repo_root)
|
|
93
|
+
return cmd_hash(args.contract_dir, args.repo_root, args.dialect)
|
|
77
94
|
elif args.command == "lint":
|
|
78
95
|
return cmd_lint(args.path)
|
|
79
96
|
elif args.command == "run":
|
|
80
97
|
return cmd_run()
|
|
98
|
+
elif args.command == "validation-op":
|
|
99
|
+
from continuo_python_runtime.validation.runner import main as validation_op_main
|
|
100
|
+
validation_op_main()
|
|
101
|
+
return 0
|
|
81
102
|
except HarnessError as exc:
|
|
82
103
|
logger.error("%s", exc)
|
|
83
104
|
return 1
|
|
@@ -88,27 +109,35 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
88
109
|
return 0
|
|
89
110
|
|
|
90
111
|
|
|
91
|
-
def cmd_validate(contract_dir: str) -> int:
|
|
112
|
+
def cmd_validate(contract_dir: str, dialect: str | None = None) -> int:
|
|
92
113
|
"""Validate contracts in a directory."""
|
|
93
|
-
load_contract_dir(Path(contract_dir))
|
|
114
|
+
load_contract_dir(Path(contract_dir), dialect=dialect)
|
|
94
115
|
return 0
|
|
95
116
|
|
|
96
117
|
|
|
97
|
-
def cmd_merge(
|
|
118
|
+
def cmd_merge(
|
|
119
|
+
contract_dir: str,
|
|
120
|
+
service: str,
|
|
121
|
+
repo_root: str,
|
|
122
|
+
out: str,
|
|
123
|
+
dialect: str | None = None,
|
|
124
|
+
) -> int:
|
|
98
125
|
"""Merge contracts into a wire contract file."""
|
|
99
|
-
doc = build_wire_contract(Path(contract_dir), Path(repo_root), service)
|
|
126
|
+
doc = build_wire_contract(Path(contract_dir), Path(repo_root), service, dialect=dialect)
|
|
100
127
|
write_wire_contract(doc, Path(out))
|
|
101
128
|
return 0
|
|
102
129
|
|
|
103
130
|
|
|
104
|
-
def cmd_hash(contract_dir: str, repo_root: str) -> int:
|
|
131
|
+
def cmd_hash(contract_dir: str, repo_root: str, dialect: str | None = None) -> int:
|
|
105
132
|
"""Print relation and content hash for each node.
|
|
106
133
|
|
|
107
134
|
Reuses build_wire_contract for consistent hashing and error handling.
|
|
108
135
|
Service value is irrelevant to per-node hashes since entries don't include it.
|
|
109
136
|
"""
|
|
110
137
|
# Reuse build_wire_contract for consistent hashing and error handling
|
|
111
|
-
doc = build_wire_contract(
|
|
138
|
+
doc = build_wire_contract(
|
|
139
|
+
Path(contract_dir), Path(repo_root), service="_hash", dialect=dialect
|
|
140
|
+
)
|
|
112
141
|
|
|
113
142
|
# Print relation\thash from wire contract nodes (already sorted by relation)
|
|
114
143
|
for node_entry in doc["nodes"]:
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Static in-repo import-closure resolver.
|
|
2
|
+
|
|
3
|
+
CI's ``content_hash`` is the sole change detector Continuo uses to decide
|
|
4
|
+
whether a node needs revalidation. Until this module existed, that hash
|
|
5
|
+
covered only the node's own script file, so a byte edit to a shared helper
|
|
6
|
+
module the script imports changed nothing — the node kept running against a
|
|
7
|
+
stale fingerprint in production. ``resolve_closure`` closes that gap: it
|
|
8
|
+
returns the transitive set of in-repo Python files a script reaches through
|
|
9
|
+
its ``import`` statements, so a later stage can fold their bytes into the
|
|
10
|
+
hash too.
|
|
11
|
+
|
|
12
|
+
That framing decides every ambiguous call in the algorithm below:
|
|
13
|
+
under-inclusion is a correctness bug (a stale node silently running in
|
|
14
|
+
production), while over-inclusion is merely a spurious revalidation.
|
|
15
|
+
|
|
16
|
+
- Search roots (rule 4) are the fixed, ordered pair ``(repo_root,
|
|
17
|
+
script_dir)`` — ``script_dir`` computed once from the seed script and
|
|
18
|
+
reused, unchanged, for every resolution in the traversal, never the
|
|
19
|
+
current file's own directory. This mirrors
|
|
20
|
+
:func:`continuo_python_runtime.harness.ensure_import_paths` exactly, which
|
|
21
|
+
puts that same pair on ``sys.path`` for the process lifetime: the static
|
|
22
|
+
model and the runtime model are provably the same list. A module importing
|
|
23
|
+
a sibling that is on neither root (e.g. ``lib/shared.py`` doing ``import
|
|
24
|
+
sibling`` for a file at ``lib/sibling.py``, when the script itself lives
|
|
25
|
+
elsewhere) raises ``ImportError`` at run time, so excluding that sibling
|
|
26
|
+
from the closure cannot hide a stale-node bug — that node cannot run at
|
|
27
|
+
all. When the script lives at ``repo_root`` itself, the pair collapses to
|
|
28
|
+
a single root so the same candidate is not probed twice.
|
|
29
|
+
- Within a search root, the package form (``<root>/a/b/c/__init__.py``) is
|
|
30
|
+
tried before the module-file form (``<root>/a/b/c.py``), matching
|
|
31
|
+
Python's own lookup order: when both exist, ``import a.b.c`` binds the
|
|
32
|
+
package, and the module file of the same name is never executed.
|
|
33
|
+
- ``from pkg import name`` (rule 3) is expanded to include both ``pkg`` and
|
|
34
|
+
``pkg.name`` as candidate dotted names, because ``name`` may be a
|
|
35
|
+
submodule (a file) rather than an attribute of ``pkg`` — the two are
|
|
36
|
+
indistinguishable from the import statement's syntax alone. The same
|
|
37
|
+
applies to relative imports: ``from . import name`` always includes the
|
|
38
|
+
bare enclosing package as a candidate too, not only when the import also
|
|
39
|
+
names a submodule (``from .mod import name``) — real Python executes the
|
|
40
|
+
package's ``__init__.py`` either way, so treating the two forms
|
|
41
|
+
asymmetrically would under-include exactly the file this module exists to
|
|
42
|
+
stop missing. This choice leans deliberately toward over-inclusion:
|
|
43
|
+
``name`` may turn out to be a plain attribute rather than a submodule, in
|
|
44
|
+
which case the extra candidate simply fails to resolve.
|
|
45
|
+
|
|
46
|
+
Dynamic-import constructs (``importlib``, ``builtins``, ``__builtins__``,
|
|
47
|
+
``__import__``, ``exec``, ``eval``, ``.import_module``) are rejected outright
|
|
48
|
+
rather than degrading to "resolve what we can": whatever a script imports
|
|
49
|
+
through one of those, this static analysis cannot see, so the hash could
|
|
50
|
+
never be trusted to reflect it.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import ast
|
|
56
|
+
from collections import deque
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
from continuo_python_runtime.errors import ContractError
|
|
60
|
+
|
|
61
|
+
_DYNAMIC_IMPORT_MODULES = frozenset({"importlib", "builtins"})
|
|
62
|
+
_DYNAMIC_IMPORT_NAMES = frozenset({"__import__", "exec", "eval", "__builtins__"})
|
|
63
|
+
_DYNAMIC_IMPORT_ATTRS = frozenset({"import_module", "__import__"})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dynamic_import_violations(tree: ast.AST) -> list[tuple[int, str]]:
|
|
67
|
+
"""Return `(lineno, construct)` for every dynamic-import construct in *tree*.
|
|
68
|
+
|
|
69
|
+
Flags, per AST node type:
|
|
70
|
+
|
|
71
|
+
- `ast.Import`: any alias whose root module (`name.split(".")[0]`) is
|
|
72
|
+
`importlib` or `builtins` — e.g. `import importlib.util`, `import
|
|
73
|
+
builtins`. `construct` is that root module name.
|
|
74
|
+
- `ast.ImportFrom`: if the root module is `importlib` or `builtins`,
|
|
75
|
+
one violation naming that module — and nothing else from the same
|
|
76
|
+
statement (one statement, one violation; its aliases are not also
|
|
77
|
+
inspected). Otherwise, any `alias.name` in `{"__import__", "exec",
|
|
78
|
+
"eval"}` is a violation named for that alias — this catches
|
|
79
|
+
re-exports of those names from a module that is neither `importlib`
|
|
80
|
+
nor `builtins` (e.g. `from somewhere import exec as e`), which no
|
|
81
|
+
other rule here would see.
|
|
82
|
+
- `ast.Name`: `id` in `{"__import__", "exec", "eval", "__builtins__"}` (a
|
|
83
|
+
bare reference or call). `__builtins__` is included because it is
|
|
84
|
+
injected into every module's globals by the harness's own loader
|
|
85
|
+
(`spec_from_file_location` + `exec_module`, see `harness.py`) as the
|
|
86
|
+
builtins *dict* — so `__builtins__['exec'](...)` and
|
|
87
|
+
`__builtins__['__import__'](...)` need no preceding `import builtins`
|
|
88
|
+
at all, and flagging the bare name catches every subscript form in one
|
|
89
|
+
rule.
|
|
90
|
+
- `ast.Attribute`: `attr` in `{"import_module", "__import__"}`.
|
|
91
|
+
Deliberately NOT `exec`/`eval`: those are legitimate method names on
|
|
92
|
+
arbitrary objects (pandas `DataFrame.eval`/`DataFrame.query`, used in
|
|
93
|
+
ordinary node scripts that return a dataframe), and flagging them here
|
|
94
|
+
would reject that legitimate user code. `builtins.exec` /
|
|
95
|
+
`builtins.eval` (attribute access on the `builtins` module after an
|
|
96
|
+
explicit `import builtins`) are still caught, because that `import` is
|
|
97
|
+
itself an `ast.Import` violation above — but `__builtins__['exec']`
|
|
98
|
+
needs no such import, which is exactly what the `ast.Name` rule above
|
|
99
|
+
exists to close.
|
|
100
|
+
|
|
101
|
+
`construct` is the offending name as written: "importlib", "builtins",
|
|
102
|
+
"__import__", "exec", "eval", "__builtins__", or "import_module". Sorted
|
|
103
|
+
by lineno, then construct.
|
|
104
|
+
"""
|
|
105
|
+
violations: list[tuple[int, str]] = []
|
|
106
|
+
for node in ast.walk(tree):
|
|
107
|
+
if isinstance(node, ast.Import):
|
|
108
|
+
for alias in node.names:
|
|
109
|
+
root = alias.name.split(".")[0]
|
|
110
|
+
if root in _DYNAMIC_IMPORT_MODULES:
|
|
111
|
+
violations.append((node.lineno, root))
|
|
112
|
+
elif isinstance(node, ast.ImportFrom):
|
|
113
|
+
from_root = node.module.split(".")[0] if node.module is not None else None
|
|
114
|
+
if from_root in _DYNAMIC_IMPORT_MODULES:
|
|
115
|
+
violations.append((node.lineno, from_root))
|
|
116
|
+
continue # one statement, one violation - aliases not also checked
|
|
117
|
+
for alias in node.names:
|
|
118
|
+
if alias.name in _DYNAMIC_IMPORT_NAMES:
|
|
119
|
+
violations.append((node.lineno, alias.name))
|
|
120
|
+
elif isinstance(node, ast.Name):
|
|
121
|
+
if node.id in _DYNAMIC_IMPORT_NAMES:
|
|
122
|
+
violations.append((node.lineno, node.id))
|
|
123
|
+
elif isinstance(node, ast.Attribute):
|
|
124
|
+
if node.attr in _DYNAMIC_IMPORT_ATTRS:
|
|
125
|
+
violations.append((node.lineno, node.attr))
|
|
126
|
+
return sorted(violations)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _package_components(importing_file: Path, repo_root: Path) -> tuple[str, ...]:
|
|
130
|
+
"""Dotted-path components of *importing_file*'s parent directory,
|
|
131
|
+
relative to *repo_root* (empty tuple when the file lives at the root)."""
|
|
132
|
+
rel_parent = importing_file.parent.relative_to(repo_root)
|
|
133
|
+
if rel_parent == Path("."):
|
|
134
|
+
return ()
|
|
135
|
+
return rel_parent.parts
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _names_from_import(node: ast.Import) -> list[str]:
|
|
139
|
+
return [alias.name for alias in node.names]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _names_from_import_from(
|
|
143
|
+
node: ast.ImportFrom, importing_file: Path, repo_root: Path
|
|
144
|
+
) -> list[str]:
|
|
145
|
+
if node.level == 0:
|
|
146
|
+
# level == 0 always carries a module per the grammar (`from X import Y`).
|
|
147
|
+
base = node.module
|
|
148
|
+
assert base is not None
|
|
149
|
+
return [base] + [f"{base}.{alias.name}" for alias in node.names]
|
|
150
|
+
|
|
151
|
+
components = _package_components(importing_file, repo_root)
|
|
152
|
+
walk_up = node.level - 1
|
|
153
|
+
if walk_up > len(components):
|
|
154
|
+
return [] # would escape repo_root - cannot name an in-repo file
|
|
155
|
+
|
|
156
|
+
prefix_parts = components[: len(components) - walk_up]
|
|
157
|
+
prefix = ".".join(prefix_parts)
|
|
158
|
+
base = ".".join(part for part in (prefix, node.module) if part)
|
|
159
|
+
|
|
160
|
+
# Real Python executes the package's __init__.py for `from . import name`
|
|
161
|
+
# regardless of whether `name` is a submodule file or just an attribute
|
|
162
|
+
# defined inside __init__.py - the two are indistinguishable from the
|
|
163
|
+
# import statement's syntax alone, so the bare prefix/base is always a
|
|
164
|
+
# candidate (symmetric with the level == 0 branch above), not only when
|
|
165
|
+
# node.module is also present. Missing __init__.py here would be
|
|
166
|
+
# under-inclusion (a stale node in production); including it when it was
|
|
167
|
+
# already reachable is a no-op, and including it spuriously costs at
|
|
168
|
+
# most one revalidation - see the module docstring.
|
|
169
|
+
names = [base] if base else []
|
|
170
|
+
names.extend(f"{base}.{alias.name}" if base else alias.name for alias in node.names)
|
|
171
|
+
return names
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _collect_import_names(tree: ast.AST, importing_file: Path, repo_root: Path) -> list[str]:
|
|
175
|
+
names: list[str] = []
|
|
176
|
+
for node in ast.walk(tree):
|
|
177
|
+
if isinstance(node, ast.Import):
|
|
178
|
+
names.extend(_names_from_import(node))
|
|
179
|
+
elif isinstance(node, ast.ImportFrom):
|
|
180
|
+
names.extend(_names_from_import_from(node, importing_file, repo_root))
|
|
181
|
+
return names
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _resolve_name(
|
|
185
|
+
name: str, search_roots: tuple[Path, ...]
|
|
186
|
+
) -> tuple[Path, Path, tuple[str, ...]] | None:
|
|
187
|
+
"""Resolve a dotted module name to (root, resolved file, name parts).
|
|
188
|
+
|
|
189
|
+
Tries each root in *search_roots*, in order, and for each, the package
|
|
190
|
+
form `<root>/a/b/c/__init__.py` before the module-file form
|
|
191
|
+
`<root>/a/b/c.py` - matching Python's own package-before-module lookup
|
|
192
|
+
order (when both exist, `import a.b.c` binds the package and the module
|
|
193
|
+
file of the same name is never executed). Returns None when the name
|
|
194
|
+
resolves under no root (it's external).
|
|
195
|
+
"""
|
|
196
|
+
parts = tuple(name.split("."))
|
|
197
|
+
for root in search_roots:
|
|
198
|
+
package_init = root.joinpath(*parts, "__init__.py")
|
|
199
|
+
if package_init.is_file():
|
|
200
|
+
return root, package_init.resolve(), parts
|
|
201
|
+
module_file = root.joinpath(*parts).with_suffix(".py")
|
|
202
|
+
if module_file.is_file():
|
|
203
|
+
return root, module_file.resolve(), parts
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def resolve_closure(script_path: Path, repo_root: Path) -> list[Path]:
|
|
208
|
+
"""Return the transitive in-repo import closure of *script_path*.
|
|
209
|
+
|
|
210
|
+
Absolute, resolved paths, sorted, with *script_path* itself EXCLUDED (it is
|
|
211
|
+
`source_hash`). Files that do not resolve under *repo_root* — stdlib,
|
|
212
|
+
site-packages, anything installed — are not closure members: external deps
|
|
213
|
+
are the image's concern (`image_tag`), not the hash's.
|
|
214
|
+
|
|
215
|
+
Search roots are the fixed, ordered pair `(repo_root, script_dir)` -
|
|
216
|
+
`script_dir` is *script_path*'s own directory, computed once here and
|
|
217
|
+
reused for every resolution in the traversal, matching what
|
|
218
|
+
`harness.ensure_import_paths` puts on `sys.path` for the whole process.
|
|
219
|
+
When the script lives at *repo_root* itself the pair collapses to a
|
|
220
|
+
single root, so it is not probed twice.
|
|
221
|
+
"""
|
|
222
|
+
repo_root = repo_root.resolve()
|
|
223
|
+
script_resolved = script_path.resolve()
|
|
224
|
+
script_dir = script_resolved.parent
|
|
225
|
+
search_roots = (repo_root,) if script_dir == repo_root else (repo_root, script_dir)
|
|
226
|
+
|
|
227
|
+
seen: set[Path] = {script_resolved}
|
|
228
|
+
queue: deque[Path] = deque([script_resolved])
|
|
229
|
+
|
|
230
|
+
while queue:
|
|
231
|
+
current = queue.popleft()
|
|
232
|
+
rel = current.relative_to(repo_root)
|
|
233
|
+
|
|
234
|
+
source = current.read_bytes()
|
|
235
|
+
try:
|
|
236
|
+
tree = ast.parse(source)
|
|
237
|
+
except SyntaxError as exc:
|
|
238
|
+
raise ContractError(f"{rel}: syntax error: {exc.msg}") from exc
|
|
239
|
+
|
|
240
|
+
violations = dynamic_import_violations(tree)
|
|
241
|
+
if violations:
|
|
242
|
+
lineno, construct = violations[0]
|
|
243
|
+
raise ContractError(
|
|
244
|
+
f"{rel}:{lineno}: dynamic import construct {construct!r} is not "
|
|
245
|
+
"allowed — the content hash cannot see it"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
for name in _collect_import_names(tree, current, repo_root):
|
|
249
|
+
match = _resolve_name(name, search_roots)
|
|
250
|
+
if match is None:
|
|
251
|
+
continue
|
|
252
|
+
root, resolved_file, parts = match
|
|
253
|
+
|
|
254
|
+
candidates = [resolved_file]
|
|
255
|
+
for depth in range(1, len(parts)):
|
|
256
|
+
init_candidate = root.joinpath(*parts[:depth], "__init__.py")
|
|
257
|
+
if init_candidate.is_file():
|
|
258
|
+
candidates.append(init_candidate.resolve())
|
|
259
|
+
|
|
260
|
+
for candidate in candidates:
|
|
261
|
+
if not candidate.is_relative_to(repo_root):
|
|
262
|
+
continue # symlink escape
|
|
263
|
+
if candidate in seen:
|
|
264
|
+
continue
|
|
265
|
+
seen.add(candidate)
|
|
266
|
+
queue.append(candidate)
|
|
267
|
+
|
|
268
|
+
return sorted(seen - {script_resolved})
|
|
@@ -10,13 +10,13 @@ from continuo_python_runtime.contract.model import Node
|
|
|
10
10
|
from continuo_python_runtime.errors import ReadError
|
|
11
11
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
|
-
from
|
|
13
|
+
from continuo_engine_contract.port import WarehouseAdapter # type: ignore[import-untyped]
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class RunContext:
|
|
17
17
|
"""Manages declared read access for a node execution."""
|
|
18
18
|
|
|
19
|
-
def __init__(self, node: Node, adapter: "
|
|
19
|
+
def __init__(self, node: Node, adapter: "WarehouseAdapter") -> None:
|
|
20
20
|
"""Initialize context with a node and adapter.
|
|
21
21
|
|
|
22
22
|
Args:
|