dirigent-block-base 0.17.0__py3-none-any.whl
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.
- dirigent_block_base/__init__.py +48 -0
- dirigent_block_base/clock.py +221 -0
- dirigent_block_base/convert_std.py +535 -0
- dirigent_block_base/log_notifier.py +44 -0
- dirigent_block_base/logging.py +63 -0
- dirigent_block_base/messages.py +155 -0
- dirigent_block_base/pipelines.py +162 -0
- dirigent_block_base/py.typed +0 -0
- dirigent_block_base/report.py +86 -0
- dirigent_block_base/validate.py +81 -0
- dirigent_block_base/values.py +40 -0
- dirigent_block_base-0.17.0.dist-info/METADATA +28 -0
- dirigent_block_base-0.17.0.dist-info/RECORD +16 -0
- dirigent_block_base-0.17.0.dist-info/WHEEL +4 -0
- dirigent_block_base-0.17.0.dist-info/entry_points.txt +3 -0
- dirigent_block_base-0.17.0.dist-info/licenses/LICENSE +18 -0
|
@@ -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)
|