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
61 changes: 61 additions & 0 deletions docs/template_globals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!-- Generated by scripts/generate_template_globals_docs.py. Do not edit by hand. -->

# Template globals

A pipeline template can call these functions:

```yaml
- id: save
tool: filesystem
action: write
parameters:
path: "report-{{ execution.timestamp }}.md"
content: "{{ include_file('header.md') }}"
```

They are **not** filters. A filter transforms a value the author already holds,
so any Jinja environment can evaluate one. Each global below answers a question
about the state of a *run*, so only the runtime can evaluate it:

```yaml
- id: make_it # writes ./artifact
- id: check_it # content: "{{ file_exists('artifact') }}" -> True
```

`check_it` renders `True` because by then `make_it` has run. The compiler and
the validators therefore know these names but hold none of the implementations
-- if they held them, that expression would be answered at compile time, before
any step had run, and would write `False` into the file with nothing failing.

## Calling them

A global must be **called**. Naming one without calling it renders the function
object itself:

| Written | Result |
|-|-|
| `{{ now() }}` | the time |
| `{{ now }}` | `<function ...now at 0x1084...>` written into your artifact |
| `{{ now.foo }}` | fails at run time |
| `{{ now(1, 2) }}` | fails at run time |

All three of the wrong forms are refused at compile time (`orchestrator
validate`, exit 2).


## Available globals

| Global | Arguments | Description |
|-|-|-|
| `active_loops` | 0 | The names of the loops currently running. |
| `current_loop_name` | 0 | The innermost active loop's name. |
| `file_exists` | 1 to 2 | Whether a path exists, answered when the step runs, so a file an earlier step wrote counts. |
| `historical_loops` | 0 | The names of finished loops whose values are still reachable. |
| `include_file` | 1 to 2 | The contents of a file, read when the step runs. |
| `loop_item_at` | 2 | An item at a fixed index of a named loop: `loop_item_at('outer', 0)`. |
| `loop_var` | 2 | A named loop's variable by name: `loop_var('outer', 'item')`. |
| `now` | 0 | The current time. Re-evaluated at every use, so two steps in one run disagree -- prefer `execution.timestamp` where a run needs one answer. |

A name that is not on this list is not a global. `{{ nowx() }}` is a
typo and is refused at compile time rather than becoming an undefined
value at run time.
114 changes: 114 additions & 0 deletions scripts/generate_template_globals_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Generate docs/template_globals.md from the pipeline-global registry.

The page is written from the same specs the validator checks calls against, so
it cannot document a global that does not exist, miss one that does, or state
an argument count the validator would reject.
`tests/test_template_globals.py` re-runs this and fails if the committed file
differs.

python scripts/generate_template_globals_docs.py # write the page
python scripts/generate_template_globals_docs.py --check # exit 1 if stale
"""

from __future__ import annotations

import argparse
import pathlib
import sys

ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))

from orchestrator.core.template_globals import GLOBAL_SPECS # noqa: E402

TARGET = ROOT / "docs" / "template_globals.md"

HEADER = """<!-- Generated by scripts/generate_template_globals_docs.py. Do not edit by hand. -->

# Template globals

A pipeline template can call these functions:

```yaml
- id: save
tool: filesystem
action: write
parameters:
path: "report-{{ execution.timestamp }}.md"
content: "{{ include_file('header.md') }}"
```

They are **not** filters. A filter transforms a value the author already holds,
so any Jinja environment can evaluate one. Each global below answers a question
about the state of a *run*, so only the runtime can evaluate it:

```yaml
- id: make_it # writes ./artifact
- id: check_it # content: "{{ file_exists('artifact') }}" -> True
```

`check_it` renders `True` because by then `make_it` has run. The compiler and
the validators therefore know these names but hold none of the implementations
-- if they held them, that expression would be answered at compile time, before
any step had run, and would write `False` into the file with nothing failing.

## Calling them

A global must be **called**. Naming one without calling it renders the function
object itself:

| Written | Result |
|-|-|
| `{{ now() }}` | the time |
| `{{ now }}` | `<function ...now at 0x1084...>` written into your artifact |
| `{{ now.foo }}` | fails at run time |
| `{{ now(1, 2) }}` | fails at run time |

All three of the wrong forms are refused at compile time (`orchestrator
validate`, exit 2).

"""


def render() -> str:
lines = [HEADER, "## Available globals\n"]
lines.append("| Global | Arguments | Description |")
lines.append("|-|-|-|")
for spec in sorted(GLOBAL_SPECS, key=lambda s: s.name):
lines.append(f"| `{spec.name}` | {spec.arity} | {spec.summary} |")
lines.append("")
lines.append(
"A name that is not on this list is not a global. `{{ nowx() }}` is a\n"
"typo and is refused at compile time rather than becoming an undefined\n"
"value at run time.\n"
)
return "\n".join(lines)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="verify, do not write")
args = parser.parse_args()

rendered = render()
if args.check:
current = TARGET.read_text() if TARGET.exists() else ""
if current != rendered:
print(f"{TARGET.relative_to(ROOT)} is out of date.", file=sys.stderr)
print(
"Regenerate with: python scripts/generate_template_globals_docs.py",
file=sys.stderr,
)
return 1
print(f"{TARGET.relative_to(ROOT)} is up to date.")
return 0

TARGET.parent.mkdir(parents=True, exist_ok=True)
TARGET.write_text(rendered)
print(f"wrote {TARGET.relative_to(ROOT)}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
206 changes: 206 additions & 0 deletions src/orchestrator/core/template_globals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""The pipeline language's global functions, and how they may be called.

Filters transform a value the author already holds. Globals do not: each of
these answers a question about the state of a *run* -- what time it started,
whether a file exists yet, which loop iteration this is. That is why only the
runtime holds their implementations, and why the compiler and the validators
must know them by name without being able to call them. `template_sandbox`
explains the mechanics; this module says what the names *are*.

#450 taught both validators these names, which stopped `{{ now() }}` -- a
pipeline that runs correctly -- from being reported as an undefined variable.
It stopped there, at the name, and so accepted every way of naming a global
that is not actually a call::

{{ nowx() }} rejected (not a global)
{{ now.foo }} accepted -> fails at run time
{{ file_exists.bad }} accepted -> fails at run time
{{ now(1, 2, 3) }} accepted -> fails at run time
{{ file_exists() }} accepted -> fails at run time
{{ now }} accepted -> *runs*, and writes
"<function ...now at 0x1084...>"

The last one is the worst, because nothing fails: the repr of a live function
object is written into the artifact as though it were data. (It is not a
sandbox escape -- `now.__globals__` and `now.__class__` are both refused by
`SandboxedEnvironment`, which #447 put in place. What leaks is the repr, not
the object graph.)

So the contract has to cover the call, not just the name, and it is declared
here rather than inferred from the callables. Inference was the right move in
#450, when the only question was which names exist and drift was the risk. It
cannot express what this needs -- an argument contract, a summary, later a
deprecation -- and it makes the public language a shadow of a private
implementation detail. `test_template_globals.py` asserts every spec matches
the callable the runtime actually registers, which keeps the drift protection
that derivation gave for free.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, FrozenSet, List, Optional, Tuple


@dataclass(frozen=True)
class GlobalSpec:
"""One global function: its name, how many arguments it takes, what it does."""

name: str
min_args: int
max_args: Optional[int] # None means unbounded
summary: str

def accepts(self, positional: int) -> bool:
if positional < self.min_args:
return False
return self.max_args is None or positional <= self.max_args

@property
def arity(self) -> str:
"""How the argument count reads in an error message or a doc table."""
if self.max_args is None:
return f"{self.min_args} or more"
if self.min_args == self.max_args:
return str(self.min_args)
return f"{self.min_args} to {self.max_args}"


#: Every global the pipeline language offers. The runtime registers exactly
#: these; the compiler and validators recognise exactly these.
GLOBAL_SPECS: Tuple[GlobalSpec, ...] = (
GlobalSpec(
"now", 0, 0,
"The current time. Re-evaluated at every use, so two steps in one run "
"disagree -- prefer `execution.timestamp` where a run needs one answer.",
),
GlobalSpec(
"file_exists", 1, 2,
"Whether a path exists, answered when the step runs, so a file an "
"earlier step wrote counts.",
),
GlobalSpec(
"include_file", 1, 2,
"The contents of a file, read when the step runs.",
),
GlobalSpec(
"loop_var", 2, 2,
"A named loop's variable by name: `loop_var('outer', 'item')`.",
),
GlobalSpec(
"loop_item_at", 2, 2,
"An item at a fixed index of a named loop: `loop_item_at('outer', 0)`.",
),
GlobalSpec("current_loop_name", 0, 0, "The innermost active loop's name."),
GlobalSpec("active_loops", 0, 0, "The names of the loops currently running."),
GlobalSpec(
"historical_loops", 0, 0,
"The names of finished loops whose values are still reachable.",
),
)

GLOBAL_NAMES: FrozenSet[str] = frozenset(spec.name for spec in GLOBAL_SPECS)

_BY_NAME = {spec.name: spec for spec in GLOBAL_SPECS}


def global_spec(name: str) -> Optional[GlobalSpec]:
"""The spec for `name`, or None if it is not a pipeline global."""
return _BY_NAME.get(name)


#: Stable identifiers for the two ways a global can be misused. Callers match
#: on these rather than on message text.
NOT_CALLED = "global_not_called"
WRONG_ARITY = "global_wrong_arity"


@dataclass(frozen=True)
class GlobalMisuse:
"""A global named in a template in a way that cannot work at run time."""

name: str
code: str
message: str
suggestion: str


def find_global_misuse(ast: Any) -> List[GlobalMisuse]:
"""Every misuse of a pipeline global in a parsed template.

Works on the parsed AST rather than on the text because the text does not
distinguish the cases: `now` appears identically in `{{ now() }}`,
`{{ now.foo }}` and `{{ now }}`, and only the first is a call.
"""
from jinja2 import nodes

# A Name node is a legitimate use only when it is the thing being called.
# Identity matters here, not the name: `{{ now() and now.foo }}` has two
# Name nodes spelled the same, one valid and one not.
calls_by_callee = {
id(call.node): call
for call in ast.find_all(nodes.Call)
if isinstance(call.node, nodes.Name) and call.node.name in GLOBAL_NAMES
}

# `{% for now in items %}{{ now }}{% endfor %}` rebinds the name: the
# target is a `store`, but the use inside the body is an ordinary `load`
# and is indistinguishable from ours without tracking scope. A template
# that binds the name anywhere is left alone entirely -- deliberately
# conservative, because a false rejection here is the exact failure the
# last three changes to this validator existed to remove.
shadowed = {
node.name
for node in ast.find_all(nodes.Name)
if getattr(node, "ctx", "load") != "load"
}

misuse: List[GlobalMisuse] = []
seen = set()
for name_node in ast.find_all(nodes.Name):
spec = global_spec(name_node.name)
if spec is None or spec.name in shadowed:
continue
if getattr(name_node, "ctx", "load") != "load":
continue

call = calls_by_callee.get(id(name_node))
if call is None:
key = (spec.name, NOT_CALLED)
if key in seen:
continue
seen.add(key)
call_form = f"{spec.name}()" if spec.min_args == 0 else f"{spec.name}(...)"
misuse.append(GlobalMisuse(
name=spec.name,
code=NOT_CALLED,
message=(
f"'{spec.name}' is a function and must be called: write "
f"'{call_form}'. Naming it without calling it yields the "
f"function itself, which renders as '<function ...>'."
),
suggestion=call_form,
))
continue

# `f(*args)` cannot be counted before it runs, so it is not checked.
if call.dyn_args is not None:
continue

positional = len(call.args)
if not spec.accepts(positional):
key = (spec.name, WRONG_ARITY, positional)
if key in seen:
continue
seen.add(key)
misuse.append(GlobalMisuse(
name=spec.name,
code=WRONG_ARITY,
message=(
f"'{spec.name}' takes {spec.arity} argument(s), not "
f"{positional}"
),
suggestion=f"{spec.name} expects {spec.arity} argument(s)",
))

return misuse
Loading
Loading