dirigent-blocks 0.9.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.
@@ -0,0 +1,289 @@
1
+ """Capturing what a local process printed: decode it, log it, store it, quote it."""
2
+
3
+ import asyncio
4
+ from collections.abc import Sequence
5
+ from typing import NamedTuple
6
+
7
+ from dirigent_plugin import ByteSink, StepContext
8
+
9
+ LOGGED_LINES = 40
10
+
11
+ FAILURE_TAIL_LINES = 5
12
+
13
+ #: What a scrubbed secret is replaced by, matching the marker the API redacts a connection with.
14
+ REDACTED = "***"
15
+
16
+
17
+ class Captured(NamedTuple):
18
+ """One stream as an output carries it: the head that inlines, and the truth about the rest."""
19
+
20
+ text: str
21
+ """The head of the stream, as much of it as the instance lets a block inline."""
22
+
23
+ total_bytes: int
24
+ """How much the process printed, whether it inlined or not."""
25
+
26
+ truncated: bool
27
+ """Whether the text above is short of the stream; the artifact URI always holds all of it."""
28
+
29
+
30
+ def captured(payload: bytes, limit: int) -> Captured:
31
+ """Cut one captured stream down to what may be inlined, and say what was cut."""
32
+ return Captured(text=decode(payload[:limit]), total_bytes=len(payload), truncated=len(payload) > limit)
33
+
34
+
35
+ #: How much of the end of a stream is kept, for the lines a failure message quotes. The head
36
+ #: is what a block may inline; this is the other end, and together they bound what is held.
37
+ TAIL_BYTES = 8 * 1024
38
+
39
+ #: How much is read from a pipe at a time.
40
+ CHUNK_BYTES = 64 * 1024
41
+
42
+
43
+ class Logged(NamedTuple):
44
+ """What a stream put into the run log while it was still being read."""
45
+
46
+ lines: int
47
+ """Non-blank lines the stream carried."""
48
+
49
+ live: int
50
+ """How many of them reached the run log as they arrived."""
51
+
52
+
53
+ class Drained(NamedTuple):
54
+ """What was kept of a stream that was written through to storage as it arrived."""
55
+
56
+ head: bytes
57
+ """The beginning, as much as may be inlined."""
58
+
59
+ tail: bytes
60
+ """The end, for the lines a failure quotes."""
61
+
62
+ total_bytes: int
63
+ """How much the process printed, all of which reached storage."""
64
+
65
+ logged: Logged | None = None
66
+ """What was already logged live, or ``None`` when the stream was not logged as it arrived."""
67
+
68
+ def captured(self, limit: int) -> Captured:
69
+ """Present the head as an output carries it, against what the whole stream was."""
70
+ return Captured(
71
+ text=decode(self.head[:limit]),
72
+ total_bytes=self.total_bytes,
73
+ truncated=self.total_bytes > limit,
74
+ )
75
+
76
+
77
+ class Ends:
78
+ """Both ends of a stream and its size, holding nothing of the middle.
79
+
80
+ A stream is fed in as it arrives. What is kept is the head, which is what may be inlined,
81
+ and the last few kilobytes, which is what a failure message quotes.
82
+ """
83
+
84
+ def __init__(self, head_limit: int) -> None:
85
+ """Keep at most this much of the beginning."""
86
+ self._head_limit = head_limit
87
+ self._head = bytearray()
88
+ self._tail = bytearray()
89
+ self.total_bytes = 0
90
+
91
+ def add(self, chunk: bytes) -> None:
92
+ """Take one piece of the stream."""
93
+ self.total_bytes += len(chunk)
94
+ if len(self._head) < self._head_limit:
95
+ self._head.extend(chunk[: self._head_limit - len(self._head)])
96
+ self._tail.extend(chunk)
97
+ if len(self._tail) > TAIL_BYTES:
98
+ del self._tail[: len(self._tail) - TAIL_BYTES]
99
+
100
+ def drained(self, live: "LiveLines | None" = None) -> Drained:
101
+ """Present what was kept, and what a live logger already said about it."""
102
+ return Drained(
103
+ head=bytes(self._head),
104
+ tail=bytes(self._tail),
105
+ total_bytes=self.total_bytes,
106
+ logged=live.logged() if live is not None else None,
107
+ )
108
+
109
+
110
+ class LiveLines:
111
+ """Puts the first lines of a stream into the run log as the process prints them.
112
+
113
+ Bytes are fed in as they are read. Every complete non-blank line is counted, and the
114
+ first ``budget`` of them are logged where they happen, so a run screen shows a long
115
+ command working rather than everything it printed at the moment it exited. Past the
116
+ budget nothing is held: the line is counted and dropped, and the other end of the stream
117
+ reaches the log when the stream closes.
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ ctx: StepContext,
123
+ stream: str,
124
+ *,
125
+ budget: int = LOGGED_LINES // 2,
126
+ redact: Sequence[str] = (),
127
+ ) -> None:
128
+ """Log to this stream's level, redacting the strings the block handed the process."""
129
+ self._log = ctx.log.info if stream == "stdout" else ctx.log.warning
130
+ self._stream = stream
131
+ self._budget = budget
132
+ self._redact = redact
133
+ self._pending = bytearray()
134
+ self._nonblank = False
135
+ self._lines = 0
136
+ self._live = 0
137
+
138
+ def feed(self, chunk: bytes) -> None:
139
+ """Take one piece of the stream, logging every complete line it completes."""
140
+ start = 0
141
+ while (cut := chunk.find(b"\n", start)) >= 0:
142
+ self._take(chunk[start:cut])
143
+ self._finish()
144
+ start = cut + 1
145
+ self._take(chunk[start:])
146
+
147
+ def close(self) -> None:
148
+ """Log a last line the process left without a newline."""
149
+ self._finish()
150
+
151
+ def logged(self) -> Logged:
152
+ """How many lines the stream carried and how many of them are in the log already."""
153
+ return Logged(lines=self._lines, live=self._live)
154
+
155
+ def _take(self, piece: bytes) -> None:
156
+ """Hold a piece of the line being read, or only whether it has anything in it."""
157
+ if not piece:
158
+ return
159
+ self._nonblank = self._nonblank or bool(piece.strip())
160
+ if self._live < self._budget:
161
+ self._pending.extend(piece)
162
+
163
+ def _finish(self) -> None:
164
+ """End the line being read: count it, and log it while the budget lasts."""
165
+ if self._nonblank:
166
+ self._lines += 1
167
+ if self._live < self._budget:
168
+ self._live += 1
169
+ self._log(scrub(decode(bytes(self._pending)).rstrip("\r"), self._redact), stream=self._stream)
170
+ self._pending.clear()
171
+ self._nonblank = False
172
+
173
+
174
+ async def drain(
175
+ reader: "asyncio.StreamReader | None",
176
+ sink: ByteSink,
177
+ *,
178
+ head_limit: int,
179
+ live: LiveLines | None = None,
180
+ ) -> Drained:
181
+ """Read one pipe to its end, writing it through to storage and keeping both ends.
182
+
183
+ Nothing holds the whole stream. A command that prints a gigabyte is a file in storage and
184
+ two bounded buffers here, rather than a gigabyte in the worker.
185
+
186
+ With a ``live`` logger the lines go into the run log as they are read, up to its budget.
187
+ """
188
+ ends = Ends(head_limit)
189
+ while reader is not None:
190
+ chunk = await reader.read(CHUNK_BYTES)
191
+ if not chunk:
192
+ break
193
+ ends.add(chunk)
194
+ if live is not None:
195
+ live.feed(chunk)
196
+ await sink.write(chunk)
197
+ if live is not None:
198
+ live.close()
199
+ return ends.drained(live)
200
+
201
+
202
+ def decode(payload: bytes) -> str:
203
+ """Render captured bytes as text without ever failing on what a process printed."""
204
+ return payload.decode("utf-8", errors="replace")
205
+
206
+
207
+ def lines_of(payload: bytes) -> list[str]:
208
+ """Split a captured stream into its non-blank lines."""
209
+ return [line for line in decode(payload).splitlines() if line.strip()]
210
+
211
+
212
+ def scrub(text: str, secrets: Sequence[str] = ()) -> str:
213
+ """Replace every occurrence of a secret with the redaction marker.
214
+
215
+ A tool prints what it was given. ``git`` echoes a remote URL, an ssh command line and,
216
+ when a credential is wrong, sometimes the credential itself, so a block that hands a
217
+ process a secret hands this the same strings and nothing reaches a log or a failure
218
+ message with them still in it. Longest first, so a secret that contains another is
219
+ replaced whole rather than left in pieces.
220
+ """
221
+ for secret in sorted((one for one in secrets if one), key=len, reverse=True):
222
+ text = text.replace(secret, REDACTED)
223
+ return text
224
+
225
+
226
+ def tail(
227
+ payload: bytes,
228
+ *,
229
+ count: int = FAILURE_TAIL_LINES,
230
+ separator: str = " / ",
231
+ redact: Sequence[str] = (),
232
+ ) -> str:
233
+ """Take the last few lines of a stream, which is what a failure message wants."""
234
+ return scrub(separator.join(lines_of(payload)[-count:]), redact)
235
+
236
+
237
+ async def store(ctx: StepContext, uri: str, payload: bytes) -> None:
238
+ """Write one captured stream to storage, so it outlives the worker that produced it."""
239
+ async with ctx.storage.open_write(uri) as sink:
240
+ await sink.write(payload)
241
+
242
+
243
+ def log_stream(ctx: StepContext, stream: str, drained: Drained, redact: Sequence[str] = ()) -> None:
244
+ """Put what the process printed into the run's log, from both ends of the stream.
245
+
246
+ A caller that read the stream through to storage holds only its two ends, and identical
247
+ ends are how one small enough to be held whole says so. The whole of a stream is elided
248
+ by line count; what is held in two pieces can only be logged as two pieces.
249
+
250
+ A stream that was logged live already has its first lines in the log: this adds the last
251
+ of them, and only the ones that were never said.
252
+
253
+ ``redact`` names the strings a block handed the process that must not reach the log.
254
+ """
255
+ log = ctx.log.info if stream == "stdout" else ctx.log.warning
256
+ half = LOGGED_LINES // 2
257
+ if drained.logged is not None:
258
+ remaining = drained.logged.lines - drained.logged.live
259
+ if remaining <= 0:
260
+ return
261
+ last = lines_of(drained.tail)[-min(remaining, half) :]
262
+ if remaining > len(last):
263
+ log(f"... {remaining - len(last)} more lines, see the {stream} artifact ...", stream=stream)
264
+ for line in last:
265
+ log(scrub(line, redact), stream=stream)
266
+ return
267
+ if drained.head == drained.tail:
268
+ lines = lines_of(drained.head)
269
+ if not lines:
270
+ return
271
+ shown = lines
272
+ if len(lines) > LOGGED_LINES:
273
+ shown = [
274
+ *lines[:half],
275
+ f"... {len(lines) - LOGGED_LINES} more lines, see the {stream} artifact ...",
276
+ *lines[-half:],
277
+ ]
278
+ for line in shown:
279
+ log(scrub(line, redact), stream=stream)
280
+ return
281
+ first = lines_of(drained.head)
282
+ last = lines_of(drained.tail)
283
+ if not first and not last:
284
+ return
285
+ for line in first[:half]:
286
+ log(scrub(line, redact), stream=stream)
287
+ log(f"... the middle is not in the log, see the {stream} artifact ...", stream=stream)
288
+ for line in last[-half:]:
289
+ log(scrub(line, redact), stream=stream)
@@ -0,0 +1,220 @@
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_common import BlockModel, Duration
10
+ from dirigent_plugin import NotYet, Sensor, SensorSpec, StepContext
11
+
12
+ type DayName = Literal["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
13
+
14
+ #: Indexed with ``date.weekday()``, so the order is that function's and not a preference.
15
+ WEEKDAYS: Final[tuple[DayName, ...]] = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
16
+
17
+ DEFAULT_TIMEZONE: Final = "UTC"
18
+
19
+ MAX_WAIT: Final = timedelta(hours=1)
20
+
21
+ MIN_WAIT: Final = timedelta(seconds=1)
22
+
23
+ SEARCH_DAYS: Final = 8
24
+
25
+ #: The shortest park a sleep asks for, so the last sliver of a wait is one poke and not many.
26
+ MIN_SLEEP_POLL: Final = timedelta(milliseconds=100)
27
+
28
+
29
+ class TimeWindowConfig(BlockModel):
30
+ """The window a step may proceed in, in one named timezone."""
31
+
32
+ after: time = time(0, 0)
33
+ """The local time the window opens."""
34
+
35
+ before: time = time(23, 59, 59)
36
+ """The local time it closes; earlier than ``after`` means the window crosses midnight."""
37
+
38
+ timezone: str = DEFAULT_TIMEZONE
39
+ """The IANA zone the window is read in. A window without one is a window in someone's head."""
40
+
41
+ days: list[DayName] = Field(default_factory=list["DayName"])
42
+ """The days the window opens on; empty means every day."""
43
+
44
+ @field_validator("timezone")
45
+ @classmethod
46
+ def _check_timezone(cls, value: str) -> str:
47
+ """Reject a zone this machine's database does not have, at validation and not at poke."""
48
+ try:
49
+ ZoneInfo(value)
50
+ except (ZoneInfoNotFoundError, ValueError) as error:
51
+ raise ValueError(f"{value!r} is not an IANA timezone name, such as 'Europe/Oslo' or 'UTC'") from error
52
+ return value
53
+
54
+ @model_validator(mode="after")
55
+ def _check_window(self) -> "TimeWindowConfig":
56
+ """Reject a window of zero width, which no clock is ever inside."""
57
+ if self.after == self.before:
58
+ raise ValueError(
59
+ f"a window from {self.after} to {self.before} is empty; "
60
+ f"omit the sensor rather than writing a window nothing falls in"
61
+ )
62
+ return self
63
+
64
+ @property
65
+ def zone(self) -> ZoneInfo:
66
+ """The zone object the window is evaluated in."""
67
+ return ZoneInfo(self.timezone)
68
+
69
+ @property
70
+ def crosses_midnight(self) -> bool:
71
+ """Report whether this window opens on one day and closes on the next."""
72
+ return self.after > self.before
73
+
74
+ def opens_on(self, day: date) -> bool:
75
+ """Report whether a window opens on a given date."""
76
+ return not self.days or WEEKDAYS[day.weekday()] in self.days
77
+
78
+ def opening(self, day: date) -> datetime:
79
+ """The moment the window opens on a given date, in its own zone."""
80
+ return datetime.combine(day, self.after, tzinfo=self.zone)
81
+
82
+
83
+ class TimeWindowOutput(BlockModel):
84
+ """The observation that the clock is inside the window, passed downstream like any output."""
85
+
86
+ entered_at: datetime
87
+ """When the window this poke fell inside opened, not when the poke happened."""
88
+
89
+ timezone: str
90
+
91
+
92
+ class TimeWindowSensor(Sensor[TimeWindowConfig, TimeWindowOutput]):
93
+ """Waits until the local clock in a named timezone is inside a window."""
94
+
95
+ spec = SensorSpec(
96
+ id="time.window",
97
+ summary="Wait until the local clock is inside a time window.",
98
+ default_poll=timedelta(minutes=1),
99
+ default_deadline=timedelta(hours=24),
100
+ )
101
+ config_model: ClassVar[type[BaseModel]] = TimeWindowConfig
102
+ output_model: ClassVar[type[BaseModel]] = TimeWindowOutput
103
+
104
+ async def poke(self, config: TimeWindowConfig, ctx: StepContext) -> TimeWindowOutput | NotYet:
105
+ """Look at one clock once, and either proceed or say how long the wait is."""
106
+ moment = now_in(config.zone)
107
+ entered = entered_at(config, moment)
108
+ if entered is not None:
109
+ # The stream carries the time of every line, so a block repeating it says nothing
110
+ # the reader cannot already see; the window it opened is in the output.
111
+ ctx.log.info("the window is open", timezone=config.timezone)
112
+ return TimeWindowOutput(entered_at=entered, timezone=config.timezone)
113
+ wait = wait_for(config, moment)
114
+ ctx.log.debug("the window is closed", wait_seconds=round(wait.total_seconds(), 1), timezone=config.timezone)
115
+ return NotYet(next_poll_in=wait)
116
+
117
+
118
+ def now_in(zone: ZoneInfo) -> datetime:
119
+ """Read the current moment in one zone."""
120
+ return datetime.now(zone)
121
+
122
+
123
+ def entered_at(config: TimeWindowConfig, moment: datetime) -> datetime | None:
124
+ """Return when the window the moment falls inside opened, or None when it is closed."""
125
+ local = moment.astimezone(config.zone)
126
+ today = local.date()
127
+ clock = local.timetz().replace(tzinfo=None)
128
+ if not config.crosses_midnight:
129
+ if config.after <= clock < config.before and config.opens_on(today):
130
+ return config.opening(today)
131
+ return None
132
+ if clock >= config.after and config.opens_on(today):
133
+ return config.opening(today)
134
+ yesterday = today - timedelta(days=1)
135
+ if clock < config.before and config.opens_on(yesterday):
136
+ return config.opening(yesterday)
137
+ return None
138
+
139
+
140
+ def next_opening(config: TimeWindowConfig, moment: datetime) -> datetime | None:
141
+ """Find the next moment the window opens, searching a little over a week ahead."""
142
+ local = moment.astimezone(config.zone)
143
+ for offset in range(SEARCH_DAYS):
144
+ day = local.date() + timedelta(days=offset)
145
+ if not config.opens_on(day):
146
+ continue
147
+ opening = config.opening(day)
148
+ if opening > local:
149
+ return opening
150
+ return None # pragma: no cover - a non-empty day set always opens within eight days
151
+
152
+
153
+ def wait_for(config: TimeWindowConfig, moment: datetime) -> timedelta:
154
+ """Say how long to park a closed window, bounded at both ends."""
155
+ opening = next_opening(config, moment)
156
+ if opening is None: # pragma: no cover - next_opening always finds one
157
+ return MAX_WAIT
158
+ return max(MIN_WAIT, min(opening - moment.astimezone(config.zone), MAX_WAIT))
159
+
160
+
161
+ class TimeSleepConfig(BlockModel):
162
+ """How long the wait lasts."""
163
+
164
+ wait_for: Duration = Field(alias="for")
165
+ """How long to wait, measured from when the attempt started.
166
+
167
+ The step's deadline still ends the wait, so a `for` longer than a day needs a `deadline`
168
+ beside it: the sensor default is 24 hours."""
169
+
170
+
171
+ class TimeSleepOutput(BlockModel):
172
+ """What the wait amounted to, passed downstream like any other output."""
173
+
174
+ started_at: datetime
175
+ """When the wait began, which is when the attempt started and not when a poke ran."""
176
+
177
+ waited_ms: int
178
+ """How long the wait actually lasted, which is the configured duration plus poll latency."""
179
+
180
+
181
+ class TimeSleepSensor(Sensor[TimeSleepConfig, TimeSleepOutput]):
182
+ """Waits a fixed duration without occupying a worker.
183
+
184
+ Each poke reads the clock and returns, so an hour's wait is a parked row rather than an
185
+ hour of a worker's concurrency. This is the block to reach for instead of `shell.run`
186
+ with `sleep`, which asks an instance to allowlist arbitrary code execution in order to do
187
+ something harmless.
188
+
189
+ The deadline is anchored on the attempt's start, so a worker restarting mid-wait resumes
190
+ the same wait instead of starting the clock again.
191
+ """
192
+
193
+ spec = SensorSpec(
194
+ id="time.sleep",
195
+ summary="Wait a fixed duration.",
196
+ default_poll=timedelta(seconds=1),
197
+ default_deadline=timedelta(hours=24),
198
+ )
199
+ config_model: ClassVar[type[BaseModel]] = TimeSleepConfig
200
+ output_model: ClassVar[type[BaseModel]] = TimeSleepOutput
201
+
202
+ async def poke(self, config: TimeSleepConfig, ctx: StepContext) -> TimeSleepOutput | NotYet:
203
+ """Read the clock once: finish, or say how much of the wait is left."""
204
+ moment = datetime.now(UTC)
205
+ remaining = ctx.started_at + config.wait_for - moment
206
+ if remaining > timedelta(0):
207
+ ctx.log.debug(
208
+ "still waiting",
209
+ waited_ms=milliseconds(moment - ctx.started_at),
210
+ remaining_ms=milliseconds(remaining),
211
+ )
212
+ return NotYet(next_poll_in=max(remaining, MIN_SLEEP_POLL))
213
+ waited = moment - ctx.started_at
214
+ ctx.log.info("the wait is over", waited_ms=milliseconds(waited))
215
+ return TimeSleepOutput(started_at=ctx.started_at, waited_ms=milliseconds(waited))
216
+
217
+
218
+ def milliseconds(value: timedelta) -> int:
219
+ """Render a duration as the whole milliseconds emitted data measures timings in."""
220
+ return round(value.total_seconds() * 1000)