dirigent-block-base 0.17.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,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigent-block-base
3
+ Version: 0.17.0
4
+ Summary: The base block family for dirigent: logging, reporting, validation, values, time, pipeline.run, and the convert.std codec.
5
+ License-Expression: LicenseRef-Proprietary
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Requires-Dist: dirigent-common==0.17.0
10
+ Requires-Dist: dirigent-plugin==0.17.0
11
+ Requires-Dist: jsonschema>=4.26.0
12
+ Requires-Dist: pyyaml>=6.0.3
13
+ Requires-Dist: structlog>=25.5.0
14
+ Requires-Python: >=3.13
15
+ Description-Content-Type: text/markdown
16
+
17
+ # dirigent-block-base
18
+
19
+ The base block family: what a pipeline needs whatever it integrates with. `log.write` puts a
20
+ line in the run's log, `report.render` renders text from a template, `validate.schema` holds a
21
+ value to a JSON Schema, `value.const` emits a fixed value, `pipeline.run` starts another
22
+ pipeline and waits for it, `convert.std` trades between json, ndjson, csv, yaml and xml on
23
+ the standard library, and the `time.sleep` and `time.window` sensors park an attempt until a
24
+ moment arrives.
25
+
26
+ It also contributes the `log` notifier, the one alert channel with no connection kind beside
27
+ it: there is no credential to mint, and an instance with nothing configured still says
28
+ something when a rule fires.
@@ -0,0 +1,12 @@
1
+ # dirigent-block-base
2
+
3
+ The base block family: what a pipeline needs whatever it integrates with. `log.write` puts a
4
+ line in the run's log, `report.render` renders text from a template, `validate.schema` holds a
5
+ value to a JSON Schema, `value.const` emits a fixed value, `pipeline.run` starts another
6
+ pipeline and waits for it, `convert.std` trades between json, ndjson, csv, yaml and xml on
7
+ the standard library, and the `time.sleep` and `time.window` sensors park an attempt until a
8
+ moment arrives.
9
+
10
+ It also contributes the `log` notifier, the one alert channel with no connection kind beside
11
+ it: there is no credential to mint, and an instance with nothing configured still says
12
+ something when a rule fires.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "dirigent-block-base"
3
+ version = "0.17.0"
4
+ description = "The base block family for dirigent: logging, reporting, validation, values, time, pipeline.run, and the convert.std codec."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
13
+ dependencies = [
14
+ "dirigent-common==0.17.0",
15
+ "dirigent-plugin==0.17.0",
16
+ "jsonschema>=4.26.0",
17
+ "pyyaml>=6.0.3",
18
+ "structlog>=25.5.0",
19
+ ]
20
+
21
+ [project.entry-points."dirigent.plugins.v1"]
22
+ block-base = "dirigent_block_base:plugin"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.12.0,<0.13.0"]
26
+ build-backend = "uv_build"
27
+
28
+ [tool.uv.sources.dirigent-common]
29
+ workspace = true
30
+
31
+ [tool.uv.sources.dirigent-plugin]
32
+ workspace = true
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "dirigent-block-base"
3
+ version = "0.17.0"
4
+ description = "The base block family for dirigent: logging, reporting, validation, values, time, pipeline.run, and the convert.std codec."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
13
+ dependencies = [
14
+ "dirigent-common==0.17.0",
15
+ "dirigent-plugin==0.17.0",
16
+ "jsonschema>=4.26.0",
17
+ "pyyaml>=6.0.3",
18
+ "structlog>=25.5.0",
19
+ ]
20
+
21
+ [project.entry-points."dirigent.plugins.v1"]
22
+ block-base = "dirigent_block_base:plugin"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.12.0,<0.13.0"]
26
+ build-backend = "uv_build"
27
+
28
+ [tool.uv.sources]
29
+ dirigent-common = { workspace = true }
30
+ dirigent-plugin = { workspace = true }
@@ -0,0 +1,48 @@
1
+ """The base block family: what a pipeline needs whatever it integrates with."""
2
+
3
+ from dirigent_block_base.clock import TimeSleepSensor, TimeWindowSensor
4
+ from dirigent_block_base.convert_std import StdConverter
5
+ from dirigent_block_base.log_notifier import LogNotifier
6
+ from dirigent_block_base.logging import LogWriteOperator
7
+ from dirigent_block_base.pipelines import PipelineRunOperator
8
+ from dirigent_block_base.report import ReportRenderOperator
9
+ from dirigent_block_base.validate import ValidateSchemaOperator
10
+ from dirigent_block_base.values import ValueConstOperator
11
+ from dirigent_plugin import Contribution, extension
12
+
13
+
14
+ class BaseBlocks:
15
+ """The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
16
+
17
+ @extension
18
+ def contribute(self) -> Contribution:
19
+ """Contribute the base blocks and the log channel, which needs no credential to deliver."""
20
+ return Contribution(
21
+ operators=[
22
+ LogWriteOperator(),
23
+ ReportRenderOperator(),
24
+ ValidateSchemaOperator(),
25
+ ValueConstOperator(),
26
+ PipelineRunOperator(),
27
+ StdConverter(),
28
+ ],
29
+ sensors=[TimeSleepSensor(), TimeWindowSensor()],
30
+ notifiers=[LogNotifier()],
31
+ )
32
+
33
+
34
+ plugin = BaseBlocks()
35
+
36
+ __all__ = [
37
+ "BaseBlocks",
38
+ "LogNotifier",
39
+ "LogWriteOperator",
40
+ "PipelineRunOperator",
41
+ "ReportRenderOperator",
42
+ "StdConverter",
43
+ "TimeSleepSensor",
44
+ "TimeWindowSensor",
45
+ "ValidateSchemaOperator",
46
+ "ValueConstOperator",
47
+ "plugin",
48
+ ]
@@ -0,0 +1,221 @@
1
+ """Clock sensors: ``time.window`` waits for a window to open, ``time.sleep`` waits a duration."""
2
+
3
+ from datetime import UTC, date, datetime, time, timedelta
4
+ from typing import ClassVar, Final, Literal
5
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
6
+
7
+ from pydantic import BaseModel, Field, field_validator, model_validator
8
+
9
+ from dirigent_block_base.messages import (
10
+ EMPTY_WINDOW,
11
+ NOT_A_TIMEZONE,
12
+ )
13
+ from dirigent_common import BlockModel, Duration
14
+ from dirigent_plugin import NotYet, Sensor, SensorSpec, StepContext
15
+
16
+ type DayName = Literal["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
17
+
18
+ #: Indexed with ``date.weekday()``, so the order is that function's and not a preference.
19
+ WEEKDAYS: Final[tuple[DayName, ...]] = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
20
+
21
+ DEFAULT_TIMEZONE: Final = "UTC"
22
+
23
+ MAX_WAIT: Final = timedelta(hours=1)
24
+
25
+ MIN_WAIT: Final = timedelta(seconds=1)
26
+
27
+ SEARCH_DAYS: Final = 8
28
+
29
+ #: The shortest park a sleep asks for, so the last sliver of a wait is one poke and not many.
30
+ MIN_SLEEP_POLL: Final = timedelta(milliseconds=100)
31
+
32
+
33
+ class TimeWindowConfig(BlockModel):
34
+ """The window a step may proceed in, in one named timezone."""
35
+
36
+ after: time = time(0, 0)
37
+ """The local time the window opens."""
38
+
39
+ before: time = time(23, 59, 59)
40
+ """The local time it closes; earlier than ``after`` means the window crosses midnight."""
41
+
42
+ timezone: str = DEFAULT_TIMEZONE
43
+ """The IANA zone the window is read in. A window without one is a window in someone's head."""
44
+
45
+ days: list[DayName] = Field(default_factory=list["DayName"])
46
+ """The days the window opens on; empty means every day."""
47
+
48
+ @field_validator("timezone")
49
+ @classmethod
50
+ def _check_timezone(cls, value: str) -> str:
51
+ """Reject a zone this machine's database does not have, at validation and not at poke."""
52
+ try:
53
+ ZoneInfo(value)
54
+ except (ZoneInfoNotFoundError, ValueError) as error:
55
+ raise ValueError(NOT_A_TIMEZONE.render(value=repr(value))) from error
56
+ return value
57
+
58
+ @model_validator(mode="after")
59
+ def _check_window(self) -> "TimeWindowConfig":
60
+ """Reject a window of zero width, which no clock is ever inside."""
61
+ if self.after == self.before:
62
+ raise ValueError(EMPTY_WINDOW.render(after=self.after, before=self.before))
63
+ return self
64
+
65
+ @property
66
+ def zone(self) -> ZoneInfo:
67
+ """The zone object the window is evaluated in."""
68
+ return ZoneInfo(self.timezone)
69
+
70
+ @property
71
+ def crosses_midnight(self) -> bool:
72
+ """Report whether this window opens on one day and closes on the next."""
73
+ return self.after > self.before
74
+
75
+ def opens_on(self, day: date) -> bool:
76
+ """Report whether a window opens on a given date."""
77
+ return not self.days or WEEKDAYS[day.weekday()] in self.days
78
+
79
+ def opening(self, day: date) -> datetime:
80
+ """The moment the window opens on a given date, in its own zone."""
81
+ return datetime.combine(day, self.after, tzinfo=self.zone)
82
+
83
+
84
+ class TimeWindowOutput(BlockModel):
85
+ """The observation that the clock is inside the window, passed downstream like any output."""
86
+
87
+ entered_at: datetime
88
+ """When the window this poke fell inside opened, not when the poke happened."""
89
+
90
+ timezone: str
91
+
92
+
93
+ class TimeWindowSensor(Sensor[TimeWindowConfig, TimeWindowOutput]):
94
+ """Waits until the local clock in a named timezone is inside a window."""
95
+
96
+ spec = SensorSpec(
97
+ id="time.window",
98
+ summary="Wait until the local clock is inside a time window.",
99
+ default_poll=timedelta(minutes=1),
100
+ default_deadline=timedelta(hours=24),
101
+ )
102
+ config_model: ClassVar[type[BaseModel]] = TimeWindowConfig
103
+ output_model: ClassVar[type[BaseModel]] = TimeWindowOutput
104
+
105
+ async def poke(self, config: TimeWindowConfig, ctx: StepContext) -> TimeWindowOutput | NotYet:
106
+ """Look at one clock once, and either proceed or say how long the wait is."""
107
+ moment = now_in(config.zone)
108
+ entered = entered_at(config, moment)
109
+ if entered is not None:
110
+ # The stream carries the time of every line, so a block repeating it says nothing
111
+ # the reader cannot already see; the window it opened is in the output.
112
+ ctx.log.info("the window is open", timezone=config.timezone)
113
+ return TimeWindowOutput(entered_at=entered, timezone=config.timezone)
114
+ wait = wait_for(config, moment)
115
+ ctx.log.debug("the window is closed", wait_seconds=round(wait.total_seconds(), 1), timezone=config.timezone)
116
+ return NotYet(next_poll_in=wait)
117
+
118
+
119
+ def now_in(zone: ZoneInfo) -> datetime:
120
+ """Read the current moment in one zone."""
121
+ return datetime.now(zone)
122
+
123
+
124
+ def entered_at(config: TimeWindowConfig, moment: datetime) -> datetime | None:
125
+ """Return when the window the moment falls inside opened, or None when it is closed."""
126
+ local = moment.astimezone(config.zone)
127
+ today = local.date()
128
+ clock = local.timetz().replace(tzinfo=None)
129
+ if not config.crosses_midnight:
130
+ if config.after <= clock < config.before and config.opens_on(today):
131
+ return config.opening(today)
132
+ return None
133
+ if clock >= config.after and config.opens_on(today):
134
+ return config.opening(today)
135
+ yesterday = today - timedelta(days=1)
136
+ if clock < config.before and config.opens_on(yesterday):
137
+ return config.opening(yesterday)
138
+ return None
139
+
140
+
141
+ def next_opening(config: TimeWindowConfig, moment: datetime) -> datetime | None:
142
+ """Find the next moment the window opens, searching a little over a week ahead."""
143
+ local = moment.astimezone(config.zone)
144
+ for offset in range(SEARCH_DAYS):
145
+ day = local.date() + timedelta(days=offset)
146
+ if not config.opens_on(day):
147
+ continue
148
+ opening = config.opening(day)
149
+ if opening > local:
150
+ return opening
151
+ return None # pragma: no cover - a non-empty day set always opens within eight days
152
+
153
+
154
+ def wait_for(config: TimeWindowConfig, moment: datetime) -> timedelta:
155
+ """Say how long to park a closed window, bounded at both ends."""
156
+ opening = next_opening(config, moment)
157
+ if opening is None: # pragma: no cover - next_opening always finds one
158
+ return MAX_WAIT
159
+ return max(MIN_WAIT, min(opening - moment.astimezone(config.zone), MAX_WAIT))
160
+
161
+
162
+ class TimeSleepConfig(BlockModel):
163
+ """How long the wait lasts."""
164
+
165
+ wait_for: Duration = Field(alias="for")
166
+ """How long to wait, measured from when the attempt started.
167
+
168
+ The step's deadline still ends the wait, so a `for` longer than a day needs a `deadline`
169
+ beside it: the sensor default is 24 hours."""
170
+
171
+
172
+ class TimeSleepOutput(BlockModel):
173
+ """What the wait amounted to, passed downstream like any other output."""
174
+
175
+ started_at: datetime
176
+ """When the wait began, which is when the attempt started and not when a poke ran."""
177
+
178
+ waited_ms: int
179
+ """How long the wait actually lasted, which is the configured duration plus poll latency."""
180
+
181
+
182
+ class TimeSleepSensor(Sensor[TimeSleepConfig, TimeSleepOutput]):
183
+ """Waits a fixed duration without occupying a worker.
184
+
185
+ Each poke reads the clock and returns, so an hour's wait is a parked row rather than an
186
+ hour of a worker's concurrency. This is the block to reach for instead of `shell.run`
187
+ with `sleep`, which asks an instance to allowlist arbitrary code execution in order to do
188
+ something harmless.
189
+
190
+ The deadline is anchored on the attempt's start, so a worker restarting mid-wait resumes
191
+ the same wait instead of starting the clock again.
192
+ """
193
+
194
+ spec = SensorSpec(
195
+ id="time.sleep",
196
+ summary="Wait a fixed duration.",
197
+ default_poll=timedelta(seconds=1),
198
+ default_deadline=timedelta(hours=24),
199
+ )
200
+ config_model: ClassVar[type[BaseModel]] = TimeSleepConfig
201
+ output_model: ClassVar[type[BaseModel]] = TimeSleepOutput
202
+
203
+ async def poke(self, config: TimeSleepConfig, ctx: StepContext) -> TimeSleepOutput | NotYet:
204
+ """Read the clock once: finish, or say how much of the wait is left."""
205
+ moment = datetime.now(UTC)
206
+ remaining = ctx.started_at + config.wait_for - moment
207
+ if remaining > timedelta(0):
208
+ ctx.log.debug(
209
+ "still waiting",
210
+ waited_ms=milliseconds(moment - ctx.started_at),
211
+ remaining_ms=milliseconds(remaining),
212
+ )
213
+ return NotYet(next_poll_in=max(remaining, MIN_SLEEP_POLL))
214
+ waited = moment - ctx.started_at
215
+ ctx.log.info("the wait is over", waited_ms=milliseconds(waited))
216
+ return TimeSleepOutput(started_at=ctx.started_at, waited_ms=milliseconds(waited))
217
+
218
+
219
+ def milliseconds(value: timedelta) -> int:
220
+ """Render a duration as the whole milliseconds emitted data measures timings in."""
221
+ return round(value.total_seconds() * 1000)