dirigent-blocks 0.16.2__tar.gz → 0.16.4__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.
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/PKG-INFO +3 -3
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/pyproject.toml +3 -3
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/pyproject.toml.orig +3 -3
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/docker.py +14 -6
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/git.py +36 -2
- dirigent_blocks-0.16.4/src/dirigent_blocks/jq_runner.py +54 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/rabbitmq.py +37 -11
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/shell.py +31 -8
- dirigent_blocks-0.16.4/src/dirigent_blocks/transform_jq.py +318 -0
- dirigent_blocks-0.16.2/src/dirigent_blocks/transform_jq.py +0 -153
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/LICENSE +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/README.md +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/__init__.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/build.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/capture.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/clock.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/compose.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/connections.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/convert_std.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/environment.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/http.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/kafka.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/logging.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/notifiers.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/pipelines.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/py.typed +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/reap.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/report.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/secrets.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/sql.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/storage.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/subprocess.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/validate.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/values.py +0 -0
- {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/webhooks.py +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-blocks
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.4
|
|
4
4
|
Summary: Built-in generic operators and sensors for dirigent.
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Requires-Dist: aio-pika>=9.5.7
|
|
8
8
|
Requires-Dist: aiokafka>=0.12.0
|
|
9
9
|
Requires-Dist: aiosmtplib>=5.1.2
|
|
10
|
-
Requires-Dist: dirigent-common==0.16.
|
|
11
|
-
Requires-Dist: dirigent-plugin==0.16.
|
|
10
|
+
Requires-Dist: dirigent-common==0.16.4
|
|
11
|
+
Requires-Dist: dirigent-plugin==0.16.4
|
|
12
12
|
Requires-Dist: httpx2>=2.12.0
|
|
13
13
|
Requires-Dist: jq>=1.8
|
|
14
14
|
Requires-Dist: jsonschema>=4.26.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-blocks"
|
|
3
|
-
version = "0.16.
|
|
3
|
+
version = "0.16.4"
|
|
4
4
|
description = "Built-in generic operators and sensors for dirigent."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -10,8 +10,8 @@ dependencies = [
|
|
|
10
10
|
"aio-pika>=9.5.7",
|
|
11
11
|
"aiokafka>=0.12.0",
|
|
12
12
|
"aiosmtplib>=5.1.2",
|
|
13
|
-
"dirigent-common==0.16.
|
|
14
|
-
"dirigent-plugin==0.16.
|
|
13
|
+
"dirigent-common==0.16.4",
|
|
14
|
+
"dirigent-plugin==0.16.4",
|
|
15
15
|
"httpx2>=2.12.0",
|
|
16
16
|
"jq>=1.8",
|
|
17
17
|
"jsonschema>=4.26.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-blocks"
|
|
3
|
-
version = "0.16.
|
|
3
|
+
version = "0.16.4"
|
|
4
4
|
description = "Built-in generic operators and sensors for dirigent."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -10,8 +10,8 @@ dependencies = [
|
|
|
10
10
|
"aio-pika>=9.5.7",
|
|
11
11
|
"aiokafka>=0.12.0",
|
|
12
12
|
"aiosmtplib>=5.1.2",
|
|
13
|
-
"dirigent-common==0.16.
|
|
14
|
-
"dirigent-plugin==0.16.
|
|
13
|
+
"dirigent-common==0.16.4",
|
|
14
|
+
"dirigent-plugin==0.16.4",
|
|
15
15
|
"httpx2>=2.12.0",
|
|
16
16
|
"jq>=1.8",
|
|
17
17
|
"jsonschema>=4.26.0",
|
|
@@ -44,6 +44,7 @@ from dirigent_plugin import (
|
|
|
44
44
|
ProbeStatus,
|
|
45
45
|
RemoteHandle,
|
|
46
46
|
ShellString,
|
|
47
|
+
ShellVariables,
|
|
47
48
|
StepContext,
|
|
48
49
|
classify_default,
|
|
49
50
|
)
|
|
@@ -369,7 +370,7 @@ def private_parent(ctx: StepContext) -> Path:
|
|
|
369
370
|
return subprocess.local_root(ctx)
|
|
370
371
|
|
|
371
372
|
|
|
372
|
-
class DockerRunConfig(
|
|
373
|
+
class DockerRunConfig(ShellVariables):
|
|
373
374
|
"""Which image to run, with what command, environment, mounts, and limits."""
|
|
374
375
|
|
|
375
376
|
image: str = Field(min_length=1)
|
|
@@ -381,9 +382,11 @@ class DockerRunConfig(BlockModel):
|
|
|
381
382
|
command: Annotated[str | None, ShellString()] = None
|
|
382
383
|
"""The command as a shell string, run through ``/bin/sh -c`` inside the container.
|
|
383
384
|
|
|
384
|
-
Every ``${...}``
|
|
385
|
-
from a webhook payload is one word
|
|
386
|
-
|
|
385
|
+
Every ``${...}`` in it is rewritten by the engine to a variable it sets in the container's
|
|
386
|
+
environment, so a value that came from a webhook payload is one word the shell never
|
|
387
|
+
parses, however the reference was quoted -- and inside single quotes, which a shell keeps
|
|
388
|
+
literal, the command reads that variable's name rather than its value. Prefer ``argv``: it
|
|
389
|
+
involves no shell at all."""
|
|
387
390
|
|
|
388
391
|
workdir: str | None = None
|
|
389
392
|
"""The working directory inside the container, overriding the image's own."""
|
|
@@ -728,9 +731,14 @@ def _command(config: DockerRunConfig) -> list[JsonValue] | None:
|
|
|
728
731
|
|
|
729
732
|
|
|
730
733
|
def _environment(config: DockerRunConfig) -> list[JsonValue]:
|
|
731
|
-
"""Build the container's environment from an allowlist, never from wholesale inheritance.
|
|
734
|
+
"""Build the container's environment from an allowlist, never from wholesale inheritance.
|
|
735
|
+
|
|
736
|
+
The values the engine substituted out of the shell string come last, so a document cannot
|
|
737
|
+
override what a reference resolved to.
|
|
738
|
+
"""
|
|
732
739
|
inherited = allowed(config.env_allowlist)
|
|
733
|
-
|
|
740
|
+
built = {**inherited, **config.env, **config.shell_variables}
|
|
741
|
+
return [f"{name}={value}" for name, value in built.items()]
|
|
734
742
|
|
|
735
743
|
|
|
736
744
|
def _nano_cpus(config: DockerRunConfig) -> int | None:
|
|
@@ -250,7 +250,7 @@ class GitCheckoutOperator(Operator[GitCheckoutConfig, GitCheckoutOutput]):
|
|
|
250
250
|
root = subprocess.local_root(ctx)
|
|
251
251
|
settings = ctx.connection(config.connection, GitConnectionConfig)
|
|
252
252
|
target = config.target or ctx.step
|
|
253
|
-
destination = root
|
|
253
|
+
destination = _destination(root, target)
|
|
254
254
|
remote = public_url(settings.url)
|
|
255
255
|
binary = shutil.which("git", path=subprocess.environment([], {}, root).get("PATH"))
|
|
256
256
|
if binary is None:
|
|
@@ -409,8 +409,11 @@ async def _standing(git: _Git, config: GitCheckoutConfig, destination: Path, rem
|
|
|
409
409
|
A retry of a step, or a second run into a work directory that already holds the checkout,
|
|
410
410
|
should cost one ref listing rather than a second clone. Anything that is not a working
|
|
411
411
|
tree at the wanted commit answers false, and the target is then rebuilt from scratch.
|
|
412
|
+
|
|
413
|
+
A target that is itself a symlink is never standing: what it points at is some other
|
|
414
|
+
directory, so it is unlinked and replaced rather than looked into.
|
|
412
415
|
"""
|
|
413
|
-
if not (destination / ".git").exists():
|
|
416
|
+
if destination.is_symlink() or not (destination / ".git").exists():
|
|
414
417
|
return False
|
|
415
418
|
code, out, _err = await git.call(destination, "rev-parse", "HEAD")
|
|
416
419
|
if code != 0:
|
|
@@ -484,6 +487,37 @@ def classify(stderr: bytes) -> ErrorClass:
|
|
|
484
487
|
return ErrorClass.UNKNOWN
|
|
485
488
|
|
|
486
489
|
|
|
490
|
+
def _destination(root: Path, target: str) -> Path:
|
|
491
|
+
"""Where the checkout lands, once it is certain that path is inside the run's work directory.
|
|
492
|
+
|
|
493
|
+
The config refuses a target that is absolute or carries ``..``, which is a check on the
|
|
494
|
+
text; a symlink an earlier step left in the work directory is not. So every component
|
|
495
|
+
between the root and the target must be a real directory here, and the directory the
|
|
496
|
+
checkout lands in must resolve inside the root, before anything looks at the target,
|
|
497
|
+
clears it or clones into it. The target itself may be a symlink: it is unlinked rather
|
|
498
|
+
than followed.
|
|
499
|
+
"""
|
|
500
|
+
base = root.resolve()
|
|
501
|
+
parts = Path(target).parts
|
|
502
|
+
walked = base
|
|
503
|
+
for part in parts[:-1]:
|
|
504
|
+
walked = walked / part
|
|
505
|
+
if walked.is_symlink():
|
|
506
|
+
raise BlockFailure(
|
|
507
|
+
f"the checkout target {target!r} leads through the symlink {walked}, which can point "
|
|
508
|
+
f"anywhere; a target is a path of real directories under the run's work directory {base}",
|
|
509
|
+
error_class=ErrorClass.REJECTED,
|
|
510
|
+
)
|
|
511
|
+
destination = walked / parts[-1] if parts else walked
|
|
512
|
+
landing = destination.parent.resolve()
|
|
513
|
+
if landing != base and base not in landing.parents:
|
|
514
|
+
raise BlockFailure(
|
|
515
|
+
f"the checkout target {target!r} lands at {destination}, which is outside the run's work directory {base}",
|
|
516
|
+
error_class=ErrorClass.REJECTED,
|
|
517
|
+
)
|
|
518
|
+
return destination
|
|
519
|
+
|
|
520
|
+
|
|
487
521
|
def _clear(destination: Path) -> None:
|
|
488
522
|
"""Empty the target so a clone into it starts from nothing, whatever was there before."""
|
|
489
523
|
if destination.is_symlink() or destination.is_file():
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The process a jq program is evaluated in, started by ``transform_jq`` and spoken to over its pipes.
|
|
2
|
+
|
|
3
|
+
jq is a C extension that holds the GIL for as long as a program runs, so a program evaluated
|
|
4
|
+
in the worker's own process holds its event loop too: the step's timeout never fires, the
|
|
5
|
+
lease heartbeat never runs, and the sweeper eventually hands the attempt to another worker
|
|
6
|
+
while this one is still computing. There is no way to interrupt a jq program and no way to
|
|
7
|
+
cancel a thread, so a program runs here, where killing the process ends it.
|
|
8
|
+
|
|
9
|
+
One request per line in, one reply per line back: ``{"program": ..., "value": ...}`` is
|
|
10
|
+
answered with ``{"outputs": [...]}``, or with ``{"error": "..."}`` carrying jq's own message
|
|
11
|
+
about a program that could not be compiled or met data it cannot work on. The pipe closing is
|
|
12
|
+
how the parent says it is done.
|
|
13
|
+
|
|
14
|
+
This module is run by path, never imported, so it costs the standard library and jq and
|
|
15
|
+
nothing else to start. Importing it as part of its package would load every block dirigent
|
|
16
|
+
ships in order to evaluate ``.name``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import jq
|
|
24
|
+
|
|
25
|
+
#: Typed ``Any`` because the jq binding is a C extension that ships no type information.
|
|
26
|
+
libjq: Any = jq
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
"""Answer one request at a time until the pipe closes.
|
|
31
|
+
|
|
32
|
+
The last program compiled is kept because a step sends the same one once per element, and
|
|
33
|
+
only the last, because the process outlives the step and a run's programs would otherwise
|
|
34
|
+
pile up in it.
|
|
35
|
+
"""
|
|
36
|
+
out = sys.stdout.buffer
|
|
37
|
+
source: str | None = None
|
|
38
|
+
compiled: Any = None
|
|
39
|
+
for line in sys.stdin.buffer:
|
|
40
|
+
request = json.loads(line)
|
|
41
|
+
try:
|
|
42
|
+
if request["program"] != source:
|
|
43
|
+
# Named only once it has compiled, so a refused program is not cached as one.
|
|
44
|
+
compiled = libjq.compile(request["program"])
|
|
45
|
+
source = request["program"]
|
|
46
|
+
reply = {"outputs": compiled.input_value(request["value"]).all()}
|
|
47
|
+
except ValueError as error:
|
|
48
|
+
reply = {"error": str(error).strip()}
|
|
49
|
+
out.write(json.dumps(reply).encode() + b"\n")
|
|
50
|
+
out.flush()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
|
@@ -6,11 +6,11 @@ there is enough, handing the batch downstream as its output.
|
|
|
6
6
|
|
|
7
7
|
Unlike a Kafka topic, a RabbitMQ queue keeps its own place: a message is held unacknowledged
|
|
8
8
|
until it is acked or nacked, so the broker rather than the cursor is the bookkeeping. What the
|
|
9
|
-
sensor decides is *when* to acknowledge, and the default is ``on_success``: a poke that parks
|
|
10
|
-
|
|
11
|
-
is left for the next poke or another consumer
|
|
12
|
-
nothing the sensor needs, only what a reader
|
|
13
|
-
last delivery tag seen.
|
|
9
|
+
sensor decides is *when* to acknowledge, and the default is ``on_success``: a poke that parks,
|
|
10
|
+
or that cannot read one of the bodies it took, nacks everything back onto the queue with
|
|
11
|
+
``requeue``, so a batch too small to act on is left for the next poke or another consumer
|
|
12
|
+
rather than swallowed. The cursor here carries nothing the sensor needs, only what a reader
|
|
13
|
+
wants -- how many messages have gone past and the last delivery tag seen.
|
|
14
14
|
|
|
15
15
|
``rabbitmq.publish`` goes the other way, and is the sink an upstream step hands a value to: it
|
|
16
16
|
puts one message on an exchange, or on the default exchange where a routing key is a queue name.
|
|
@@ -213,11 +213,13 @@ class RabbitConsumeConfig(BlockModel):
|
|
|
213
213
|
ack: Literal["on_success", "always"] = "on_success"
|
|
214
214
|
"""When a message is acknowledged.
|
|
215
215
|
|
|
216
|
-
``on_success`` acknowledges only in the poke that succeeds,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
216
|
+
``on_success`` acknowledges only in the poke that succeeds, once every body in the batch has
|
|
217
|
+
been read. A poke that parks, or that finds a body it cannot read, nacks what it took back
|
|
218
|
+
onto the queue with ``requeue``, so nothing is lost to a batch that was too small or to one
|
|
219
|
+
unreadable message beside sound ones, and another consumer may take it instead. ``always``
|
|
220
|
+
acknowledges every message the moment it is taken, before any of them is read, which suits a
|
|
221
|
+
queue nothing else reads and a step that would rather drop a partial or unreadable batch
|
|
222
|
+
than see it twice."""
|
|
221
223
|
|
|
222
224
|
value_format: Payload = "json"
|
|
223
225
|
"""How a message body is decoded."""
|
|
@@ -276,6 +278,8 @@ class RabbitConsumeSensor(Sensor[RabbitConsumeConfig, RabbitConsumeOutput]):
|
|
|
276
278
|
queue = await _queue(connection, config)
|
|
277
279
|
taken = await _drain(queue, config)
|
|
278
280
|
if config.ack == "always":
|
|
281
|
+
# The batch is settled before it is read, so a body that will not decode is gone
|
|
282
|
+
# from the queue with the rest of them.
|
|
279
283
|
for delivery in taken:
|
|
280
284
|
await delivery.ack()
|
|
281
285
|
if len(taken) < config.min_messages:
|
|
@@ -293,10 +297,10 @@ class RabbitConsumeSensor(Sensor[RabbitConsumeConfig, RabbitConsumeOutput]):
|
|
|
293
297
|
next_poll_in=config.poll_every,
|
|
294
298
|
message=f"{len(taken)} of {config.min_messages} messages",
|
|
295
299
|
)
|
|
300
|
+
messages = await _decoded(taken, config)
|
|
296
301
|
if config.ack == "on_success":
|
|
297
302
|
for delivery in taken:
|
|
298
303
|
await delivery.ack()
|
|
299
|
-
messages = [_read(delivery, config) for delivery in taken]
|
|
300
304
|
ctx.log.info("consumed", queue=config.queue, count=len(messages), ack=config.ack)
|
|
301
305
|
return RabbitConsumeOutput(messages=messages, count=len(messages))
|
|
302
306
|
except BlockFailure:
|
|
@@ -341,6 +345,28 @@ async def _drain(queue: Queue, config: RabbitConsumeConfig) -> list[Delivery]:
|
|
|
341
345
|
return taken
|
|
342
346
|
|
|
343
347
|
|
|
348
|
+
async def _decoded(taken: list[Delivery], config: RabbitConsumeConfig) -> list[RabbitMessage]:
|
|
349
|
+
"""Read the whole batch, or hand the whole batch back and say which delivery would not read.
|
|
350
|
+
|
|
351
|
+
Under ``on_success`` nothing has been acked yet, so a body that is not what the step was
|
|
352
|
+
promised is requeued along with the sound messages beside it rather than taking them off
|
|
353
|
+
the queue with it.
|
|
354
|
+
"""
|
|
355
|
+
messages: list[RabbitMessage] = []
|
|
356
|
+
for delivery in taken:
|
|
357
|
+
try:
|
|
358
|
+
messages.append(_read(delivery, config))
|
|
359
|
+
except Exception as error:
|
|
360
|
+
if config.ack == "on_success":
|
|
361
|
+
for held in taken:
|
|
362
|
+
await held.nack(requeue=True)
|
|
363
|
+
raise BlockFailure(
|
|
364
|
+
f"delivery {delivery.delivery_tag} of the {len(taken)} taken did not read: {error}",
|
|
365
|
+
error_class=classify(error),
|
|
366
|
+
) from error
|
|
367
|
+
return messages
|
|
368
|
+
|
|
369
|
+
|
|
344
370
|
def _cursor(held: dict[str, JsonValue] | None, taken: list[Delivery]) -> dict[str, JsonValue]:
|
|
345
371
|
"""What a reader is told about a wait the broker is doing the bookkeeping for."""
|
|
346
372
|
seen = int(cast("int", (held or {}).get("seen", 0))) + len(taken)
|
|
@@ -15,10 +15,19 @@ from dirigent_blocks import subprocess
|
|
|
15
15
|
from dirigent_blocks.capture import log_stream, tail
|
|
16
16
|
from dirigent_blocks.environment import reject_reserved
|
|
17
17
|
from dirigent_common import BlockModel, Duration
|
|
18
|
-
from dirigent_plugin import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
from dirigent_plugin import (
|
|
19
|
+
BlockFailure,
|
|
20
|
+
ErrorClass,
|
|
21
|
+
Operator,
|
|
22
|
+
OperatorSpec,
|
|
23
|
+
RemoteHandle,
|
|
24
|
+
ShellString,
|
|
25
|
+
ShellVariables,
|
|
26
|
+
StepContext,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ShellRunConfig(ShellVariables):
|
|
22
31
|
"""What to run, where, with what environment, and for how long."""
|
|
23
32
|
|
|
24
33
|
argv: list[str] = Field(default_factory=list[str])
|
|
@@ -27,9 +36,11 @@ class ShellRunConfig(BlockModel):
|
|
|
27
36
|
command: Annotated[str | None, ShellString()] = None
|
|
28
37
|
"""The command as a shell string, for when a pipe or a redirect is the point.
|
|
29
38
|
|
|
30
|
-
Every ``${...}``
|
|
31
|
-
from a webhook payload is one word
|
|
32
|
-
|
|
39
|
+
Every ``${...}`` in it is rewritten by the engine to a variable it sets in this command's
|
|
40
|
+
environment, so a value that came from a webhook payload is one word the shell never
|
|
41
|
+
parses, however the reference was quoted -- and inside single quotes, which a shell keeps
|
|
42
|
+
literal, the command reads that variable's name rather than its value. Prefer ``argv``
|
|
43
|
+
anyway: it involves no shell at all."""
|
|
33
44
|
|
|
34
45
|
cwd: str | None = None
|
|
35
46
|
"""A directory relative to the run's work directory; never an absolute path."""
|
|
@@ -90,6 +101,18 @@ class ShellRunOutput(BlockModel):
|
|
|
90
101
|
"""Whether ``stderr`` above is short of the stream, which is an independent question."""
|
|
91
102
|
|
|
92
103
|
|
|
104
|
+
def _environment(config: ShellRunConfig, home: Path) -> dict[str, str]:
|
|
105
|
+
"""Build the command's environment, the substituted values last.
|
|
106
|
+
|
|
107
|
+
They are set after ``env`` so a document cannot override what a reference resolved to,
|
|
108
|
+
and they are set whatever the allowlist holds: the command reads them because the engine
|
|
109
|
+
put them in it.
|
|
110
|
+
"""
|
|
111
|
+
environ = subprocess.environment(config.env_allowlist, config.env, home)
|
|
112
|
+
environ.update(config.shell_variables)
|
|
113
|
+
return environ
|
|
114
|
+
|
|
115
|
+
|
|
93
116
|
class ShellRunOperator(Operator[ShellRunConfig, ShellRunOutput]):
|
|
94
117
|
"""Runs a command on the worker, inside the run's work directory and behind the allowlist."""
|
|
95
118
|
|
|
@@ -117,7 +140,7 @@ class ShellRunOperator(Operator[ShellRunConfig, ShellRunOutput]):
|
|
|
117
140
|
stdout_uri=stdout_uri,
|
|
118
141
|
stderr_uri=stderr_uri,
|
|
119
142
|
timeout_seconds=config.timeout.total_seconds(),
|
|
120
|
-
environ=
|
|
143
|
+
environ=_environment(config, directory),
|
|
121
144
|
argv=config.argv or None,
|
|
122
145
|
command=config.command,
|
|
123
146
|
)
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""The ``jq`` engines: a jq program as the transform, map, and filter verbs' program.
|
|
2
|
+
|
|
3
|
+
A program is compiled here, where a bad one is refused with jq's own message, and evaluated
|
|
4
|
+
in a jq process of its own. jq is a C extension that holds the GIL for as long as a program
|
|
5
|
+
runs, so evaluating one on the worker would hold the event loop with it and starve the step
|
|
6
|
+
timeout and the lease heartbeat that share it. A program cannot be interrupted and a thread
|
|
7
|
+
cannot be cancelled, so the step's timeout ends a program the only way there is: the process
|
|
8
|
+
running it is killed, and the step fails with it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import atexit
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from contextvars import ContextVar
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import IO, Annotated, Any, ClassVar, cast
|
|
20
|
+
|
|
21
|
+
import jq
|
|
22
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
23
|
+
|
|
24
|
+
from dirigent_common import JQ_MEDIA_TYPE
|
|
25
|
+
from dirigent_plugin import Engine, Filterer, Mapper, ProgramConfig, Transformer, TransformError
|
|
26
|
+
|
|
27
|
+
#: What a program producing nothing is told to write instead.
|
|
28
|
+
NO_OUTPUT = (
|
|
29
|
+
"the program produced no output, and a transform step has to produce one; "
|
|
30
|
+
"a program that means 'possibly nothing' emits [] or null explicitly"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
#: Wraps the author's program so jq's ``env`` and ``$ENV`` read an empty object instead of
|
|
34
|
+
#: the worker's environment, which is where dirigent's own secrets live. The program keeps
|
|
35
|
+
#: its own line numbers because the message a bad one is refused with comes from compiling
|
|
36
|
+
#: the author's text, unwrapped.
|
|
37
|
+
SANDBOX = "{} as $ENV | def env: {}; (\n"
|
|
38
|
+
|
|
39
|
+
#: The script a jq process runs, started by path so that it imports jq and the standard
|
|
40
|
+
#: library and nothing else.
|
|
41
|
+
RUNNER = Path(__file__).with_name("jq_runner.py")
|
|
42
|
+
|
|
43
|
+
#: What a step is told when the process running its program stopped on its own. It is not a
|
|
44
|
+
#: TransformError, because nothing about the program says it will happen again.
|
|
45
|
+
PROCESS_STOPPED = "the process running the jq program stopped before it answered"
|
|
46
|
+
|
|
47
|
+
#: Typed ``Any`` because the jq binding is a C extension that ships no type information.
|
|
48
|
+
libjq: Any = jq
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _compile(program: str) -> object:
|
|
52
|
+
"""Compile a jq program with env and $ENV shadowed, refusing a bad one with jq's own message.
|
|
53
|
+
|
|
54
|
+
What is handed on is the sandboxed source rather than the compiled program, because the
|
|
55
|
+
process that evaluates it compiles its own copy: compiling here is what refuses a bad
|
|
56
|
+
program, at apply and again on the worker.
|
|
57
|
+
"""
|
|
58
|
+
sandboxed = f"{SANDBOX}{program}\n)"
|
|
59
|
+
try:
|
|
60
|
+
# The author's text is compiled first, so a refusal carries jq's message about their
|
|
61
|
+
# program and not about the wrapper.
|
|
62
|
+
libjq.compile(program)
|
|
63
|
+
libjq.compile(sandboxed)
|
|
64
|
+
except ValueError as error:
|
|
65
|
+
raise TransformError(str(error).strip()) from error
|
|
66
|
+
return sandboxed
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _outputs(compiled: object, value: JsonValue) -> list[JsonValue]:
|
|
70
|
+
"""Run a compiled program over one value in this step's jq process and read its outputs."""
|
|
71
|
+
return _running().run(cast("str", compiled), value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Process:
|
|
75
|
+
"""One jq process, and the two pipes a program and its outputs cross."""
|
|
76
|
+
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
"""Start the process and hold the pipes it is spoken to over."""
|
|
79
|
+
self.process = subprocess.Popen(
|
|
80
|
+
[sys.executable, str(RUNNER)],
|
|
81
|
+
stdin=subprocess.PIPE,
|
|
82
|
+
stdout=subprocess.PIPE,
|
|
83
|
+
)
|
|
84
|
+
# Popen types both pipes as optional; both were asked for.
|
|
85
|
+
self._to = cast("IO[bytes]", self.process.stdin)
|
|
86
|
+
self._from = cast("IO[bytes]", self.process.stdout)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def alive(self) -> bool:
|
|
90
|
+
"""Whether this process is still there to run a program."""
|
|
91
|
+
return self.process.poll() is None
|
|
92
|
+
|
|
93
|
+
def run(self, program: str, value: JsonValue) -> list[JsonValue]:
|
|
94
|
+
"""Send one program and one value, and read back the stream of outputs it produced."""
|
|
95
|
+
try:
|
|
96
|
+
self._to.write(json.dumps({"program": program, "value": value}).encode() + b"\n")
|
|
97
|
+
self._to.flush()
|
|
98
|
+
reply = self._from.readline()
|
|
99
|
+
except OSError as error:
|
|
100
|
+
raise RuntimeError(PROCESS_STOPPED) from error
|
|
101
|
+
if not reply:
|
|
102
|
+
# A killed process, which is how a cancelled step ends its program, and how a
|
|
103
|
+
# program that ran the process out of memory ends itself.
|
|
104
|
+
raise RuntimeError(PROCESS_STOPPED)
|
|
105
|
+
answered = cast("dict[str, Any]", json.loads(reply))
|
|
106
|
+
refusal = answered.get("error")
|
|
107
|
+
if refusal is not None:
|
|
108
|
+
raise TransformError(cast("str", refusal))
|
|
109
|
+
return cast("list[JsonValue]", answered["outputs"])
|
|
110
|
+
|
|
111
|
+
def kill(self) -> None:
|
|
112
|
+
"""End whatever is running, which is the only way to stop a jq program, and close it out.
|
|
113
|
+
|
|
114
|
+
Waiting is what makes the process dead rather than dying, so the pool sees at once
|
|
115
|
+
that this one is not to be handed out again; a killed process is reaped at once.
|
|
116
|
+
Closing the pipes waits for the read a step's thread is still blocked in, which the
|
|
117
|
+
kill has just ended, and leaves no descriptor behind for a collection to complain of.
|
|
118
|
+
"""
|
|
119
|
+
self.process.kill()
|
|
120
|
+
self.process.wait()
|
|
121
|
+
self._to.close()
|
|
122
|
+
self._from.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _Processes:
|
|
126
|
+
"""The jq processes this worker keeps, one per transform step running at a time.
|
|
127
|
+
|
|
128
|
+
A step holds one for as long as its program runs and gives it back after, so two steps
|
|
129
|
+
never meet on one pipe. A process is started when there is none to hand out and then
|
|
130
|
+
lives on, because starting one costs more than every program a step runs through it; it
|
|
131
|
+
ends when the worker does, its pipe closing under it. A process that was killed is not
|
|
132
|
+
handed out again.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self) -> None:
|
|
136
|
+
"""Start with no processes; the first step that needs one starts it."""
|
|
137
|
+
self._idle: list[_Process] = []
|
|
138
|
+
|
|
139
|
+
async def take(self) -> _Process:
|
|
140
|
+
"""Hand out an idle process, starting one off the event loop when there is none."""
|
|
141
|
+
while self._idle:
|
|
142
|
+
process = self._idle.pop()
|
|
143
|
+
if process.alive:
|
|
144
|
+
return process
|
|
145
|
+
starting = asyncio.ensure_future(asyncio.to_thread(_Process))
|
|
146
|
+
try:
|
|
147
|
+
return await asyncio.shield(starting)
|
|
148
|
+
except asyncio.CancelledError:
|
|
149
|
+
# A step can be cancelled while its process is still starting. The start is
|
|
150
|
+
# shielded so the process is finished and kept, rather than left running for a
|
|
151
|
+
# step that is already gone.
|
|
152
|
+
starting.add_done_callback(self._started)
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
def give_back(self, process: _Process) -> None:
|
|
156
|
+
"""Take a process back for the next step, unless it was killed."""
|
|
157
|
+
if process.alive:
|
|
158
|
+
self._idle.append(process)
|
|
159
|
+
|
|
160
|
+
def shutdown(self) -> None:
|
|
161
|
+
"""Kill every idle process, so none is left running when this one exits."""
|
|
162
|
+
while self._idle:
|
|
163
|
+
self._idle.pop().kill()
|
|
164
|
+
|
|
165
|
+
def _started(self, starting: "asyncio.Future[_Process]") -> None:
|
|
166
|
+
"""Keep a process whose step gave up on it while it was starting."""
|
|
167
|
+
if not starting.cancelled() and starting.exception() is None:
|
|
168
|
+
self.give_back(starting.result())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
#: The jq processes this worker keeps. One pool per process rather than one per engine: the
|
|
172
|
+
#: three verbs run the same programs the same way, and a step holds a process, not a verb.
|
|
173
|
+
_PROCESSES = _Processes()
|
|
174
|
+
|
|
175
|
+
atexit.register(_PROCESSES.shutdown)
|
|
176
|
+
|
|
177
|
+
#: The jq process the step running on this task talks to. A program is evaluated from inside
|
|
178
|
+
#: the thread offload() hands the step's work to, which is where the engine reads it.
|
|
179
|
+
_RUNNING: ContextVar[_Process] = ContextVar("dirigent_jq_process")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _running() -> _Process:
|
|
183
|
+
"""The jq process this step holds, which only a step running through offload() has."""
|
|
184
|
+
try:
|
|
185
|
+
return _RUNNING.get()
|
|
186
|
+
except LookupError as error:
|
|
187
|
+
missing = "a jq engine is called through offload(), which takes the process its program runs in"
|
|
188
|
+
raise RuntimeError(missing) from error
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class JqEngine(Engine):
|
|
192
|
+
"""Where the three jq engines' programs run: in a jq process, never on the worker's loop."""
|
|
193
|
+
|
|
194
|
+
async def offload[T](self, work: Callable[[], T]) -> T:
|
|
195
|
+
"""Hold a jq process for the step, and kill it if the step is cancelled or times out."""
|
|
196
|
+
process = await _PROCESSES.take()
|
|
197
|
+
token = _RUNNING.set(process)
|
|
198
|
+
try:
|
|
199
|
+
return await asyncio.to_thread(work)
|
|
200
|
+
except asyncio.CancelledError:
|
|
201
|
+
# Cancelling the await does not stop the thread: it is inside a blocking read,
|
|
202
|
+
# and killing the process is both what ends the program and what lets that read
|
|
203
|
+
# return, so nothing is left running behind a step that has already failed.
|
|
204
|
+
process.kill()
|
|
205
|
+
raise
|
|
206
|
+
finally:
|
|
207
|
+
_RUNNING.reset(token)
|
|
208
|
+
_PROCESSES.give_back(process)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class JqProgramConfig(ProgramConfig):
|
|
212
|
+
"""The program-shaped config of a jq engine, whichever verb is running it.
|
|
213
|
+
|
|
214
|
+
All three verbs take the same program in the same language, so all three publish the
|
|
215
|
+
same config: the frame's fields, with ``program`` narrowed to jq source.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
program: Annotated[str, Field(min_length=1, json_schema_extra={"contentMediaType": JQ_MEDIA_TYPE})]
|
|
219
|
+
"""The jq program this step runs."""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class JqTransformer(JqEngine, Transformer):
|
|
223
|
+
"""Reshapes one whole value by running a jq program over it.
|
|
224
|
+
|
|
225
|
+
A jq program opens no file, no socket, and starts nothing: it is handed a value and
|
|
226
|
+
returns values, which is why this engine executes no code on the worker and needs no
|
|
227
|
+
allowlist entry. Its two ways of reading an environment, ``env`` and ``$ENV``, are
|
|
228
|
+
shadowed, so a program sees an empty object rather than the process it runs in -- which
|
|
229
|
+
is a jq process of dirigent's own, so that a step's timeout can end it.
|
|
230
|
+
|
|
231
|
+
A jq program is a stream: it can emit zero, one, or many outputs. One output is the
|
|
232
|
+
step's value, many outputs are that list of values, and none is a refusal, because a
|
|
233
|
+
step that produced nothing has no output for the next step to read.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
kind = "jq"
|
|
237
|
+
summary = "Reshape a value with a jq program."
|
|
238
|
+
config_model: ClassVar[type[BaseModel]] = JqProgramConfig
|
|
239
|
+
|
|
240
|
+
def compile(self, program: str) -> object:
|
|
241
|
+
"""Compile a jq program, refusing a bad one with jq's own message about it."""
|
|
242
|
+
return _compile(program)
|
|
243
|
+
|
|
244
|
+
def apply(self, compiled: object, value: JsonValue) -> JsonValue:
|
|
245
|
+
"""Run the program over one value, and read its stream of outputs as one result."""
|
|
246
|
+
produced = _outputs(compiled, value)
|
|
247
|
+
if not produced:
|
|
248
|
+
raise TransformError(NO_OUTPUT)
|
|
249
|
+
return produced[0] if len(produced) == 1 else produced
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class JqMapper(JqEngine, Mapper):
|
|
253
|
+
"""Replaces every element of a list with what a jq program makes of that element.
|
|
254
|
+
|
|
255
|
+
The program is run once per element, with the element as its input, and is sandboxed
|
|
256
|
+
exactly as ``transform.jq`` is: ``env`` and ``$ENV`` read an empty object.
|
|
257
|
+
|
|
258
|
+
A map replaces each element with one thing, so the stream a program emits for an element
|
|
259
|
+
has to be exactly one output. Zero of them and several of them are both refusals rather
|
|
260
|
+
than a shorter or longer list, because dropping elements is what ``filter.jq`` is for and
|
|
261
|
+
changing their number is what ``transform.jq`` is for.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
kind = "jq"
|
|
265
|
+
summary = "Replace every element of a list with what a jq program makes of it."
|
|
266
|
+
config_model: ClassVar[type[BaseModel]] = JqProgramConfig
|
|
267
|
+
|
|
268
|
+
def compile(self, program: str) -> object:
|
|
269
|
+
"""Compile a jq program, refusing a bad one with jq's own message about it."""
|
|
270
|
+
return _compile(program)
|
|
271
|
+
|
|
272
|
+
def apply(self, compiled: object, value: JsonValue) -> JsonValue:
|
|
273
|
+
"""Run the program over one element, and take its single output as the replacement."""
|
|
274
|
+
produced = _outputs(compiled, value)
|
|
275
|
+
if len(produced) != 1:
|
|
276
|
+
raise TransformError(
|
|
277
|
+
f"the program produced {len(produced)} outputs, and a map replaces an element with "
|
|
278
|
+
f"exactly one; a program that drops elements is a filter.jq step, and one that "
|
|
279
|
+
f"changes how many there are is a transform.jq step"
|
|
280
|
+
)
|
|
281
|
+
return produced[0]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class JqFilterer(JqEngine, Filterer):
|
|
285
|
+
"""Keeps the elements of a list a jq program answers true for.
|
|
286
|
+
|
|
287
|
+
The program is run once per element, with the element as its input, and is sandboxed
|
|
288
|
+
exactly as ``transform.jq`` is: ``env`` and ``$ENV`` read an empty object.
|
|
289
|
+
|
|
290
|
+
The answer has to be exactly one output, and that output has to be ``true`` or ``false``.
|
|
291
|
+
jq's own truthiness is not applied: a program emitting ``0`` or ``""`` is a mistake
|
|
292
|
+
surfaced rather than an element quietly dropped, and an author who means "has readings"
|
|
293
|
+
writes ``.count > 0``.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
kind = "jq"
|
|
297
|
+
summary = "Keep the elements of a list a jq program answers true for."
|
|
298
|
+
config_model: ClassVar[type[BaseModel]] = JqProgramConfig
|
|
299
|
+
|
|
300
|
+
def compile(self, program: str) -> object:
|
|
301
|
+
"""Compile a jq program, refusing a bad one with jq's own message about it."""
|
|
302
|
+
return _compile(program)
|
|
303
|
+
|
|
304
|
+
def keep(self, compiled: object, value: JsonValue) -> bool:
|
|
305
|
+
"""Run the program over one element, and read its single boolean output as the verdict."""
|
|
306
|
+
produced = _outputs(compiled, value)
|
|
307
|
+
if len(produced) != 1:
|
|
308
|
+
raise TransformError(
|
|
309
|
+
f"the program produced {len(produced)} outputs, and a filter answers one true or false "
|
|
310
|
+
f"per element; a program that reshapes an element is a map.jq or transform.jq step"
|
|
311
|
+
)
|
|
312
|
+
answer = produced[0]
|
|
313
|
+
if not isinstance(answer, bool):
|
|
314
|
+
raise TransformError(
|
|
315
|
+
f"the program answered {json.dumps(answer)}, and a filter answers true or false; jq's "
|
|
316
|
+
f"truthiness is not applied, so a program meaning 'has readings' writes '.count > 0'"
|
|
317
|
+
)
|
|
318
|
+
return answer
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
"""The ``jq`` engines: a jq program as the transform, map, and filter verbs' program."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from typing import Annotated, Any, ClassVar, cast
|
|
5
|
-
|
|
6
|
-
import jq
|
|
7
|
-
from pydantic import BaseModel, Field, JsonValue
|
|
8
|
-
|
|
9
|
-
from dirigent_common import JQ_MEDIA_TYPE
|
|
10
|
-
from dirigent_plugin import Filterer, Mapper, ProgramConfig, Transformer, TransformError
|
|
11
|
-
|
|
12
|
-
#: What a program producing nothing is told to write instead.
|
|
13
|
-
NO_OUTPUT = (
|
|
14
|
-
"the program produced no output, and a transform step has to produce one; "
|
|
15
|
-
"a program that means 'possibly nothing' emits [] or null explicitly"
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
#: Wraps the author's program so jq's ``env`` and ``$ENV`` read an empty object instead of
|
|
19
|
-
#: the worker's environment, which is where dirigent's own secrets live. The program keeps
|
|
20
|
-
#: its own line numbers because the message a bad one is refused with comes from compiling
|
|
21
|
-
#: the author's text, unwrapped.
|
|
22
|
-
SANDBOX = "{} as $ENV | def env: {}; (\n"
|
|
23
|
-
|
|
24
|
-
#: Typed ``Any`` because the jq binding is a C extension that ships no type information.
|
|
25
|
-
libjq: Any = jq
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _compile(program: str) -> object:
|
|
29
|
-
"""Compile a jq program with env and $ENV shadowed, refusing a bad one with jq's own message."""
|
|
30
|
-
try:
|
|
31
|
-
# The author's text is compiled first, so a refusal carries jq's message about their
|
|
32
|
-
# program and not about the wrapper.
|
|
33
|
-
libjq.compile(program)
|
|
34
|
-
return libjq.compile(f"{SANDBOX}{program}\n)")
|
|
35
|
-
except ValueError as error:
|
|
36
|
-
raise TransformError(str(error).strip()) from error
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _outputs(compiled: object, value: JsonValue) -> list[JsonValue]:
|
|
40
|
-
"""Run a compiled program over one value and read its whole stream of outputs."""
|
|
41
|
-
try:
|
|
42
|
-
return cast("list[JsonValue]", cast("Any", compiled).input_value(value).all())
|
|
43
|
-
except ValueError as error:
|
|
44
|
-
raise TransformError(str(error).strip()) from error
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class JqProgramConfig(ProgramConfig):
|
|
48
|
-
"""The program-shaped config of a jq engine, whichever verb is running it.
|
|
49
|
-
|
|
50
|
-
All three verbs take the same program in the same language, so all three publish the
|
|
51
|
-
same config: the frame's fields, with ``program`` narrowed to jq source.
|
|
52
|
-
"""
|
|
53
|
-
|
|
54
|
-
program: Annotated[str, Field(min_length=1, json_schema_extra={"contentMediaType": JQ_MEDIA_TYPE})]
|
|
55
|
-
"""The jq program this step runs."""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class JqTransformer(Transformer):
|
|
59
|
-
"""Reshapes one whole value by running a jq program over it.
|
|
60
|
-
|
|
61
|
-
jq opens no file, no socket, and starts no process: a program is handed a value and
|
|
62
|
-
returns values, which is why this engine executes no code on the worker and needs no
|
|
63
|
-
allowlist entry. Its two ways of reading the worker's environment, ``env`` and ``$ENV``,
|
|
64
|
-
are shadowed, so a program sees an empty object rather than the process it runs in.
|
|
65
|
-
|
|
66
|
-
A jq program is a stream: it can emit zero, one, or many outputs. One output is the
|
|
67
|
-
step's value, many outputs are that list of values, and none is a refusal, because a
|
|
68
|
-
step that produced nothing has no output for the next step to read.
|
|
69
|
-
"""
|
|
70
|
-
|
|
71
|
-
kind = "jq"
|
|
72
|
-
summary = "Reshape a value with a jq program."
|
|
73
|
-
config_model: ClassVar[type[BaseModel]] = JqProgramConfig
|
|
74
|
-
|
|
75
|
-
def compile(self, program: str) -> object:
|
|
76
|
-
"""Compile a jq program, refusing a bad one with jq's own message about it."""
|
|
77
|
-
return _compile(program)
|
|
78
|
-
|
|
79
|
-
def apply(self, compiled: object, value: JsonValue) -> JsonValue:
|
|
80
|
-
"""Run the program over one value, and read its stream of outputs as one result."""
|
|
81
|
-
produced = _outputs(compiled, value)
|
|
82
|
-
if not produced:
|
|
83
|
-
raise TransformError(NO_OUTPUT)
|
|
84
|
-
return produced[0] if len(produced) == 1 else produced
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
class JqMapper(Mapper):
|
|
88
|
-
"""Replaces every element of a list with what a jq program makes of that element.
|
|
89
|
-
|
|
90
|
-
The program is run once per element, with the element as its input, and is sandboxed
|
|
91
|
-
exactly as ``transform.jq`` is: ``env`` and ``$ENV`` read an empty object.
|
|
92
|
-
|
|
93
|
-
A map replaces each element with one thing, so the stream a program emits for an element
|
|
94
|
-
has to be exactly one output. Zero of them and several of them are both refusals rather
|
|
95
|
-
than a shorter or longer list, because dropping elements is what ``filter.jq`` is for and
|
|
96
|
-
changing their number is what ``transform.jq`` is for.
|
|
97
|
-
"""
|
|
98
|
-
|
|
99
|
-
kind = "jq"
|
|
100
|
-
summary = "Replace every element of a list with what a jq program makes of it."
|
|
101
|
-
config_model: ClassVar[type[BaseModel]] = JqProgramConfig
|
|
102
|
-
|
|
103
|
-
def compile(self, program: str) -> object:
|
|
104
|
-
"""Compile a jq program, refusing a bad one with jq's own message about it."""
|
|
105
|
-
return _compile(program)
|
|
106
|
-
|
|
107
|
-
def apply(self, compiled: object, value: JsonValue) -> JsonValue:
|
|
108
|
-
"""Run the program over one element, and take its single output as the replacement."""
|
|
109
|
-
produced = _outputs(compiled, value)
|
|
110
|
-
if len(produced) != 1:
|
|
111
|
-
raise TransformError(
|
|
112
|
-
f"the program produced {len(produced)} outputs, and a map replaces an element with "
|
|
113
|
-
f"exactly one; a program that drops elements is a filter.jq step, and one that "
|
|
114
|
-
f"changes how many there are is a transform.jq step"
|
|
115
|
-
)
|
|
116
|
-
return produced[0]
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
class JqFilterer(Filterer):
|
|
120
|
-
"""Keeps the elements of a list a jq program answers true for.
|
|
121
|
-
|
|
122
|
-
The program is run once per element, with the element as its input, and is sandboxed
|
|
123
|
-
exactly as ``transform.jq`` is: ``env`` and ``$ENV`` read an empty object.
|
|
124
|
-
|
|
125
|
-
The answer has to be exactly one output, and that output has to be ``true`` or ``false``.
|
|
126
|
-
jq's own truthiness is not applied: a program emitting ``0`` or ``""`` is a mistake
|
|
127
|
-
surfaced rather than an element quietly dropped, and an author who means "has readings"
|
|
128
|
-
writes ``.count > 0``.
|
|
129
|
-
"""
|
|
130
|
-
|
|
131
|
-
kind = "jq"
|
|
132
|
-
summary = "Keep the elements of a list a jq program answers true for."
|
|
133
|
-
config_model: ClassVar[type[BaseModel]] = JqProgramConfig
|
|
134
|
-
|
|
135
|
-
def compile(self, program: str) -> object:
|
|
136
|
-
"""Compile a jq program, refusing a bad one with jq's own message about it."""
|
|
137
|
-
return _compile(program)
|
|
138
|
-
|
|
139
|
-
def keep(self, compiled: object, value: JsonValue) -> bool:
|
|
140
|
-
"""Run the program over one element, and read its single boolean output as the verdict."""
|
|
141
|
-
produced = _outputs(compiled, value)
|
|
142
|
-
if len(produced) != 1:
|
|
143
|
-
raise TransformError(
|
|
144
|
-
f"the program produced {len(produced)} outputs, and a filter answers one true or false "
|
|
145
|
-
f"per element; a program that reshapes an element is a map.jq or transform.jq step"
|
|
146
|
-
)
|
|
147
|
-
answer = produced[0]
|
|
148
|
-
if not isinstance(answer, bool):
|
|
149
|
-
raise TransformError(
|
|
150
|
-
f"the program answered {json.dumps(answer)}, and a filter answers true or false; jq's "
|
|
151
|
-
f"truthiness is not applied, so a program meaning 'has readings' writes '.count > 0'"
|
|
152
|
-
)
|
|
153
|
-
return answer
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|