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.
Files changed (35) hide show
  1. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/PKG-INFO +3 -3
  2. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/pyproject.toml +3 -3
  3. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/pyproject.toml.orig +3 -3
  4. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/docker.py +14 -6
  5. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/git.py +36 -2
  6. dirigent_blocks-0.16.4/src/dirigent_blocks/jq_runner.py +54 -0
  7. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/rabbitmq.py +37 -11
  8. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/shell.py +31 -8
  9. dirigent_blocks-0.16.4/src/dirigent_blocks/transform_jq.py +318 -0
  10. dirigent_blocks-0.16.2/src/dirigent_blocks/transform_jq.py +0 -153
  11. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/LICENSE +0 -0
  12. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/README.md +0 -0
  13. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/__init__.py +0 -0
  14. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/build.py +0 -0
  15. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/capture.py +0 -0
  16. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/clock.py +0 -0
  17. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/compose.py +0 -0
  18. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/connections.py +0 -0
  19. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/convert_std.py +0 -0
  20. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/environment.py +0 -0
  21. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/http.py +0 -0
  22. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/kafka.py +0 -0
  23. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/logging.py +0 -0
  24. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/notifiers.py +0 -0
  25. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/pipelines.py +0 -0
  26. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/py.typed +0 -0
  27. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/reap.py +0 -0
  28. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/report.py +0 -0
  29. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/secrets.py +0 -0
  30. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/sql.py +0 -0
  31. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/storage.py +0 -0
  32. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/subprocess.py +0 -0
  33. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/validate.py +0 -0
  34. {dirigent_blocks-0.16.2 → dirigent_blocks-0.16.4}/src/dirigent_blocks/values.py +0 -0
  35. {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.2
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.2
11
- Requires-Dist: dirigent-plugin==0.16.2
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.2"
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.2",
14
- "dirigent-plugin==0.16.2",
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.2"
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.2",
14
- "dirigent-plugin==0.16.2",
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(BlockModel):
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 ``${...}`` substituted into it is shell-quoted by the engine, so a value that came
385
- from a webhook payload is one word rather than one program. Prefer ``argv``: it involves
386
- no shell at all."""
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
- return [f"{name}={value}" for name, value in {**inherited, **config.env}.items()]
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 / target
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
- nacks everything it took back onto the queue with ``requeue``, so a batch too small to act on
11
- is left for the next poke or another consumer rather than swallowed. The cursor here carries
12
- nothing the sensor needs, only what a reader wants -- how many messages have gone past and the
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, and a poke that parks nacks what
217
- it took back onto the queue with ``requeue``, so nothing is lost to a batch that was too
218
- small and another consumer may take it instead. ``always`` acknowledges every message the
219
- moment it is taken, which suits a queue nothing else reads and a step that would rather
220
- drop a partial batch than see it twice."""
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 BlockFailure, ErrorClass, Operator, OperatorSpec, RemoteHandle, ShellString, StepContext
19
-
20
-
21
- class ShellRunConfig(BlockModel):
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 ``${...}`` substituted into it is shell-quoted by the engine, so a value that came
31
- from a webhook payload is one word rather than one program. Prefer ``argv`` anyway: it
32
- involves no shell at all."""
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=subprocess.environment(config.env_allowlist, config.env, directory),
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