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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,34 @@ jobs:
- name: Conformance
run: python conformance/run.py

typescript:
name: TypeScript runtime
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/codeupipe-ts
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: actions/setup-node@v5
with:
node-version: "24"
- name: Install
run: npm ci
- name: Type check
run: npm run typecheck
- name: Tests
run: npm test
- name: Build
run: npm run build
- name: Example
run: node examples/pricing/main.ts
- name: Conformance
working-directory: .
run: python conformance/run.py typescript:run

export:
name: Export against dynamic run
runs-on: ubuntu-latest
Expand Down
23 changes: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# codeupipe

codeupipe is a Python library for writing a program as a pipeline of small
steps.
codeupipe is a library for writing a program as a pipeline of small steps.
It is for Python. A TypeScript runtime is in this repository and is not on
npm yet.

There are eight primitives. Each one does one thing.

Expand All @@ -28,12 +29,13 @@ If you know Unix, these are close to things you know.
| Hook | A git hook: code that runs before and after a step |
| State | `$?` and `PIPESTATUS` |

This repository has two packages.
This repository has three packages.

| Package | Contents | Reference |
|---|---|---|
| `codeupipe` | The eight primitives. An export command that writes a pipeline as plain Python with no import of codeupipe. A config file reader. No dependencies | [conformance/API.md](conformance/API.md), [docs/static-export.md](docs/static-export.md), [docs/config.md](docs/config.md) |
| `codeupipe-lint` | Two linters for pipeline code. Depends on `codeupipe` only | [packages/codeupipe-lint/README.md](packages/codeupipe-lint/README.md) |
| `codeupipe` for TypeScript | The eight primitives. No dependencies. No export and no linter | [packages/codeupipe-ts/README.md](packages/codeupipe-ts/README.md) |

## Install

Expand Down Expand Up @@ -150,7 +152,7 @@ An editable install needs pip 21.3 or later.
| Command | What it does |
|---|---|
| `python -m pytest tests -q` | Runs the tests |
| `python conformance/run.py` | Runs the 27 conformance cases against the dynamic run and the three exports |
| `python conformance/run.py` | Runs the 27 conformance cases against the Python dynamic run and the three exports, and against the TypeScript run when the machine has Node 22.18 or later |
| `python -m codeupipe_lint.anonymous_steps` | Runs the anonymous-step linter. Paths come from `cup.config.json` |
| `python -m codeupipe_lint.invariants` | Runs the invariant linter. Paths come from `cup.config.json` |
| `python -m codeupipe.lower examples.pricing.pricing_pipeline:PricingPipeline --out build/pricing` | Exports the example to `build/pricing/src/steps/` |
Expand All @@ -163,8 +165,9 @@ An editable install needs pip 21.3 or later.
|---|---|
| `packages/codeupipe/` | The `codeupipe` package: `core/`, `lower/` (the export), `config.py` |
| `packages/codeupipe-lint/` | The `codeupipe-lint` package: `anonymous_steps/`, `invariants/` |
| `conformance/` | The contract ([API.md](conformance/API.md)), the cases, the runner, the benchmark |
| `tests/` | The tests for the two packages |
| `packages/codeupipe-ts/` | The TypeScript runtime, with its tests and its example |
| `conformance/` | The contract ([API.md](conformance/API.md)), the cases, the runners, the benchmark |
| `tests/` | The tests for the two Python packages |
| `examples/pricing/` | The example, with its test in `tests/examples/` |
| `scorecard/` | The comparison of the export with the dynamic run on random pipelines |
| `docs/` | Reference for the export and the config file |
Expand All @@ -173,9 +176,11 @@ The root `pyproject.toml` is not a package. It holds the test settings.

## Other languages

This repository is Python only. The contract and the conformance cases do
not depend on Python. A runtime in another language must implement the
contract and add a conformance runner.
The contract and the conformance cases do not depend on a language. Python
is the first runtime and TypeScript is the second
([packages/codeupipe-ts/](packages/codeupipe-ts/)). The export and the
linters are for Python only. A runtime in another language must implement
the contract and add a conformance runner.

## License

Expand Down
35 changes: 31 additions & 4 deletions conformance/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ parameters, their defaults, and how they fail.
| State | records what happened |
| Pipeline | runs the steps in order |

Python implements it. A runtime in another language implements the same
contract, with identifier casing in that language's style, and is judged by
the same conformance cases.
Python and TypeScript implement it. Each runtime has the same contract,
with identifier casing in the style of its language, and the same
conformance cases judge it. The tables below show the Python names. The
TypeScript names and differences are in "Runtimes".

## Principles

Expand Down Expand Up @@ -78,7 +79,7 @@ Rules:

`test(payload) -> bool`.

- A Predicate is an object. It is identified by its class like a step, and
- A Predicate is an object. Its class gives it its name, and
any setting (a threshold, a flag) is on the instance:
`AtLeastPredicate("qty", 10)`. Functions, lambdas and closures are not
Predicates.
Expand Down Expand Up @@ -202,3 +203,29 @@ map and fails the comparison.
it changes timing for callers who wait on a Tap's side effect.
- **Other runtimes.** A runtime in another language is written to this
contract and added with its conformance runner.

## Runtimes

| | Python | TypeScript |
|---|---|---|
| Package | `packages/codeupipe` | `packages/codeupipe-ts` |
| Names | snake_case | camelCase: `addFilter`, `addValve`, `toDict`, `withTrace`, `traceId`, `onError` |
| Run | `run` is sync. `run_async` is opt-in | One `run`. It returns a Promise. A step can be sync or async |
| Absent | `None` | `null` |
| Label of a step | `type(step).__name__` | `step.constructor.name` |
| Export | `codeupipe.lower` | none |
| Linter | `codeupipe-lint` | none |

Notes on TypeScript:

- **Principle 3.** JavaScript always has an event loop, so there is no
second entry point. Inside `run`, a step that returns a value and not a
Promise is not awaited.
- **A function is not a step.** Every JavaScript function has a `call`
method, so a step must be an object.
- **Labels in minified code.** A minifier changes class names. A label is
then the changed name, unless the build keeps class names. A class with no
name has the label `anonymous`.
- **State and concurrent runs.** Two runs of one pipeline at the same time
each write to their own State. `state` is the State of the run that
started last.
62 changes: 32 additions & 30 deletions conformance/MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,49 @@ live copy is `results/latest.md` (not committed). The command exits non-zero
if any target has a ❌.

**Every target passes every case, and nothing is unsupported.** The targets
are the dynamic run, the async run, and the three static exports.
are the Python dynamic run, async run and three static exports, and the
TypeScript run.

The two fast exports (`static-steps`, `static-inline`) expose no State or
Hooks, so they pass those cases as ✅\*. `static-observe` (export with
`observe=True`) records both and passes every case outright. See
[docs/static-export.md](../docs/static-export.md).

| case | python:run | python:run_async | python:static-steps | python:static-inline | python:static-observe |
|---|---|---|---|---|---|
| `hook/lifecycle-on-success` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `hook/on-error-when-a-filter-fails` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `hook/not-notified-of-a-tap-failure` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `hook/on-error-names-a-failing-valve` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `payload/insert-adds-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/insert-overwrites` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/missing-key-reads-as-null` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/nested-input-is-never-mutated` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/json-value-types-round-trip` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/remove-drops-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/remove-of-a-missing-key-is-a-no-op` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/merge-right-side-wins` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/has-reports-presence-even-for-null` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/mutable-escape-hatch-sets-in-place` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `pipeline/empty-returns-input` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `pipeline/steps-run-in-order` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `pipeline/filter-error-halts-the-run` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `pipeline/nested-pipeline-is-a-step` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `tap/observes-without-changing-output` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `tap/failure-never-halts-the-pipeline` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `tap/failure-does-not-stop-later-taps` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `tap/sees-payload-after-a-skipped-valve` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `valve/runs-when-predicate-true` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `valve/skips-when-predicate-false` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `valve/missing-key-is-falsy` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `valve/sees-upstream-changes` | ✅ | ✅ | ✅* | ✅* | ✅ |
| `valve/independent-gates` | ✅ | ✅ | ✅* | ✅* | ✅ |
| case | python:run | python:run_async | python:static-steps | python:static-inline | python:static-observe | typescript:run |
|---|---|---|---|---|---|---|
| `hook/lifecycle-on-success` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `hook/on-error-when-a-filter-fails` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `hook/not-notified-of-a-tap-failure` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `hook/on-error-names-a-failing-valve` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `payload/insert-adds-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/insert-overwrites` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/missing-key-reads-as-null` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/nested-input-is-never-mutated` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/json-value-types-round-trip` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/remove-drops-a-key` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/remove-of-a-missing-key-is-a-no-op` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/merge-right-side-wins` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/has-reports-presence-even-for-null` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `payload/mutable-escape-hatch-sets-in-place` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `pipeline/empty-returns-input` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `pipeline/steps-run-in-order` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `pipeline/filter-error-halts-the-run` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `pipeline/nested-pipeline-is-a-step` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `tap/observes-without-changing-output` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `tap/failure-never-halts-the-pipeline` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `tap/failure-does-not-stop-later-taps` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `tap/sees-payload-after-a-skipped-valve` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `valve/runs-when-predicate-true` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `valve/skips-when-predicate-false` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `valve/missing-key-is-falsy` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `valve/sees-upstream-changes` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |
| `valve/independent-gates` | ✅ | ✅ | ✅* | ✅* | ✅ | ✅ |

- **python:run**: 27 pass, 0 pass*, 0 fail, 0 unsupported
- **python:run_async**: 27 pass, 0 pass*, 0 fail, 0 unsupported
- **python:static-steps**: 11 pass, 16 pass*, 0 fail, 0 unsupported
- **python:static-inline**: 11 pass, 16 pass*, 0 fail, 0 unsupported
- **python:static-observe**: 27 pass, 0 pass*, 0 fail, 0 unsupported
- **typescript:run**: 27 pass, 0 pass*, 0 fail, 0 unsupported

✅* passed everything the target can observe; it does not expose State or Hooks (the fast static export). ➖ the target cannot express the case.
5 changes: 5 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ load a number can leave the band.
| `python:run`, `python:run_async` | `codeupipe.core` Pipeline, sync and async entry points |
| `python:static-steps`, `python:static-inline` | `codeupipe.lower` fast export, loaded from disk (no State or Hooks: ✅\*) |
| `python:static-observe` | `codeupipe.lower` export with `observe=True`: State and Hooks judged too |
| `typescript:run` | `packages/codeupipe-ts` Pipeline. Node runs the `.ts` runner as it is (Node 22.18 or later). With no arguments, `run.py` leaves this target out when the machine has no such Node |

## Statuses

Expand Down Expand Up @@ -115,4 +116,8 @@ never halts the pipeline). Where it is silent, they encode Python
2. **Hooks around a skipped Valve.** Python fires `before:v` / `after:v` even
when the Valve skips its filter.
3. **`equals` across types.** Python's `==` makes `1 == true`; JSON does not.
TypeScript uses `===`, which does not compare lists or maps by content.
No case depends on it yet.
4. **`truthy` across languages.** An empty list or map is false in Python
and true in JavaScript. Each runner uses the rule of its language. No
case depends on it yet.
37 changes: 35 additions & 2 deletions conformance/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

python conformance/run.py # all targets
python conformance/run.py python:run python:static-inline
python conformance/run.py typescript:run

With no arguments, the TypeScript target runs only when the machine has
Node 22.18 or later. A target that is named and cannot run is a failure.

A runner executes cases and reports what happened. Only this file decides
pass or fail, so every target is held to exactly the same comparison.
Expand All @@ -18,6 +22,7 @@
import json
import os
import pathlib
import shutil
import subprocess
import sys

Expand All @@ -37,15 +42,43 @@ def _python(target):
}


def _typescript(target):
# Node runs the .ts file as it is. Nothing is built or downloaded.
return {
"cmd": ["node", str(HERE / "runners/typescript/runner.ts"), target],
"env": {},
"cwd": REPO,
}


def _node_runs_typescript():
"""Whether there is a Node that runs a .ts file as it is (22.18 or later)."""
node = shutil.which("node")
if node is None:
return False
try:
version = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=30).stdout
major, minor = (int(part) for part in version.strip().lstrip("v").split(".")[:2])
except (OSError, ValueError, subprocess.SubprocessError):
return False
return (major, minor) >= (22, 18)


TARGETS = {
"python:run": _python("run"),
"python:run_async": _python("run_async"),
"python:static-steps": _python("static-steps"),
"python:static-inline": _python("static-inline"),
"python:static-observe": _python("static-observe"),
"typescript:run": _typescript("run"),
}


def default_targets():
"""Every target that this machine can run. TypeScript needs Node."""
return [name for name in TARGETS if not name.startswith("typescript:") or _node_runs_typescript()]


def load_cases():
cases = []
for path in sorted(CASES.glob("*.json")):
Expand Down Expand Up @@ -130,7 +163,7 @@ def judge(case, result):

def main(argv):
cases = load_cases()
names = argv or list(TARGETS)
names = argv or default_targets()
report = {"cases": len(cases), "targets": {}}
matrix = {c["id"]: {} for c in cases}

Expand All @@ -156,7 +189,7 @@ def main(argv):
(RESULTS / "latest.json").write_text(json.dumps(report, indent=2))
(RESULTS / "latest.md").write_text(_markdown(cases, names, matrix, report))
print(_markdown(cases, names, matrix, report))
failed = any(t.get("FAIL") for t in report["targets"].values())
failed = any(t.get("FAIL") or "error" in t for t in report["targets"].values())
return 1 if failed else 0


Expand Down
16 changes: 16 additions & 0 deletions conformance/runners/typescript/cup-ops/add-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts";

/** insert key = get(key, 0) + by */
export class AddFilter implements Filter {
readonly key: string;
readonly by: number;

constructor(key: string, by: number) {
this.key = key;
this.by = by;
}

call(payload: Payload): Payload {
return payload.insert(this.key, payload.get(this.key, 0) + this.by);
}
}
17 changes: 17 additions & 0 deletions conformance/runners/typescript/cup-ops/append-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts";

/** insert key = a copy of get(key), or of an empty list, with value added */
export class AppendFilter implements Filter {
readonly key: string;
readonly value: unknown;

constructor(key: string, value: unknown) {
this.key = key;
this.value = value;
}

call(payload: Payload): Payload {
const list = payload.get<unknown[]>(this.key) ?? [];
return payload.insert(this.key, [...list, this.value]);
}
}
16 changes: 16 additions & 0 deletions conformance/runners/typescript/cup-ops/copy-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Filter, Payload } from "../../../../packages/codeupipe-ts/src/index.ts";

/** insert to = get(from), null if missing */
export class CopyFilter implements Filter {
readonly from: string;
readonly to: string;

constructor(from: string, to: string) {
this.from = from;
this.to = to;
}

call(payload: Payload): Payload {
return payload.insert(this.to, payload.get(this.from));
}
}
16 changes: 16 additions & 0 deletions conformance/runners/typescript/cup-ops/equals-predicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Payload, Predicate } from "../../../../packages/codeupipe-ts/src/index.ts";

/** get(key) == value */
export class EqualsPredicate implements Predicate {
readonly key: string;
readonly value: unknown;

constructor(key: string, value: unknown) {
this.key = key;
this.value = value;
}

test(payload: Payload): boolean {
return payload.get(this.key) === this.value;
}
}
Loading
Loading