dirigent-testing 0.9.0__tar.gz

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.
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
2
+
3
+ This source code and accompanying documentation are the property of
4
+ Morten Olav Hansen. No license, express or implied, is granted to use, copy,
5
+ modify, merge, publish, distribute, sublicense, or sell copies of this
6
+ software or its derivatives.
7
+
8
+ The source is published for reference only. Any use beyond reading
9
+ requires written permission from the copyright holder.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
14
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
15
+ OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
16
+
17
+ Third-party components redistributed with this software, and the licences they
18
+ carry, are listed in THIRD_PARTY_NOTICES.md.
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigent-testing
3
+ Version: 0.9.0
4
+ Summary: Test doubles and pytest fixtures for writing and testing dirigent blocks.
5
+ License-Expression: LicenseRef-Proprietary
6
+ License-File: LICENSE
7
+ Requires-Dist: dirigent-common
8
+ Requires-Dist: dirigent-plugin
9
+ Requires-Dist: httpx2>=2.12.0
10
+ Requires-Dist: pydantic>=2.13.5
11
+ Requires-Dist: pytest>=9.1.1
12
+ Requires-Dist: pyyaml>=6.0.3
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # dirigent-testing
17
+
18
+ Test doubles and pytest fixtures for writing and testing dirigent blocks.
19
+
20
+ Installing it registers the fixtures by entry point, so a block author writes no conftest.
21
+ `block_ctx` is a step context with a recording log and real storage behind it, and
22
+ `call_block` validates a config the way the engine does before making the block's first call.
23
+
24
+ `check_pack_examples` checks a pack's own example documents against its own `Contribution`
25
+ -- format, code, blocks, config schemas, and carried connections -- without importing
26
+ dirigent-core, and `assert_contribution_conforms` checks the blocks a contribution provides
27
+ are well-formed. Both return a list of human-readable issues; an empty list means it passed.
@@ -0,0 +1,12 @@
1
+ # dirigent-testing
2
+
3
+ Test doubles and pytest fixtures for writing and testing dirigent blocks.
4
+
5
+ Installing it registers the fixtures by entry point, so a block author writes no conftest.
6
+ `block_ctx` is a step context with a recording log and real storage behind it, and
7
+ `call_block` validates a config the way the engine does before making the block's first call.
8
+
9
+ `check_pack_examples` checks a pack's own example documents against its own `Contribution`
10
+ -- format, code, blocks, config schemas, and carried connections -- without importing
11
+ dirigent-core, and `assert_contribution_conforms` checks the blocks a contribution provides
12
+ are well-formed. Both return a list of human-readable issues; an empty list means it passed.
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "dirigent-testing"
3
+ version = "0.9.0"
4
+ description = "Test doubles and pytest fixtures for writing and testing dirigent blocks."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ dependencies = [
10
+ "dirigent-common",
11
+ "dirigent-plugin",
12
+ "httpx2>=2.12.0",
13
+ "pydantic>=2.13.5",
14
+ "pytest>=9.1.1",
15
+ "pyyaml>=6.0.3",
16
+ ]
17
+
18
+ [project.entry-points.pytest11]
19
+ dirigent_testing = "dirigent_testing.fixtures"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.12.0,<0.13.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.uv.sources.dirigent-common]
26
+ workspace = true
27
+
28
+ [tool.uv.sources.dirigent-plugin]
29
+ workspace = true
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "dirigent-testing"
3
+ version = "0.9.0"
4
+ description = "Test doubles and pytest fixtures for writing and testing dirigent blocks."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ dependencies = [
10
+ "dirigent-common",
11
+ "dirigent-plugin",
12
+ "httpx2>=2.12.0",
13
+ "pydantic>=2.13.5",
14
+ "pytest>=9.1.1",
15
+ "pyyaml>=6.0.3",
16
+ ]
17
+
18
+ [project.entry-points.pytest11]
19
+ dirigent_testing = "dirigent_testing.fixtures"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.12.0,<0.13.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.uv.sources]
26
+ dirigent-common = { workspace = true }
27
+ dirigent-plugin = { workspace = true }
@@ -0,0 +1,21 @@
1
+ """Test doubles and pytest fixtures for writing and testing dirigent blocks."""
2
+
3
+ from dirigent_testing.conformance import assert_contribution_conforms, check_pack_examples
4
+ from dirigent_testing.doubles import FakeContext, FakeRuns, FakeSink, FakeStorage, RecordingLogger
5
+ from dirigent_testing.environment import CONFIGURING_PREFIXES, TEST_WIDTH, pin_terminal, scrub_configuration
6
+ from dirigent_testing.running import call_block
7
+
8
+ __all__ = [
9
+ "CONFIGURING_PREFIXES",
10
+ "TEST_WIDTH",
11
+ "FakeContext",
12
+ "FakeRuns",
13
+ "FakeSink",
14
+ "FakeStorage",
15
+ "RecordingLogger",
16
+ "assert_contribution_conforms",
17
+ "call_block",
18
+ "check_pack_examples",
19
+ "pin_terminal",
20
+ "scrub_configuration",
21
+ ]
@@ -0,0 +1,195 @@
1
+ """Checking a pack's own examples against its own contribution, without the engine.
2
+
3
+ An out-of-repo pack cannot import dirigent-core to reuse ``validate_against_catalog``: the
4
+ plugin contract stops at dirigent-plugin, and a pack must not reach past it. So the pack-side
5
+ conformance check re-derives the structural half here, over the pack's own ``Contribution``
6
+ and its own example documents. It is deliberately lighter than the engine's preflight: it
7
+ proves an example is a well-formed ``dirigent/v1`` pipeline coded after its file, that every
8
+ block it names is one the pack contributes, that each config fits that block's published
9
+ schema, and that a named connection is carried by the document. It does not resolve
10
+ references, run blocks, or reach for an instance.
11
+ """
12
+
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any, Final, cast
16
+
17
+ import yaml
18
+ from jsonschema import Draft202012Validator
19
+ from jsonschema import ValidationError as SchemaValidationError
20
+ from jsonschema.validators import extend as extend_validator # pyright: ignore[reportUnknownVariableType]
21
+ from pydantic import JsonValue
22
+
23
+ from dirigent_common import BlockModel, HumaneJsonSchema, JsonMap
24
+ from dirigent_plugin import AnyOperator, AnySensor, Contribution
25
+
26
+ #: A ``${...}`` reference, matched the way the engine matches one.
27
+ _REFERENCE: Final = re.compile(r"\$\{[^{}]+\}")
28
+
29
+
30
+ class _Deferred:
31
+ """A config value that is only a ``${...}`` reference, opaque until a run resolves it."""
32
+
33
+ def __repr__(self) -> str:
34
+ """Render the sentinel for an error message."""
35
+ return "${...}"
36
+
37
+
38
+ _DEFERRED: Final = _Deferred()
39
+
40
+
41
+ def _skip_deferred(keyword: Any) -> Any:
42
+ """Wrap one jsonschema keyword so it passes over a value that is only known at run time."""
43
+
44
+ def validate(validator: Any, value: Any, instance: Any, schema: Any) -> Any:
45
+ if instance is _DEFERRED:
46
+ return
47
+ yield from keyword(validator, value, instance, schema)
48
+
49
+ return validate
50
+
51
+
52
+ #: A validator that checks everything an example states literally and defers a reference.
53
+ _ConfigValidator: Any = extend_validator( # pyright: ignore[reportUnknownVariableType]
54
+ Draft202012Validator,
55
+ {name: _skip_deferred(keyword) for name, keyword in Draft202012Validator.VALIDATORS.items()},
56
+ )
57
+
58
+
59
+ def _defer_references(value: JsonValue) -> object:
60
+ """Replace every value that is a ``${...}`` reference with the deferred sentinel."""
61
+ match value:
62
+ case str():
63
+ return _DEFERRED if _REFERENCE.search(value) else value
64
+ case list():
65
+ return [_defer_references(item) for item in value]
66
+ case dict():
67
+ return {key: _defer_references(item) for key, item in value.items()}
68
+ case _:
69
+ return value
70
+
71
+
72
+ def check_pack_examples(contribution: Contribution, examples_dir: Path) -> list[str]:
73
+ """List everything wrong with a pack's example documents, checked against its own catalog.
74
+
75
+ Reads every ``*.yaml`` under ``examples_dir`` (recursively) and, for each, checks that it
76
+ is a ``dirigent/v1`` pipeline coded after its file with a description, that every block a
77
+ step names is one ``contribution`` provides, that each step's config fits that block's
78
+ published ``config_schema`` (a ``${...}`` value is left for the run), and that a connection
79
+ a step names is carried by the document's own ``connections`` block. Every issue is a
80
+ human-readable line prefixed with the file it was found in; an empty list means the
81
+ examples conform.
82
+ """
83
+ blocks = _blocks_by_id(contribution)
84
+ issues: list[str] = []
85
+ for path in sorted(examples_dir.rglob("*.yaml")):
86
+ issues.extend(_check_document(path, path.relative_to(examples_dir), blocks))
87
+ return issues
88
+
89
+
90
+ def _every_block(contribution: Contribution) -> list[AnyOperator | AnySensor]:
91
+ """List every operator and then every sensor a contribution provides."""
92
+ blocks: list[AnyOperator | AnySensor] = [*contribution.operators, *contribution.sensors]
93
+ return blocks
94
+
95
+
96
+ def _blocks_by_id(contribution: Contribution) -> dict[str, AnyOperator | AnySensor]:
97
+ """Index every operator and sensor a contribution provides by its block id."""
98
+ return {block.spec.id: block for block in _every_block(contribution)}
99
+
100
+
101
+ def _check_document(path: Path, label: Path, blocks: dict[str, AnyOperator | AnySensor]) -> list[str]:
102
+ """Check one example document, prefixing every issue with the file it was found in."""
103
+ try:
104
+ loaded: object = yaml.safe_load(path.read_text())
105
+ except yaml.YAMLError as error:
106
+ return [f"{label}: is not valid YAML ({error})"]
107
+ if not isinstance(loaded, dict):
108
+ return [f"{label}: is not a mapping"]
109
+ document = cast("JsonMap", loaded)
110
+
111
+ issues: list[str] = []
112
+ if document.get("format") != "dirigent/v1":
113
+ issues.append(f"{label}: format is {document.get('format')!r}, not 'dirigent/v1'")
114
+ if document.get("kind") != "pipeline":
115
+ issues.append(f"{label}: kind is {document.get('kind')!r}, not 'pipeline'")
116
+ if document.get("code") != path.stem:
117
+ issues.append(f"{label}: code is {document.get('code')!r}, but the file is named {path.stem!r}")
118
+ if not document.get("description"):
119
+ issues.append(f"{label}: has no description")
120
+
121
+ connections = document.get("connections")
122
+ known = set(cast("JsonMap", connections)) if isinstance(connections, dict) else set[str]()
123
+ steps = document.get("steps")
124
+ if isinstance(steps, dict):
125
+ for name, step in cast("JsonMap", steps).items():
126
+ issues.extend(_check_step(label, str(name), step, blocks, known))
127
+ return issues
128
+
129
+
130
+ def _check_step(
131
+ label: Path,
132
+ name: str,
133
+ step: Any,
134
+ blocks: dict[str, AnyOperator | AnySensor],
135
+ connections: set[str],
136
+ ) -> list[str]:
137
+ """Check one step: the block it names, the config it carries, the connection it references."""
138
+ if not isinstance(step, dict):
139
+ return [f"{label}: step {name!r} is not a mapping"]
140
+ step_map = cast("JsonMap", step)
141
+ issues: list[str] = []
142
+ block_id = step_map.get("block")
143
+ raw_config = step_map.get("config")
144
+ config = cast("JsonMap", raw_config) if isinstance(raw_config, dict) else cast("JsonMap", {})
145
+
146
+ block = blocks.get(block_id) if isinstance(block_id, str) else None
147
+ if block is None:
148
+ issues.append(f"{label}: step {name!r} names block {block_id!r}, which this pack does not contribute")
149
+ else:
150
+ schema = block.config_model.model_json_schema(schema_generator=HumaneJsonSchema)
151
+ issues.extend(_config_issues(label, name, config, schema))
152
+
153
+ named = config.get("connection")
154
+ if isinstance(named, str) and not _REFERENCE.search(named) and named not in connections:
155
+ issues.append(f"{label}: step {name!r} names connection {named!r}, which the document does not carry")
156
+ return issues
157
+
158
+
159
+ def _config_issues(label: Path, name: str, config: JsonMap, schema: JsonMap) -> list[str]:
160
+ """List the ways a step's config violates its block's schema, deferring every reference."""
161
+ deferred = _defer_references(config)
162
+ raised: Any = _ConfigValidator(schema).iter_errors(deferred) # pyright: ignore[reportUnknownMemberType]
163
+ found = cast("list[SchemaValidationError]", list(raised))
164
+ issues: list[str] = []
165
+ for error in sorted(found, key=lambda item: [str(part) for part in item.absolute_path]):
166
+ location = ".".join(str(part) for part in error.absolute_path)
167
+ where = f"config.{location}" if location else "config"
168
+ issues.append(f"{label}: step {name!r} {where}: {error.message}")
169
+ return issues
170
+
171
+
172
+ def assert_contribution_conforms(contribution: Contribution) -> list[str]:
173
+ """List the ways a contribution's blocks are malformed, beyond what constructing it enforces.
174
+
175
+ A ``Contribution`` already rejects colliding ids when it is built, and a block id is a
176
+ pattern that forbids an empty one, so a valid contribution passes those. This adds the
177
+ check the contract cannot make on its own: that every operator's and sensor's
178
+ ``config_model`` and ``output_model`` is a :class:`BlockModel`. An empty list means the
179
+ contribution's blocks are well-formed.
180
+ """
181
+ issues: list[str] = []
182
+ seen: set[str] = set()
183
+ for block in _every_block(contribution):
184
+ label = type(block).__name__
185
+ block_id = block.spec.id
186
+ if not block_id:
187
+ issues.append(f"{label} has an empty spec.id")
188
+ elif block_id in seen:
189
+ issues.append(f"duplicate block id {block_id!r}")
190
+ seen.add(block_id)
191
+ for role in ("config_model", "output_model"):
192
+ model = getattr(block, role)
193
+ if not (isinstance(model, type) and issubclass(model, BlockModel)):
194
+ issues.append(f"{block_id or label} {role} is not a BlockModel")
195
+ return issues
@@ -0,0 +1,273 @@
1
+ """The engine's side of a block call, faked: a step context, storage, logs, and runs."""
2
+
3
+ import tempfile
4
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
5
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+ from uuid import UUID, uuid4
10
+
11
+ import httpx2
12
+ from jsonschema import FormatChecker
13
+ from pydantic import BaseModel, JsonValue
14
+
15
+ from dirigent_common import format_checker_with
16
+ from dirigent_plugin import (
17
+ ByteSink,
18
+ FormatCheck,
19
+ RunRefused,
20
+ RunSnapshot,
21
+ RunState,
22
+ StartedRun,
23
+ StatResult,
24
+ StepContext,
25
+ )
26
+
27
+
28
+ class RecordingLogger:
29
+ """Keeps every entry a block wrote, so tests can assert on what it reported."""
30
+
31
+ def __init__(self) -> None:
32
+ """Start with an empty log."""
33
+ self.entries: list[tuple[str, str, dict[str, JsonValue]]] = []
34
+
35
+ def debug(self, message: str, **fields: JsonValue) -> None:
36
+ """Record a debug entry."""
37
+ self.entries.append(("debug", message, dict(fields)))
38
+
39
+ def info(self, message: str, **fields: JsonValue) -> None:
40
+ """Record an info entry."""
41
+ self.entries.append(("info", message, dict(fields)))
42
+
43
+ def warning(self, message: str, **fields: JsonValue) -> None:
44
+ """Record a warning entry."""
45
+ self.entries.append(("warning", message, dict(fields)))
46
+
47
+ def error(self, message: str, **fields: JsonValue) -> None:
48
+ """Record an error entry."""
49
+ self.entries.append(("error", message, dict(fields)))
50
+
51
+ def messages(self) -> list[str]:
52
+ """List just the messages, which is what most assertions want."""
53
+ return [message for _, message, _ in self.entries]
54
+
55
+
56
+ class FakeSink:
57
+ """The write end of a local file, standing in for a storage backend's sink."""
58
+
59
+ def __init__(self, path: Path) -> None:
60
+ """Open the file for writing."""
61
+ self.path = path
62
+ self._handle = path.open("wb")
63
+
64
+ async def write(self, data: bytes) -> int:
65
+ """Append bytes."""
66
+ return self._handle.write(data)
67
+
68
+ def close(self) -> None:
69
+ """Finish the file."""
70
+ self._handle.close()
71
+
72
+
73
+ class FakeStorage:
74
+ """A storage facade over one directory, backing the storage blocks in a test."""
75
+
76
+ def __init__(self, root: Path) -> None:
77
+ """Root the facade at a throwaway directory."""
78
+ self.root = root
79
+
80
+ def path_for(self, uri: str) -> Path:
81
+ """Map a URI onto a local path: absolute as given, relative under the root."""
82
+ _, _, location = uri.partition("://")
83
+ path = Path(location)
84
+ return path if path.is_absolute() else self.root / location
85
+
86
+ async def open_read(self, uri: str) -> AsyncGenerator[bytes]:
87
+ """Stream an object in two chunks, so callers cannot assume one."""
88
+ payload = self.path_for(uri).read_bytes()
89
+ middle = max(len(payload) // 2, 1)
90
+ yield payload[:middle]
91
+ if payload[middle:]:
92
+ yield payload[middle:]
93
+
94
+ def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
95
+ """Open a writer for a URI, creating the directories it needs."""
96
+
97
+ @asynccontextmanager
98
+ async def writer() -> AsyncGenerator[ByteSink]:
99
+ path = self.path_for(uri)
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ sink = FakeSink(path)
102
+ try:
103
+ yield sink
104
+ finally:
105
+ sink.close()
106
+
107
+ return writer()
108
+
109
+ async def stat(self, uri: str) -> StatResult | None:
110
+ """Describe an object, or report that it is not there."""
111
+ path = self.path_for(uri)
112
+ if not path.is_file():
113
+ return None
114
+ info = path.stat()
115
+ return StatResult(uri=uri, size=info.st_size, modified_at=datetime.fromtimestamp(info.st_mtime, tz=UTC))
116
+
117
+ async def list(self, uri: str) -> AsyncIterator[StatResult]:
118
+ """List the objects matching a pattern, in a stable order."""
119
+ scheme, _, pattern = uri.partition("://")
120
+ for path in sorted(self.root.glob(pattern.lstrip("/"))):
121
+ if not path.is_file():
122
+ continue
123
+ info = path.stat()
124
+ yield StatResult(
125
+ uri=f"{scheme}://{path.relative_to(self.root)}",
126
+ size=info.st_size,
127
+ modified_at=datetime.fromtimestamp(info.st_mtime, tz=UTC),
128
+ )
129
+
130
+ async def delete(self, uri: str) -> None:
131
+ """Remove an object."""
132
+ self.path_for(uri).unlink(missing_ok=True)
133
+
134
+
135
+ class FakeRuns:
136
+ """The runs facade, faked: an in-memory instance a composition block can drive."""
137
+
138
+ def __init__(self) -> None:
139
+ """Start with no pipelines and no runs."""
140
+ self.pipelines: set[str] = set()
141
+ self.snapshots: dict[UUID, RunSnapshot] = {}
142
+ self.started: list[tuple[str, dict[str, JsonValue], int]] = []
143
+ self.cancelled: list[UUID] = []
144
+ self.skip: set[str] = set()
145
+ self.refusal: str | None = None
146
+
147
+ def hold(
148
+ self,
149
+ pipeline: str,
150
+ state: RunState = RunState.QUEUED,
151
+ *,
152
+ total_steps: int = 0,
153
+ finished_steps: int = 0,
154
+ ) -> UUID:
155
+ """Install a run this instance holds, and return the id a handle would name."""
156
+ run_id = uuid4()
157
+ self.pipelines.add(pipeline)
158
+ self.snapshots[run_id] = RunSnapshot(
159
+ run_id=run_id,
160
+ pipeline=pipeline,
161
+ state=state,
162
+ total_steps=total_steps,
163
+ finished_steps=finished_steps,
164
+ )
165
+ return run_id
166
+
167
+ async def start(self, pipeline: str, params: Mapping[str, JsonValue], *, max_depth: int) -> StartedRun:
168
+ """Record the start and answer with a run, a skip, or the refusal the test installed."""
169
+ self.started.append((pipeline, dict(params), max_depth))
170
+ if self.refusal is not None:
171
+ raise RunRefused(self.refusal)
172
+ if pipeline in self.skip:
173
+ return StartedRun(pipeline=pipeline)
174
+ run_id = self.hold(pipeline)
175
+ return StartedRun(pipeline=pipeline, run_id=run_id, state=RunState.QUEUED)
176
+
177
+ async def snapshot(self, run_id: UUID) -> RunSnapshot | None:
178
+ """Describe a run this fake instance holds."""
179
+ return self.snapshots.get(run_id)
180
+
181
+ async def cancel(self, run_id: UUID, *, reason: str) -> bool:
182
+ """Cancel a run, unless it had already settled or was never here."""
183
+ self.cancelled.append(run_id)
184
+ held = self.snapshots.get(run_id)
185
+ if held is None or held.state.settled:
186
+ return False
187
+ self.snapshots[run_id] = held.model_copy(update={"state": RunState.CANCELLED, "error": reason})
188
+ return True
189
+
190
+
191
+ class FakeContext:
192
+ """The engine's side of the bargain, faked: real storage, a recording log, a fake client."""
193
+
194
+ def __init__(self, storage: FakeStorage, scratch: str, work: Path | None = None) -> None:
195
+ """Bind the context to a storage root, a scratch prefix, and a local work directory."""
196
+ self.run_id: UUID = uuid4()
197
+ self.step = "step"
198
+ self.run_item_id: UUID | None = None
199
+ self.attempt = 1
200
+ self.started_at = datetime.now(UTC)
201
+ self.inline_capture = 8 * 1024
202
+ self.cursor: dict[str, JsonValue] | None = None
203
+ """What the last committed ``NotYet`` returned, which a test sets to poke again."""
204
+ self.params: dict[str, JsonValue] = {}
205
+ self.log = RecordingLogger()
206
+ self.connections: dict[str, BaseModel] = {}
207
+ self.storage_connections: dict[str, str] = {}
208
+ """Which connection code serves each storage scheme, as ``storage_connections`` maps them."""
209
+ self.schemas: dict[str, Any] = {}
210
+ self.formats: dict[str, FormatCheck] = {}
211
+ """Contributed format checkers a test installs, so a block can exercise ``format: <name>``."""
212
+ self.handler: Callable[[httpx2.Request], httpx2.Response] | None = None
213
+ self.scratch_uri = scratch
214
+ self.work_dir = work if work is not None else Path(tempfile.mkdtemp(prefix="dirigent-work-"))
215
+ self._storage = storage
216
+ self._runs = FakeRuns()
217
+
218
+ @property
219
+ def storage(self) -> FakeStorage:
220
+ """The storage facade."""
221
+ return self._storage
222
+
223
+ @property
224
+ def scratch(self) -> str:
225
+ """The run-scoped URI prefix."""
226
+ return self.scratch_uri
227
+
228
+ @property
229
+ def work(self) -> Path:
230
+ """The run's directory on this worker's own filesystem, made on first read.
231
+
232
+ Absolute whatever it was set to, as the engine's own is: a child process is given
233
+ both a working directory and the paths it is to touch.
234
+ """
235
+ directory = self.work_dir.absolute()
236
+ directory.mkdir(parents=True, exist_ok=True)
237
+ return directory
238
+
239
+ @property
240
+ def runs(self) -> FakeRuns:
241
+ """The instance's own runs, as a composition block reaches them."""
242
+ return self._runs
243
+
244
+ def as_context(self) -> StepContext:
245
+ """Present the fake as the protocol a block is typed against.
246
+
247
+ The fake keeps its own logger and its own params dictionary so a test can assert on
248
+ them, and a protocol's mutable attribute matches neither of those narrower types.
249
+ """
250
+ return cast("StepContext", self)
251
+
252
+ def connection(self, ref: str, model: type[Any]) -> Any:
253
+ """Resolve a named connection the test installed."""
254
+ return self.connections[ref]
255
+
256
+ def storage_connection(self, scheme: str, model: type[Any]) -> Any:
257
+ """Resolve the connection the test bound to a storage scheme, or None where it bound none."""
258
+ ref = self.storage_connections.get(scheme)
259
+ return None if ref is None else self.connections[ref]
260
+
261
+ def schema(self, code: str) -> Any:
262
+ """Resolve a named schema the test installed; an unknown code fails as the engine's does."""
263
+ return self.schemas[code]
264
+
265
+ def format_checker(self) -> FormatChecker:
266
+ """The base formats plus any the test installed on ``formats``."""
267
+ return format_checker_with(self.formats)
268
+
269
+ def http(self, ref: str) -> httpx2.AsyncClient:
270
+ """Build a client whose transport is the test's own handler."""
271
+ if self.handler is None:
272
+ raise AssertionError("this test asked for an HTTP client but installed no handler")
273
+ return httpx2.AsyncClient(base_url="http://service.test", transport=httpx2.MockTransport(self.handler))
@@ -0,0 +1,48 @@
1
+ """Putting a test suite in a known environment: dirigent's own variables, colour, and width.
2
+
3
+ Both are explicit calls rather than import side effects, so installing the plugin never
4
+ rewrites the environment of a suite that did not ask for it.
5
+ """
6
+
7
+ import os
8
+ import shutil
9
+
10
+ import pytest
11
+
12
+ #: Prefixes of variables that configure dirigent at runtime.
13
+ CONFIGURING_PREFIXES = ("DIRIGENT_", "DG_", "OTEL_")
14
+
15
+ #: Width rich renders at under test, so an assertion never depends on the terminal.
16
+ TEST_WIDTH = "200"
17
+
18
+ _real_width: int | None = None
19
+
20
+
21
+ def pin_terminal() -> int:
22
+ """Fix width and colour for everything built afterwards, and return the real width.
23
+
24
+ A console reads both when it is built, which happens at import, so this belongs at the
25
+ top of a conftest rather than in a fixture. The width returned is the one the terminal
26
+ reported the first time, which the pinning then hides.
27
+ """
28
+ global _real_width
29
+ if _real_width is None:
30
+ _real_width = shutil.get_terminal_size().columns
31
+ os.environ["COLUMNS"] = TEST_WIDTH
32
+ os.environ["TERMINAL_WIDTH"] = TEST_WIDTH # typer renders help at this width
33
+ # A test that reads what a command printed must see the same text whether or not a
34
+ # terminal is attached, and rich decides colour when a console is built.
35
+ os.environ["NO_COLOR"] = "1"
36
+ return _real_width
37
+
38
+
39
+ def scrub_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
40
+ """Take every variable configuring dirigent out of one test's environment.
41
+
42
+ A developer with DIRIGENT_ENABLED_UNSAFE_BLOCKS set for manual testing would otherwise
43
+ turn the tests that prove the guard refuses into passes.
44
+ """
45
+ for name in list(os.environ):
46
+ if name.startswith(CONFIGURING_PREFIXES):
47
+ monkeypatch.delenv(name, raising=False)
48
+ monkeypatch.setenv("COLUMNS", TEST_WIDTH)
@@ -0,0 +1,58 @@
1
+ """The pytest plugin: the fixtures a block author gets from installing this package.
2
+
3
+ Registered by entry point, so the names live in every consumer's suite. They are prefixed
4
+ to say what they are handed by, because a consumer's own fixtures got there first.
5
+ """
6
+
7
+ from collections.abc import Iterator
8
+ from pathlib import Path
9
+ from typing import Final
10
+
11
+ import pytest
12
+
13
+ from dirigent_testing.doubles import FakeContext, FakeStorage
14
+ from dirigent_testing.environment import scrub_configuration
15
+
16
+ #: The server the lanes that need a real PostgreSQL start for themselves.
17
+ POSTGRES_IMAGE: Final = "postgres:17-alpine"
18
+
19
+
20
+ @pytest.fixture(scope="session")
21
+ def postgres_url() -> Iterator[str]:
22
+ """Start a real PostgreSQL for the session, and hand back an asyncpg URL.
23
+
24
+ Session-scoped, so every lane that asks for it shares one container.
25
+ """
26
+ postgres = pytest.importorskip("testcontainers.community.postgres")
27
+ with postgres.PostgresContainer(POSTGRES_IMAGE, driver="asyncpg") as container:
28
+ yield container.get_connection_url()
29
+
30
+
31
+ @pytest.fixture
32
+ def block_storage(tmp_path: Path) -> FakeStorage:
33
+ """A storage facade over a throwaway directory."""
34
+ return FakeStorage(tmp_path)
35
+
36
+
37
+ @pytest.fixture
38
+ def block_ctx(block_storage: FakeStorage, tmp_path: Path) -> FakeContext:
39
+ """A context wired to that storage, with a scratch prefix inside it."""
40
+ return FakeContext(block_storage, "file://scratch", work=tmp_path / "work" / "runs" / "one")
41
+
42
+
43
+ @pytest.fixture
44
+ def local_block_ctx(tmp_path: Path) -> FakeContext:
45
+ """A context whose scratch is a real file:// prefix, for a block that reads it as files."""
46
+ root = tmp_path / "artifacts"
47
+ root.mkdir()
48
+ return FakeContext(FakeStorage(root), f"file://{root}/runs/one", work=tmp_path / "work" / "runs" / "one")
49
+
50
+
51
+ @pytest.fixture
52
+ def defaults_only_environment(monkeypatch: pytest.MonkeyPatch) -> None:
53
+ """Run a test against defaults, whatever the shell exports.
54
+
55
+ Requested rather than autouse: a distributed plugin must not strip the environment of a
56
+ suite that never asked it to, so a consumer opts in -- once, from its own conftest.
57
+ """
58
+ scrub_configuration(monkeypatch)
File without changes
@@ -0,0 +1,27 @@
1
+ """Making one block call from a test, through the validation the engine puts in front of it."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from dirigent_plugin import AnyOperator, AnySensor, Operator, StepContext
9
+ from dirigent_testing.doubles import FakeContext
10
+
11
+
12
+ async def call_block(
13
+ block: AnyOperator | AnySensor,
14
+ config: Mapping[str, Any],
15
+ ctx: StepContext | FakeContext,
16
+ ) -> BaseModel:
17
+ """Validate the config the way the engine would, then make the block's first call.
18
+
19
+ An operator's execute or a sensor's poke, with the validated config -- so a test
20
+ exercises the same validation path a real run does, and a config the schema refuses
21
+ fails here rather than silently succeeding with defaults.
22
+ """
23
+ context = ctx.as_context() if isinstance(ctx, FakeContext) else ctx
24
+ validated = block.config_model.model_validate(config)
25
+ if isinstance(block, Operator):
26
+ return await block.execute(validated, context)
27
+ return await block.poke(validated, context)