dirigent-testing 0.17.2__tar.gz → 0.17.3__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.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-testing
3
- Version: 0.17.2
3
+ Version: 0.17.3
4
4
  Summary: Test doubles and pytest fixtures for writing and testing dirigent blocks.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Programming Language :: Python :: 3.13
9
- Requires-Dist: dirigent-common==0.17.2
10
- Requires-Dist: dirigent-plugin==0.17.2
9
+ Requires-Dist: dirigent-common==0.17.3
10
+ Requires-Dist: dirigent-plugin==0.17.3
11
11
  Requires-Dist: httpx2>=2.12.0
12
12
  Requires-Dist: pydantic>=2.13.5
13
13
  Requires-Dist: pytest>=9.1.1
@@ -24,6 +24,7 @@ Installing it registers the fixtures by entry point, so a block author writes no
24
24
  `call_block` validates a config the way the engine does before making the block's first call.
25
25
 
26
26
  `check_pack_examples` checks a pack's own example documents against its own `Contribution`
27
- -- format, code, blocks, config schemas, and carried connections -- without importing
27
+ -- format, code, blocks, config schemas, and the connections a step names, which the
28
+ document may either carry or list under `requires.connections` -- without importing
28
29
  dirigent-core, and `assert_contribution_conforms` checks the blocks a contribution provides
29
30
  are well-formed. Both return a list of human-readable issues; an empty list means it passed.
@@ -7,6 +7,7 @@ Installing it registers the fixtures by entry point, so a block author writes no
7
7
  `call_block` validates a config the way the engine does before making the block's first call.
8
8
 
9
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
10
+ -- format, code, blocks, config schemas, and the connections a step names, which the
11
+ document may either carry or list under `requires.connections` -- without importing
11
12
  dirigent-core, and `assert_contribution_conforms` checks the blocks a contribution provides
12
13
  are well-formed. Both return a list of human-readable issues; an empty list means it passed.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-testing"
3
- version = "0.17.2"
3
+ version = "0.17.3"
4
4
  description = "Test doubles and pytest fixtures for writing and testing dirigent blocks."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,8 +11,8 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-common==0.17.2",
15
- "dirigent-plugin==0.17.2",
14
+ "dirigent-common==0.17.3",
15
+ "dirigent-plugin==0.17.3",
16
16
  "httpx2>=2.12.0",
17
17
  "pydantic>=2.13.5",
18
18
  "pytest>=9.1.1",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-testing"
3
- version = "0.17.2"
3
+ version = "0.17.3"
4
4
  description = "Test doubles and pytest fixtures for writing and testing dirigent blocks."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,8 +11,8 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-common==0.17.2",
15
- "dirigent-plugin==0.17.2",
14
+ "dirigent-common==0.17.3",
15
+ "dirigent-plugin==0.17.3",
16
16
  "httpx2>=2.12.0",
17
17
  "pydantic>=2.13.5",
18
18
  "pytest>=9.1.1",
@@ -1,13 +1,14 @@
1
1
  """Test doubles and pytest fixtures for writing and testing dirigent blocks."""
2
2
 
3
3
  from dirigent_testing.conformance import assert_contribution_conforms, check_pack_examples
4
- from dirigent_testing.doubles import FakeContext, FakeRuns, FakeSink, FakeStorage, RecordingLogger
4
+ from dirigent_testing.doubles import FakeCapture, FakeContext, FakeRuns, FakeSink, FakeStorage, RecordingLogger
5
5
  from dirigent_testing.environment import CONFIGURING_PREFIXES, TEST_WIDTH, pin_terminal, scrub_configuration
6
6
  from dirigent_testing.running import call_block
7
7
 
8
8
  __all__ = [
9
9
  "CONFIGURING_PREFIXES",
10
10
  "TEST_WIDTH",
11
+ "FakeCapture",
11
12
  "FakeContext",
12
13
  "FakeRuns",
13
14
  "FakeSink",
@@ -6,8 +6,9 @@ conformance check re-derives the structural half here, over the pack's own ``Con
6
6
  and its own example documents. It is deliberately lighter than the engine's preflight: it
7
7
  proves an example is a well-formed ``dirigent/v1`` pipeline coded after its file, that every
8
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.
9
+ schema, and that a connection a step names is one the document accounts for -- carried, so
10
+ the document runs alone, or named under ``requires``. It does not resolve references, run
11
+ blocks, or reach for an instance.
11
12
  """
12
13
 
13
14
  import re
@@ -76,9 +77,9 @@ def check_pack_examples(contribution: Contribution, examples_dir: Path) -> list[
76
77
  is a ``dirigent/v1`` pipeline coded after its file with a description, that every block a
77
78
  step names is one ``contribution`` provides, that each step's config fits that block's
78
79
  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.
80
+ a step names is either carried by the document's own ``connections`` block or named under
81
+ its ``requires.connections``. Every issue is a human-readable line prefixed with the file
82
+ it was found in; an empty list means the examples conform.
82
83
  """
83
84
  blocks = _blocks_by_id(contribution)
84
85
  issues: list[str] = []
@@ -118,15 +119,25 @@ def _check_document(path: Path, label: Path, blocks: dict[str, AnyOperator | Any
118
119
  if not document.get("description"):
119
120
  issues.append(f"{label}: has no description")
120
121
 
121
- connections = document.get("connections")
122
- known = set(cast("JsonMap", connections)) if isinstance(connections, dict) else set[str]()
123
122
  steps = document.get("steps")
124
123
  if isinstance(steps, dict):
124
+ known = _known_connections(document)
125
125
  for name, step in cast("JsonMap", steps).items():
126
126
  issues.extend(_check_step(label, str(name), step, blocks, known))
127
127
  return issues
128
128
 
129
129
 
130
+ def _known_connections(document: JsonMap) -> set[str]:
131
+ """Every connection code a document accounts for: the ones it carries and the ones it names."""
132
+ carried = document.get("connections")
133
+ known = set(cast("JsonMap", carried)) if isinstance(carried, dict) else set[str]()
134
+ requires = document.get("requires")
135
+ named = cast("JsonMap", requires).get("connections") if isinstance(requires, dict) else None
136
+ if isinstance(named, list):
137
+ known |= {one for one in cast("list[object]", named) if isinstance(one, str)}
138
+ return known
139
+
140
+
130
141
  def _check_step(
131
142
  label: Path,
132
143
  name: str,
@@ -134,7 +145,10 @@ def _check_step(
134
145
  blocks: dict[str, AnyOperator | AnySensor],
135
146
  connections: set[str],
136
147
  ) -> list[str]:
137
- """Check one step: the block it names, the config it carries, the connection it references."""
148
+ """Check one step: the block it names, the config it carries, the connection it references.
149
+
150
+ ``connections`` is every code the document accounts for, carried or required.
151
+ """
138
152
  if not isinstance(step, dict):
139
153
  return [f"{label}: step {name!r} is not a mapping"]
140
154
  step_map = cast("JsonMap", step)
@@ -152,7 +166,9 @@ def _check_step(
152
166
 
153
167
  named = config.get("connection")
154
168
  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")
169
+ issues.append(
170
+ f"{label}: step {name!r} names connection {named!r}, which the document neither carries nor requires"
171
+ )
156
172
  return issues
157
173
 
158
174
 
@@ -15,6 +15,7 @@ from pydantic import BaseModel, JsonValue
15
15
  from dirigent_common import format_checker_with
16
16
  from dirigent_plugin import (
17
17
  ByteSink,
18
+ Capture,
18
19
  FormatCheck,
19
20
  RunRefused,
20
21
  RunSnapshot,
@@ -71,6 +72,19 @@ class FakeSink:
71
72
  self._handle.close()
72
73
 
73
74
 
75
+ class FakeCapture:
76
+ """A captured stream in a test: a sink carrying the URI it was opened under."""
77
+
78
+ def __init__(self, uri: str, sink: ByteSink) -> None:
79
+ """Bind the sink to the URI naming it."""
80
+ self.uri = uri
81
+ self._sink = sink
82
+
83
+ async def write(self, data: bytes) -> int:
84
+ """Append bytes to the stream and return how many were accepted."""
85
+ return await self._sink.write(data)
86
+
87
+
74
88
  class FakeStorage:
75
89
  """A storage facade over one directory, backing the storage blocks in a test."""
76
90
 
@@ -237,6 +251,22 @@ class FakeContext:
237
251
  """The run-scoped URI prefix."""
238
252
  return self.scratch_uri
239
253
 
254
+ def capture(self, name: str, *, content_type: str = "text/plain") -> AbstractAsyncContextManager[Capture]:
255
+ """Open a captured stream under the scratch prefix, named as the engine names it."""
256
+ uri = f"{self.scratch_uri.rstrip('/')}/{self._segment()}-{name}"
257
+
258
+ @asynccontextmanager
259
+ async def opened() -> AsyncGenerator[Capture]:
260
+ async with self.storage.open_write(uri, content_type=content_type) as sink:
261
+ yield FakeCapture(uri, sink)
262
+
263
+ return opened()
264
+
265
+ def _segment(self) -> str:
266
+ """The path this attempt's captures sit under: the step, the fan-out item, and the attempt."""
267
+ item = f"/{self.run_item_id}" if self.run_item_id is not None else ""
268
+ return f"{self.step}{item}/attempt-{self.attempt}"
269
+
240
270
  @property
241
271
  def work(self) -> Path:
242
272
  """The run's directory on this worker's own filesystem, made on first read.