Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,18 @@ classifiers = [
"Intended Audience :: End Users/Desktop"
]
dependencies = [
# 0.10.11 is the first release with `step_name` on both Session.run_task and
# Session.enter_environment (RFC 0007/0008). Below it, run_task() raises
# TypeError on every task run, since this CLI passes the keyword unconditionally.
"openjd-sessions >= 0.10.11,< 0.11",
"openjd-model >= 0.9,< 0.12"
# 0.12.0 is the first release carrying `resolved_symtab` on all three of
# Session.enter_environment, Session.exit_environment and Session.run_task.
# It is also the release that removed `extra_let_bindings` from
# enter_environment: 0.10.11 through 0.11.0 have that keyword and no
# `resolved_symtab`, so there is no version satisfying both channels and the
# floor has to move rather than being feature-detected. Below 0.12.0 every
# call site here raises TypeError on the unexpected `resolved_symtab`.
"openjd-sessions >= 0.12.0,< 0.13",
# 0.11.4 is the first release exporting `create_job_with_symbol_tables` and
# `JobWithSymbolTables`. 0.11.3 and below have `create_job` only, so the
# step-scope resolved symbol tables cannot be obtained at all.
"openjd-model >= 0.11.4,< 0.12"
]

[project.urls]
Expand Down
11 changes: 9 additions & 2 deletions src/openjd/cli/_common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from dataclasses import asdict, dataclass
from enum import Enum
from pathlib import Path
from typing import Callable, Literal
from typing import TYPE_CHECKING, Callable, Literal
import json
import yaml
import os
Expand All @@ -23,6 +23,10 @@
)
from openjd.model import DecodeValidationError, Job, JobParameterValues, EnvironmentTemplate

if TYPE_CHECKING:
# Annotations only; see the note in _job_from_template.py.
from openjd.expr import SerializedSymbolTable

__all__ = [
"add_extensions_argument",
"get_doc_type",
Expand Down Expand Up @@ -118,7 +122,10 @@ def generate_job(
environments: list[EnvironmentTemplate] = [],
*,
supported_extensions: list[str],
) -> tuple[Job, JobParameterValues]:
) -> tuple[Job, JobParameterValues, dict[str, "SerializedSymbolTable"]]:
"""Returns the job, its parameter values, and the per-step resolved symbol
tables keyed by step name. See :func:`job_from_template` for why the tables
must be carried alongside the job rather than discarded."""
try:
# Raises: RuntimeError, DecodeValidationError
template = read_job_template(args.path, supported_extensions=supported_extensions)
Expand Down
30 changes: 24 additions & 6 deletions src/openjd/cli/_common/_job_from_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import json
from pathlib import Path
import re
from typing import Optional, Union
from typing import TYPE_CHECKING, Optional, Union
import yaml

from ._validation_utils import get_doc_type
Expand All @@ -15,10 +15,17 @@
Job,
JobParameterValues,
JobTemplate,
create_job,
create_job_with_symbol_tables,
preprocess_job_parameters,
)

if TYPE_CHECKING:
# Only for annotations: `openjd.expr` is a facade over the native
# extension, and importing the CLI must not load it. The tables the model
# hands back are already instances of this type, so nothing here
# constructs one.
from openjd.expr import SerializedSymbolTable


def get_params_from_file(parameter_string: str) -> Union[dict, list]:
"""
Expand Down Expand Up @@ -111,10 +118,18 @@ def job_from_template(
parameter_args: list[str] | None,
job_template_dir: Path,
current_working_dir: Path,
) -> tuple[Job, JobParameterValues]:
) -> tuple[Job, JobParameterValues, dict[str, "SerializedSymbolTable"]]:
"""
Given a decoded Job Template and a user-input parameter dictionary,
generates a Job object and the parameter values for running the job.
generates a Job object, the parameter values for running the job, and the
per-step resolved symbol tables, keyed by step name.

A step's template-scope `let` bindings (RFC 0005 §3.6) are evaluated once
here, at job creation, and their resolved values reach a session only
through those tables -- neither the model nor the session re-derives them
from the source expressions. Sessions for a step must therefore be given
`step_symbol_tables[step.name]`, or the step's `let` produces no bindings
at all.

Raises: RuntimeError if parameters are an unsupported type or don't correspond to the template
"""
Expand All @@ -132,11 +147,14 @@ def job_from_template(
raise RuntimeError(str(ve))

try:
job = create_job(
# `create_job_with_symbol_tables` returns the same Job that
# `create_job` does; it additionally returns the symbol tables that
# instantiation built instead of discarding them.
created = create_job_with_symbol_tables(
job_template=template,
job_parameter_values=parameter_values,
environment_templates=environments,
)
return (job, parameter_values)
return (created.job, parameter_values, created.step_symbol_tables)
except DecodeValidationError as dve:
raise RuntimeError(f"Could not generate Job from template and parameters: {str(dve)}")
84 changes: 66 additions & 18 deletions src/openjd/cli/_run/_local_session/_actions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

from enum import Enum
from typing import Any, Optional
from typing import TYPE_CHECKING, Optional

from openjd.model import Step, TaskParameterSet
from openjd.model.v2023_09 import Environment
from openjd.sessions import Session

if TYPE_CHECKING:
# Annotations only: `openjd.expr` is a facade over the native extension and
# importing the CLI must not load it. These tables are produced by
# `create_job_with_symbol_tables` and forwarded unchanged.
from openjd.expr import SerializedSymbolTable


class EnvironmentType(str, Enum):
"""
Expand Down Expand Up @@ -40,11 +46,24 @@ def run(self):
class RunTaskAction(SessionAction):
_step: Step
_parameters: TaskParameterSet
_resolved_symtab: Optional["SerializedSymbolTable"]

def __init__(self, session: Session, step: Step, parameters: TaskParameterSet):
def __init__(
self,
session: Session,
step: Step,
parameters: TaskParameterSet,
resolved_symtab: Optional["SerializedSymbolTable"] = None,
):
super(RunTaskAction, self).__init__(session)
self._step = step
self._parameters = parameters
# The step's create-time resolved symbol table
# (`create_job_with_symbol_tables().step_symbol_tables[step.name]`).
# It is the only channel for the step's template-scope `let` values:
# the model does not merge them into `script.let`, and the session does
# not re-evaluate the source expressions.
self._resolved_symtab = resolved_symtab

def run(self):
self._session.run_task(
Expand All @@ -53,6 +72,7 @@ def run(self):
# RFC 0008: the step name feeds the WrappedStep.Name template
# variable inside an active onWrapTaskRun hook.
step_name=self._step.name,
resolved_symtab=self._resolved_symtab,
)

def __str__(self):
Expand All @@ -63,44 +83,53 @@ def __str__(self):
class EnterEnvironmentAction(SessionAction):
_environment: Environment
_id: str
_extra_let_bindings: Optional[list[str]]
_resolved_symtab: Optional["SerializedSymbolTable"]
_step_name: Optional[str]

def __init__(
self,
session: Session,
environment: Environment,
env_id: str,
extra_let_bindings: Optional[list[str]] = None,
resolved_symtab: Optional["SerializedSymbolTable"] = None,
step_name: Optional[str] = None,
):
super(EnterEnvironmentAction, self).__init__(session)
self._environment = environment
self._id = env_id
# RFC 0007: a step's environments are entered with the step-level
# `let` bindings so their variables/actions can reference them.
self._extra_let_bindings = extra_let_bindings
# RFC 0005 §3.6: a step's environments are entered with the step's
# create-time resolved symbol table, which carries the step's
# template-scope `let` values so their variables/actions can reference
# them. Only step-environment enters have one; job and external
# environment enters leave it None.
self._resolved_symtab = resolved_symtab
# RFC 0007 §7.3.1 (EXPR): the owning step's name seeds Step.Name for
# a step environment's `let` bindings, variables, and actions. Only
# step-environment enters carry a step name; job/external enters
# leave it None.
self._step_name = step_name

def run(self):
# Both keywords are guaranteed by this package's `openjd-sessions`
# floor (>= 0.10.11), so neither is feature-detected. They are still
# only forwarded when they carry something: a step with no `let`
# bindings means "no extra bindings", and job/external environment
# enters have no owning step, so `Step.Name` must stay undefined for
# them rather than being seeded with None.
optional_kwargs: dict[str, Any] = {}
if self._extra_let_bindings:
optional_kwargs["extra_let_bindings"] = self._extra_let_bindings
# `step_name` is omitted rather than passed as None when this enter has
# no owning step. Passing None would be equivalent today, since
# enter_environment skips a None `step_name`, but omitting it keeps the
# call site's intent legible at the boundary and it is what
# test_localsession_step_env_enter_receives_step_name asserts — that
# assertion is currently the only check that job and external enters
# do not seed Step.Name, because a template referencing Step.Name
# outside a step is rejected by static validation before any session
# is built, so no loadable template can observe it.
#
# `resolved_symtab` is passed unconditionally: None is its documented
# "no table" value and there is no analogous assertion keyed on its
# absence.
optional_kwargs: dict[str, str] = {}
if self._step_name is not None:
optional_kwargs["step_name"] = self._step_name
self._session.enter_environment(
environment=self._environment,
identifier=self._id,
resolved_symtab=self._resolved_symtab,
**optional_kwargs,
)

Expand All @@ -111,15 +140,34 @@ def __str__(self):
class ExitEnvironmentAction(SessionAction):
_id: str
_keep_session_running: bool
_resolved_symtab: Optional["SerializedSymbolTable"]

def __init__(self, session: Session, id: str, keep_session_running: bool):
def __init__(
self,
session: Session,
id: str,
keep_session_running: bool,
resolved_symtab: Optional["SerializedSymbolTable"] = None,
):
super(ExitEnvironmentAction, self).__init__(session)
self._id = id
self._keep_session_running = keep_session_running
# The same table the environment was entered with, so its onExit
# resolves in the same scope as its onEnter (what
# Session.exit_environment documents for this argument).
#
# Unlike the worker agent, the CLI holds the table as an object rather
# than a JSON string served by the service, so there is no parse step
# here that could fail and no wrapper degrading a parse failure to None
# to keep teardown unconditional. An environment entered without a
# table simply exits without one.
self._resolved_symtab = resolved_symtab

def run(self):
self._session.exit_environment(
identifier=self._id, keep_session_running=self._keep_session_running
identifier=self._id,
keep_session_running=self._keep_session_running,
resolved_symtab=self._resolved_symtab,
)

def __str__(self):
Expand Down
Loading
Loading