Skip to content

Commit d1ec0d2

Browse files
committed
feat: RFC 0008 environment wrap actions with EXPR runtime parity Implement the WRAP_ACTIONS extension (RFC 0008) in the v0 session, with EXPR (RFC 0007) runtime parity with openjd-rs: - Wrap-hook dispatch: an environment's onWrapEnvEnter / onWrapTaskRun / onWrapEnvExit runs in place of an inner environment's onEnter/onExit or a step's onRun, with WrappedAction.Command/Args/Environment/ Timeout/Cancelation.* and WrappedEnv.Name/WrappedStep.Name injected. At most one wrap-defining environment may be active (enforced at enter time). - Two strictly separated scopes (openjd-rs #277 parity): the wrapped action's values resolve against the INNER entity's own scope (its script-level let bindings and embedded files, materialized in runner order: paths, lets, contents); the hook script resolves against the wrap environment's own scope (its lets, evaluated by the runner) plus the WrappedAction.* overlay. Same-named lets on the two sides each resolve to their own value. - WrappedAction.Environment carries every session-defined variable: openjd_env definitions and entered environments' declarative variables: maps; host-inherited variables are excluded. - EXPR runtime: runner-evaluated script-level let bindings ordered around embedded-file materialization (paths before lets, contents after), typed symbol tables, enter_environment(extra_let_bindings=...) so a step's environments see the step-level lets. - Cancelation: WrappedAction.Cancelation.Mode/NotifyPeriodInSeconds resolved through the enforcement path, with Template Schemas 5.3.2 defaults (120s task onRun / 30s otherwise).
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent d57e477 commit d1ec0d2

11 files changed

Lines changed: 2641 additions & 119 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@ __pycache__/
2323
/dist
2424
_version.py
2525
*.log
26+
27+
# Local one-off helper scripts (not part of the project)
28+
/tmp/

src/openjd/sessions/_embedded_files.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,23 @@ def __init__(
209209
self._user = user
210210

211211
def materialize(self, files: EmbeddedFilesListType, symtab: SymbolTable) -> None:
212+
records = self.allocate_file_paths(files, symtab)
213+
self.write_file_contents(records, symtab)
214+
215+
def allocate_file_paths(
216+
self, files: EmbeddedFilesListType, symtab: SymbolTable
217+
) -> list[_FileRecord]:
218+
"""Allocate the on-disk paths for the embedded files and define their
219+
``Env.File.*``/``Task.File.*`` symbols in ``symtab``, without writing
220+
the file contents.
221+
222+
Splitting allocation from :meth:`write_file_contents` lets the runner
223+
evaluate EXPR ``let`` bindings between the two phases (RFC 0007): a
224+
file's *path* never depends on ``let`` values (``filename`` is a plain
225+
string), so the ``Env.File.*``/``Task.File.*`` symbols are available
226+
to the bindings, while a file's ``data`` is written afterwards so it
227+
can reference let-bound values. Mirrors the openjd-rs runners.
228+
"""
212229
if self._scope == EmbeddedFilesScope.ENV:
213230
self._logger.info("Writing embedded files for Environment to disk.")
214231
else:
@@ -222,17 +239,28 @@ def materialize(self, files: EmbeddedFilesListType, symtab: SymbolTable) -> None
222239
symbol, filename = self._get_symtab_entry(file)
223240
records.append(_FileRecord(symbol=symbol, filename=filename, file=file))
224241

225-
# Add symbols to the symbol table
242+
# Add symbols to the symbol table. For EXPR evaluation the
243+
# Env.File.*/Task.File.* symbols are host-format path values
244+
# (property access like `.parent` works), matching openjd-rs;
245+
# the legacy (non-EXPR) interpolation path ignores the type and
246+
# keeps the string form.
226247
for record in records:
227248
symtab[record.symbol] = str(record.filename)
249+
symtab.expr_types[record.symbol] = "PATH"
228250
self._logger.info(
229251
f"Mapping: {record.symbol} -> {record.filename}",
230252
extra=LogExtraInfo(
231253
openjd_log_content=LogContent.FILE_PATH | LogContent.PARAMETER_INFO
232254
),
233255
)
256+
return records
257+
except (OSError, ValueError) as err:
258+
raise RuntimeError(f"Could not write embedded file: {err}")
234259

235-
# Write the files to disk.
260+
def write_file_contents(self, records: list["_FileRecord"], symtab: SymbolTable) -> None:
261+
"""Resolve each allocated file's ``data`` against ``symtab`` and write
262+
it to disk. See :meth:`allocate_file_paths`."""
263+
try:
236264
for record in records:
237265
# Raises: OSError
238266
self._materialize_file(record.filename, record.file, symtab)

src/openjd/sessions/_path_mapping.py

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,43 @@
44
from enum import Enum
55
from os import name as os_name
66
from pathlib import PurePath, PurePosixPath, PureWindowsPath
7+
from typing import Optional, Union
78

89

910
class PathFormat(str, Enum):
1011
POSIX = "POSIX"
1112
WINDOWS = "WINDOWS"
13+
# RFC 0006 §2.3.2 (EXPR extension): URI-form source paths
14+
# (e.g. "s3://bucket/prefix") that map to local filesystem paths.
15+
URI = "URI"
1216

1317

1418
@dataclass(frozen=True)
1519
class PathMappingRule:
1620
source_path_format: PathFormat
17-
source_path: PurePath
21+
# URI-format rules keep the source as the raw string: URIs are not
22+
# filesystem paths, and PurePath normalization would corrupt the
23+
# "scheme://" separator.
24+
source_path: Union[PurePath, str]
1825
destination_path: PurePath
1926

2027
def __init__(
21-
self, *, source_path_format: PathFormat, source_path: PurePath, destination_path: PurePath
28+
self,
29+
*,
30+
source_path_format: PathFormat,
31+
source_path: Union[PurePath, str],
32+
destination_path: PurePath,
2233
):
2334
if source_path_format == PathFormat.POSIX:
2435
if not isinstance(source_path, PurePosixPath):
2536
raise ValueError(
2637
"Path mapping rule source_path_format does not match source_path type"
2738
)
39+
elif source_path_format == PathFormat.URI:
40+
if not isinstance(source_path, str):
41+
raise ValueError(
42+
"Path mapping rule source_path must be a string for the URI source_path_format"
43+
)
2844
else:
2945
if not isinstance(source_path, PureWindowsPath):
3046
raise ValueError(
@@ -49,9 +65,12 @@ def from_dict(rule: dict[str, str]) -> "PathMappingRule":
4965
raise ValueError(f"Path mapping rule requires the following fields: {field_names}")
5066

5167
source_path_format = PathFormat(rule["source_path_format"].upper())
52-
source_path: PurePath
68+
source_path: Union[PurePath, str]
5369
if source_path_format == PathFormat.POSIX:
5470
source_path = PurePosixPath(rule["source_path"])
71+
elif source_path_format == PathFormat.URI:
72+
# Keep URIs verbatim; PurePath would collapse "scheme://".
73+
source_path = rule["source_path"]
5574
else:
5675
source_path = PureWindowsPath(rule["source_path"])
5776
destination_path = PurePath(rule["destination_path"])
@@ -76,25 +95,90 @@ def to_dict(self) -> dict[str, str]:
7695
"destination_path": str(self.destination_path),
7796
}
7897

98+
@staticmethod
99+
def _uri_path_start(uri: str) -> Optional[int]:
100+
"""Byte offset where the path component begins in a URI (after
101+
``scheme://authority``), or None when there is no ``://`` separator.
102+
Mirrors openjd-rs's ``uri_path_start``."""
103+
scheme_sep = uri.find("://")
104+
if scheme_sep == -1:
105+
return None
106+
authority_start = scheme_sep + 3
107+
slash = uri.find("/", authority_start)
108+
return len(uri) if slash == -1 else slash
109+
110+
def _apply_uri(self, path: str) -> tuple[bool, str]:
111+
"""Apply a URI-format rule, mirroring openjd-rs's ``apply_uri``.
112+
113+
Per RFC 3986 the scheme and authority match case-insensitively while
114+
the path portion matches case-sensitively, on whole path components.
115+
The result is a local path in the host's format.
116+
"""
117+
sep = "/" if os_name == "posix" else "\\"
118+
source = str(self.source_path)
119+
src_path_start = self._uri_path_start(source)
120+
src_path_start = 0 if src_path_start is None else src_path_start
121+
inp_path_start = self._uri_path_start(path)
122+
inp_path_start = 0 if inp_path_start is None else inp_path_start
123+
124+
# Scheme+authority must match case-insensitively.
125+
if path[:inp_path_start].lower() != source[:src_path_start].lower():
126+
return False, path
127+
# Path portion must match case-sensitively, on a component boundary.
128+
src_path = source[src_path_start:]
129+
inp_path = path[inp_path_start:]
130+
if not inp_path.startswith(src_path):
131+
return False, path
132+
remainder = inp_path[len(src_path) :]
133+
if remainder and not remainder.startswith("/"):
134+
return False, path
135+
136+
child_parts = remainder[1:].split("/") if remainder else []
137+
result = str(self.destination_path)
138+
for part in child_parts:
139+
result += sep + part
140+
if path.endswith("/") and not result.endswith(sep):
141+
result += sep
142+
return True, result
143+
144+
def source_path_component_count(self) -> int:
145+
"""Number of components in the source path, used to order rules from
146+
most to least specific. For URI sources the ``scheme://authority``
147+
counts as one component plus one per path segment."""
148+
if isinstance(self.source_path, PurePath):
149+
return len(self.source_path.parts)
150+
source = str(self.source_path)
151+
path_start = self._uri_path_start(source)
152+
if path_start is None:
153+
return 1
154+
path_portion = source[path_start:].strip("/")
155+
return 1 + (len(path_portion.split("/")) if path_portion else 0)
156+
79157
def apply(self, *, path: str) -> tuple[bool, str]:
80158
"""Applies the path mapping rule on the given path, if it matches the rule.
81159
Does not collapse ".." since symbolic paths could be used.
82160
83161
Returns: tuple[bool, str] - indicating if the path matched the rule and the resulting
84162
mapped path. If it doesn't match, then it returns the original path unmodified.
85163
"""
164+
if self.source_path_format == PathFormat.URI:
165+
return self._apply_uri(path)
166+
source_path = self.source_path
167+
if not isinstance(source_path, PurePath):
168+
# The constructor guarantees non-URI rules carry PurePath sources.
169+
raise TypeError(
170+
"Path mapping rule source_path must be a PurePath for filesystem source formats"
171+
)
86172
pure_path: PurePath
87173
if self.source_path_format == PathFormat.POSIX:
88174
pure_path = PurePosixPath(path)
89175
else:
90176
pure_path = PureWindowsPath(path)
91177

92-
if not pure_path.is_relative_to(self.source_path):
178+
if not pure_path.is_relative_to(source_path):
93179
return False, path
94180

95-
remapped_parts = (
96-
self.destination_path.parts + pure_path.parts[len(self.source_path.parts) :]
97-
)
181+
remapped_parts = self.destination_path.parts + pure_path.parts[len(source_path.parts) :]
98182
if os_name == "posix":
99183
result = str(PurePosixPath(*remapped_parts))
100184
if self._has_trailing_slash(self.source_path_format, path):

0 commit comments

Comments
 (0)