dirigent-testing 0.26.1__tar.gz → 0.27.1__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.26.1
3
+ Version: 0.27.1
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.26.1
10
- Requires-Dist: dirigent-plugin==0.26.1
9
+ Requires-Dist: dirigent-common==0.27.1
10
+ Requires-Dist: dirigent-plugin==0.27.1
11
11
  Requires-Dist: httpx2>=2.12.0
12
12
  Requires-Dist: pydantic>=2.13.5
13
13
  Requires-Dist: pytest>=9.1.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-testing"
3
- version = "0.26.1"
3
+ version = "0.27.1"
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.26.1",
15
- "dirigent-plugin==0.26.1",
14
+ "dirigent-common==0.27.1",
15
+ "dirigent-plugin==0.27.1",
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.26.1"
3
+ version = "0.27.1"
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.26.1",
15
- "dirigent-plugin==0.26.1",
14
+ "dirigent-common==0.27.1",
15
+ "dirigent-plugin==0.27.1",
16
16
  "httpx2>=2.12.0",
17
17
  "pydantic>=2.13.5",
18
18
  "pytest>=9.1.1",
@@ -4,7 +4,7 @@ from dirigent_testing.conformance import assert_contribution_conforms, check_pac
4
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.fixtures import no_connection_outlives_its_loop
7
- from dirigent_testing.running import call_block, carry_cursor
7
+ from dirigent_testing.running import FakeReceiveContext, call_block, carry_cursor, receive_request
8
8
  from dirigent_testing.wording import check_pack_messages
9
9
 
10
10
  __all__ = [
@@ -12,6 +12,7 @@ __all__ = [
12
12
  "TEST_WIDTH",
13
13
  "FakeCapture",
14
14
  "FakeContext",
15
+ "FakeReceiveContext",
15
16
  "FakeRuns",
16
17
  "FakeSink",
17
18
  "FakeStorage",
@@ -23,5 +24,6 @@ __all__ = [
23
24
  "check_pack_messages",
24
25
  "no_connection_outlives_its_loop",
25
26
  "pin_terminal",
27
+ "receive_request",
26
28
  "scrub_configuration",
27
29
  ]
@@ -19,7 +19,7 @@ import yaml
19
19
  from jsonschema import Draft202012Validator
20
20
  from jsonschema import ValidationError as SchemaValidationError
21
21
  from jsonschema.validators import extend as extend_validator # pyright: ignore[reportUnknownVariableType]
22
- from pydantic import JsonValue
22
+ from pydantic import BaseModel, JsonValue
23
23
 
24
24
  from dirigent_common import BlockModel, HumaneJsonSchema, JsonMap
25
25
  from dirigent_plugin import AnyOperator, AnySensor, Contribution, mark_refusal
@@ -191,10 +191,11 @@ def assert_contribution_conforms(contribution: Contribution) -> list[str]:
191
191
  A ``Contribution`` already rejects colliding ids when it is built, and a block id is a
192
192
  pattern that forbids an empty one, so a valid contribution passes those. This adds the
193
193
  check the contract cannot make on its own: that every operator's and sensor's
194
- ``config_model`` and ``output_model`` is a :class:`BlockModel`. A connection kind's ``mark``
195
- is checked through :func:`dirigent_plugin.mark_refusal`, which is what registration holds
196
- it to, so a pack reads the same refusal here as it would at startup; a kind that declares
197
- none passes. An empty list means the contribution conforms.
194
+ ``config_model`` and ``output_model`` is a :class:`BlockModel`, and every source kind's is a
195
+ pydantic model. A connection or source kind's ``mark`` is checked through
196
+ :func:`dirigent_plugin.mark_refusal`, which is what registration holds it to, so a pack
197
+ reads the same refusal here as it would at startup; a kind that declares none passes. An
198
+ empty list means the contribution conforms.
198
199
  """
199
200
  issues: list[str] = []
200
201
  seen: set[str] = set()
@@ -214,4 +215,13 @@ def assert_contribution_conforms(contribution: Contribution) -> list[str]:
214
215
  refusal = mark_refusal(connection.id, connection.mark)
215
216
  if refusal is not None:
216
217
  issues.append(refusal)
218
+ for source in contribution.sources:
219
+ kind = source.spec.kind
220
+ for role in ("config_model", "output_model"):
221
+ model = getattr(source, role, None)
222
+ if not (isinstance(model, type) and issubclass(model, BaseModel)):
223
+ issues.append(f"source kind {kind} {role} is not a pydantic model")
224
+ refusal = mark_refusal(kind, source.mark)
225
+ if refusal is not None:
226
+ issues.append(refusal)
217
227
  return issues
@@ -0,0 +1,81 @@
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 jsonschema import FormatChecker
7
+ from pydantic import BaseModel
8
+
9
+ from dirigent_common.formats import base_format_checker
10
+ from dirigent_plugin import AnyOperator, AnySensor, Logger, NotYet, Operator, StepContext
11
+ from dirigent_plugin.sources import Ignore, InboundRequest, ReceivedSource, Refusal
12
+ from dirigent_testing.doubles import FakeContext, RecordingLogger
13
+
14
+
15
+ async def call_block(
16
+ block: AnyOperator | AnySensor,
17
+ config: Mapping[str, Any],
18
+ ctx: StepContext | FakeContext,
19
+ ) -> BaseModel:
20
+ """Validate the config the way the engine would, then make the block's first call.
21
+
22
+ An operator's execute or a sensor's poke, with the validated config -- so a test
23
+ exercises the same validation path a real run does, and a config the schema refuses
24
+ fails here rather than silently succeeding with defaults.
25
+ """
26
+ context = ctx.as_context() if isinstance(ctx, FakeContext) else ctx
27
+ validated = block.config_model.model_validate(config)
28
+ if isinstance(block, Operator):
29
+ return await block.execute(validated, context)
30
+ return await block.poke(validated, context)
31
+
32
+
33
+ def carry_cursor(sensor: AnySensor, answer: BaseModel, ctx: FakeContext) -> None:
34
+ """Hand the next poke the cursor the engine would, after one poke answered this.
35
+
36
+ A park that returned a cursor replaces the one the context held, and one that returned
37
+ none leaves it. A success hands on what :meth:`Sensor.resume_cursor` says, as a
38
+ ``sensor`` source hands it to the poke that starts its next wait.
39
+ """
40
+ if isinstance(answer, NotYet):
41
+ if answer.cursor is not None:
42
+ ctx.cursor = dict(answer.cursor)
43
+ return
44
+ resumed = sensor.resume_cursor(answer)
45
+ ctx.cursor = dict(resumed) if resumed is not None else None
46
+
47
+
48
+ class FakeReceiveContext:
49
+ """What a received kind may reach while it turns a request into an input, with its log kept."""
50
+
51
+ def __init__(self, format_checker: FormatChecker | None = None) -> None:
52
+ """Start with an empty log and the base format checker unless one is given."""
53
+ self.logger = RecordingLogger()
54
+ self._format_checker = format_checker or base_format_checker()
55
+
56
+ @property
57
+ def log(self) -> Logger:
58
+ """Where the kind says what it did with the request."""
59
+ return self.logger
60
+
61
+ @property
62
+ def format_checker(self) -> FormatChecker:
63
+ """The checker the kind's input is held to."""
64
+ return self._format_checker
65
+
66
+
67
+ async def receive_request(
68
+ source: ReceivedSource[Any, Any],
69
+ config: Mapping[str, Any],
70
+ request: InboundRequest,
71
+ ctx: FakeReceiveContext | None = None,
72
+ ) -> BaseModel | Ignore | Refusal:
73
+ """Validate the config, then take a request the way the intake does: its head, then its body.
74
+
75
+ A refusal on the head alone is answered before the body is read, as the server answers it.
76
+ """
77
+ validated = source.config_model.model_validate(config)
78
+ refused = source.admit(validated, request)
79
+ if refused is not None:
80
+ return refused
81
+ return await source.receive(validated, request, ctx or FakeReceiveContext())
@@ -1,42 +0,0 @@
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, NotYet, 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)
28
-
29
-
30
- def carry_cursor(sensor: AnySensor, answer: BaseModel, ctx: FakeContext) -> None:
31
- """Hand the next poke the cursor the engine would, after one poke answered this.
32
-
33
- A park that returned a cursor replaces the one the context held, and one that returned
34
- none leaves it. A success hands on what :meth:`Sensor.resume_cursor` says, as a watch
35
- hands it to the first poke of its next run.
36
- """
37
- if isinstance(answer, NotYet):
38
- if answer.cursor is not None:
39
- ctx.cursor = dict(answer.cursor)
40
- return
41
- resumed = sensor.resume_cursor(answer)
42
- ctx.cursor = dict(resumed) if resumed is not None else None