dirigent-block-jq 0.17.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
2
+
3
+ This source code and accompanying documentation are the property of
4
+ Morten Olav Hansen. No license, express or implied, is granted to use, copy,
5
+ modify, merge, publish, distribute, sublicense, or sell copies of this
6
+ software or its derivatives.
7
+
8
+ The source is published for reference only. Any use beyond reading
9
+ requires written permission from the copyright holder.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
14
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
15
+ OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
16
+
17
+ Third-party components redistributed with this software, and the licences they
18
+ carry, are listed in THIRD_PARTY_NOTICES.md.
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigent-block-jq
3
+ Version: 0.17.2
4
+ Summary: The jq engine for dirigent's transform verbs: transform.jq, map.jq and filter.jq.
5
+ License-Expression: LicenseRef-Proprietary
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Requires-Dist: dirigent-common==0.17.2
10
+ Requires-Dist: dirigent-plugin==0.17.2
11
+ Requires-Dist: jq>=1.8
12
+ Requires-Python: >=3.13
13
+ Description-Content-Type: text/markdown
14
+
15
+ # dirigent-block-jq
16
+
17
+ The jq engine for the transform verbs: `transform.jq` reshapes a whole value with a jq
18
+ program, `map.jq` replaces every element of a list with what a program makes of it, and
19
+ `filter.jq` keeps the elements a program answers true for.
20
+
21
+ A jq program is evaluated in a child process rather than in the worker, because jq holds the
22
+ GIL for as long as a program runs and there is no way to interrupt one. The runner that child
23
+ starts is a module of this package, run by path so it costs jq and the standard library to
24
+ start.
@@ -0,0 +1,10 @@
1
+ # dirigent-block-jq
2
+
3
+ The jq engine for the transform verbs: `transform.jq` reshapes a whole value with a jq
4
+ program, `map.jq` replaces every element of a list with what a program makes of it, and
5
+ `filter.jq` keeps the elements a program answers true for.
6
+
7
+ A jq program is evaluated in a child process rather than in the worker, because jq holds the
8
+ GIL for as long as a program runs and there is no way to interrupt one. The runner that child
9
+ starts is a module of this package, run by path so it costs jq and the standard library to
10
+ start.
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "dirigent-block-jq"
3
+ version = "0.17.2"
4
+ description = "The jq engine for dirigent's transform verbs: transform.jq, map.jq and filter.jq."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
13
+ dependencies = [
14
+ "dirigent-common==0.17.2",
15
+ "dirigent-plugin==0.17.2",
16
+ "jq>=1.8",
17
+ ]
18
+
19
+ [project.entry-points."dirigent.plugins.v1"]
20
+ block-jq = "dirigent_block_jq:plugin"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.12.0,<0.13.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [tool.uv.sources.dirigent-common]
27
+ workspace = true
28
+
29
+ [tool.uv.sources.dirigent-plugin]
30
+ workspace = true
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "dirigent-block-jq"
3
+ version = "0.17.2"
4
+ description = "The jq engine for dirigent's transform verbs: transform.jq, map.jq and filter.jq."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
13
+ dependencies = [
14
+ "dirigent-common==0.17.2",
15
+ "dirigent-plugin==0.17.2",
16
+ "jq>=1.8",
17
+ ]
18
+
19
+ [project.entry-points."dirigent.plugins.v1"]
20
+ block-jq = "dirigent_block_jq:plugin"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.12.0,<0.13.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [tool.uv.sources]
27
+ dirigent-common = { workspace = true }
28
+ dirigent-plugin = { workspace = true }
@@ -0,0 +1,24 @@
1
+ """The jq engine for the transform verbs: reshaping a value, and the element-wise pair."""
2
+
3
+ from dirigent_block_jq.transform_jq import JqFilterer, JqMapper, JqTransformer
4
+ from dirigent_plugin import Contribution, extension
5
+
6
+
7
+ class JqBlocks:
8
+ """The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
9
+
10
+ @extension
11
+ def contribute(self) -> Contribution:
12
+ """Contribute the jq engines behind the transform, map and filter verbs."""
13
+ return Contribution(operators=[JqTransformer(), JqMapper(), JqFilterer()])
14
+
15
+
16
+ plugin = JqBlocks()
17
+
18
+ __all__ = [
19
+ "JqBlocks",
20
+ "JqFilterer",
21
+ "JqMapper",
22
+ "JqTransformer",
23
+ "plugin",
24
+ ]
@@ -0,0 +1,65 @@
1
+ """The jq runner: the process a jq program is compiled and evaluated in, 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
+ The runner protocol is what the pipes carry, one JSON object per line each way: ``compile``
10
+ holds a program under the id it is named by, ``run`` evaluates the program held under an id
11
+ over one value, and ``forget`` releases one. The pipe closing is how the parent says it is
12
+ done, and compiling a program and reading its outputs are the only jq in here.
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 the
16
+ whole plugin 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, holding the programs compiled so far."""
31
+ out = sys.stdout.buffer
32
+ programs: dict[str, Any] = {}
33
+ for line in sys.stdin.buffer:
34
+ reply = answer(programs, json.loads(line))
35
+ out.write(json.dumps(reply).encode() + b"\n")
36
+ out.flush()
37
+
38
+
39
+ def answer(programs: dict[str, Any], request: dict[str, Any]) -> dict[str, Any]:
40
+ """Compile a program under its id, run the one an id names, or release it."""
41
+ kind = request["kind"]
42
+ if kind == "compile":
43
+ try:
44
+ compiled = libjq.compile(request["program"])
45
+ except ValueError as error:
46
+ return {"error": str(error).strip()}
47
+ # Held only once it has compiled, so a refused program is not one an id names.
48
+ programs[request["id"]] = compiled
49
+ return {"ok": True}
50
+ if kind == "forget":
51
+ programs.pop(request["id"], None)
52
+ return {"ok": True}
53
+ if kind == "run":
54
+ compiled = programs.get(request["id"])
55
+ if compiled is None:
56
+ return {"error": f"no program is compiled under {request['id']}"}
57
+ try:
58
+ return {"outputs": compiled.input_value(request["value"]).all()}
59
+ except ValueError as error:
60
+ return {"error": str(error).strip()}
61
+ return {"error": f"{kind} is not a request kind: compile, run, or forget"}
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
@@ -0,0 +1,36 @@
1
+ """Every refusal the jq engine makes, catalogued under the ``transform.jq`` prefix.
2
+
3
+ A jq refusal reaches an attempt as ``plugin.transform_failed``: the verb frame owns the
4
+ failure, and the engine's sentence rides on it as the ``detail`` param.
5
+ """
6
+
7
+ from dirigent_common import Catalogue
8
+
9
+ JQ = Catalogue("transform.jq")
10
+
11
+ PROGRAM_REFUSED = JQ.define("program_refused", "{detail}")
12
+
13
+ NO_OUTPUT = JQ.define(
14
+ "no_output",
15
+ "the program produced no output, and a transform step has to produce one; "
16
+ "a program that means 'possibly nothing' emits [] or null explicitly",
17
+ )
18
+
19
+ MAP_OUTPUT_COUNT = JQ.define(
20
+ "map_output_count",
21
+ "the program produced {produced} outputs, and a map replaces an element with "
22
+ "exactly one; a program that drops elements is a filter.jq step, and one that "
23
+ "changes how many there are is a transform.jq step",
24
+ )
25
+
26
+ FILTER_OUTPUT_COUNT = JQ.define(
27
+ "filter_output_count",
28
+ "the program produced {produced} outputs, and a filter answers one true or false "
29
+ "per element; a program that reshapes an element is a map.jq or transform.jq step",
30
+ )
31
+
32
+ FILTER_ANSWER = JQ.define(
33
+ "filter_answer",
34
+ "the program answered {answer}, and a filter answers true or false; jq's "
35
+ "truthiness is not applied, so a program meaning 'has readings' writes '.count > 0'",
36
+ )
@@ -0,0 +1,155 @@
1
+ """The ``jq`` engines: a jq program as the transform, map, and filter verbs' program.
2
+
3
+ A program is compiled and evaluated in a jq process of its own, over the runner protocol the
4
+ plugin contract defines. 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
+ A bad program is refused with jq's own message: at apply on the worker, where there is no
11
+ runner, and again by the runner when the step compiles it.
12
+ """
13
+
14
+ import json
15
+ import sys
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+ from typing import Annotated, Any, ClassVar
19
+
20
+ import jq
21
+ from pydantic import BaseModel, Field, JsonValue
22
+
23
+ from dirigent_block_jq.messages import (
24
+ FILTER_ANSWER,
25
+ FILTER_OUTPUT_COUNT,
26
+ MAP_OUTPUT_COUNT,
27
+ PROGRAM_REFUSED,
28
+ )
29
+ from dirigent_block_jq.messages import NO_OUTPUT as NO_OUTPUT_MESSAGE
30
+ from dirigent_common import JQ_MEDIA_TYPE
31
+ from dirigent_plugin import Filterer, Mapper, ProgramConfig, RunnerEngine, Transformer, TransformError
32
+
33
+ #: What a program producing nothing is told to write instead.
34
+ NO_OUTPUT = NO_OUTPUT_MESSAGE.render()
35
+
36
+ #: Wraps the author's program so jq's ``env`` and ``$ENV`` read an empty object instead of
37
+ #: the worker's environment, which is where dirigent's own secrets live. The program keeps
38
+ #: its own line numbers because the message a bad one is refused with comes from compiling
39
+ #: the author's text, unwrapped.
40
+ SANDBOX = "{} as $ENV | def env: {}; (\n"
41
+
42
+ #: The script a jq process runs, started by path so that it imports jq and the standard
43
+ #: library and nothing else.
44
+ RUNNER = Path(__file__).with_name("jq_runner.py")
45
+
46
+ #: Typed ``Any`` because the jq binding is a C extension that ships no type information.
47
+ libjq: Any = jq
48
+
49
+
50
+ class JqEngine(RunnerEngine):
51
+ """Where the three jq engines' programs run: in a jq process, never on the worker's loop."""
52
+
53
+ command: ClassVar[Sequence[str]] = (sys.executable, str(RUNNER))
54
+
55
+ def source(self, program: str) -> str:
56
+ """Hand the runner the author's program with env and $ENV shadowed."""
57
+ return f"{SANDBOX}{program}\n)"
58
+
59
+ def check_program(self, program: str) -> None:
60
+ """Compile the program here at apply, refusing a bad one with jq's own message about it."""
61
+ try:
62
+ # The author's text is compiled first, so a refusal carries jq's message about
63
+ # their program and not about the wrapper.
64
+ libjq.compile(program)
65
+ libjq.compile(self.source(program))
66
+ except ValueError as error:
67
+ raise TransformError(PROGRAM_REFUSED.render(detail=str(error).strip())) from error
68
+
69
+
70
+ class JqProgramConfig(ProgramConfig):
71
+ """The program-shaped config of a jq engine, whichever verb is running it.
72
+
73
+ All three verbs take the same program in the same language, so all three publish the
74
+ same config: the frame's fields, with ``program`` narrowed to jq source.
75
+ """
76
+
77
+ program: Annotated[str, Field(min_length=1, json_schema_extra={"contentMediaType": JQ_MEDIA_TYPE})]
78
+ """The jq program this step runs."""
79
+
80
+
81
+ class JqTransformer(JqEngine, Transformer):
82
+ """Reshapes one whole value by running a jq program over it.
83
+
84
+ A jq program opens no file, no socket, and starts nothing: it is handed a value and
85
+ returns values, which is why this engine executes no code on the worker and needs no
86
+ allowlist entry. Its two ways of reading an environment, ``env`` and ``$ENV``, are
87
+ shadowed, so a program sees an empty object rather than the process it runs in -- which
88
+ is a jq process of dirigent's own, so that a step's timeout can end it.
89
+
90
+ A jq program is a stream: it can emit zero, one, or many outputs. One output is the
91
+ step's value, many outputs are that list of values, and none is a refusal, because a
92
+ step that produced nothing has no output for the next step to read.
93
+ """
94
+
95
+ kind = "jq"
96
+ summary = "Reshape a value with a jq program."
97
+ config_model: ClassVar[type[BaseModel]] = JqProgramConfig
98
+
99
+ def apply(self, compiled: object, value: JsonValue) -> JsonValue:
100
+ """Run the program over one value, and read its stream of outputs as one result."""
101
+ produced = self.outputs(compiled, value)
102
+ if not produced:
103
+ raise TransformError(NO_OUTPUT)
104
+ return produced[0] if len(produced) == 1 else produced
105
+
106
+
107
+ class JqMapper(JqEngine, Mapper):
108
+ """Replaces every element of a list with what a jq program makes of that element.
109
+
110
+ The program is run once per element, with the element as its input, and is sandboxed
111
+ exactly as ``transform.jq`` is: ``env`` and ``$ENV`` read an empty object.
112
+
113
+ A map replaces each element with one thing, so the stream a program emits for an element
114
+ has to be exactly one output. Zero of them and several of them are both refusals rather
115
+ than a shorter or longer list, because dropping elements is what ``filter.jq`` is for and
116
+ changing their number is what ``transform.jq`` is for.
117
+ """
118
+
119
+ kind = "jq"
120
+ summary = "Replace every element of a list with what a jq program makes of it."
121
+ config_model: ClassVar[type[BaseModel]] = JqProgramConfig
122
+
123
+ def apply(self, compiled: object, value: JsonValue) -> JsonValue:
124
+ """Run the program over one element, and take its single output as the replacement."""
125
+ produced = self.outputs(compiled, value)
126
+ if len(produced) != 1:
127
+ raise TransformError(MAP_OUTPUT_COUNT.render(produced=len(produced)))
128
+ return produced[0]
129
+
130
+
131
+ class JqFilterer(JqEngine, Filterer):
132
+ """Keeps the elements of a list a jq program answers true for.
133
+
134
+ The program is run once per element, with the element as its input, and is sandboxed
135
+ exactly as ``transform.jq`` is: ``env`` and ``$ENV`` read an empty object.
136
+
137
+ The answer has to be exactly one output, and that output has to be ``true`` or ``false``.
138
+ jq's own truthiness is not applied: a program emitting ``0`` or ``""`` is a mistake
139
+ surfaced rather than an element quietly dropped, and an author who means "has readings"
140
+ writes ``.count > 0``.
141
+ """
142
+
143
+ kind = "jq"
144
+ summary = "Keep the elements of a list a jq program answers true for."
145
+ config_model: ClassVar[type[BaseModel]] = JqProgramConfig
146
+
147
+ def keep(self, compiled: object, value: JsonValue) -> bool:
148
+ """Run the program over one element, and read its single boolean output as the verdict."""
149
+ produced = self.outputs(compiled, value)
150
+ if len(produced) != 1:
151
+ raise TransformError(FILTER_OUTPUT_COUNT.render(produced=len(produced)))
152
+ answer = produced[0]
153
+ if not isinstance(answer, bool):
154
+ raise TransformError(FILTER_ANSWER.render(answer=json.dumps(answer)))
155
+ return answer