brevis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
brevis/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Brevis context: what one step tells the next.
2
+
3
+ from brevis import context
4
+
5
+ bucket = context.get("extract.bucket")
6
+ context.set(rows=48213, watermark="2026-09-07T03:00:00Z")
7
+
8
+ That is the whole library. It is not a port of the Go SDK -- no drivers, no
9
+ pagination, no ingestion ids. Python already has better tools for those, and
10
+ this package exists so a team can use them and still get the one thing an
11
+ orchestrator is for.
12
+
13
+ It has no dependencies and never will. It reads one environment variable and
14
+ writes one file.
15
+ """
16
+
17
+ from . import context
18
+ from .context import (
19
+ ContextError,
20
+ NotVisible,
21
+ TooLarge,
22
+ get,
23
+ of,
24
+ published,
25
+ set, # noqa: A004 -- shadowing the builtin is deliberate; see context.set
26
+ )
27
+
28
+ __all__ = [
29
+ "ContextError",
30
+ "NotVisible",
31
+ "TooLarge",
32
+ "context",
33
+ "get",
34
+ "of",
35
+ "published",
36
+ "set",
37
+ ]
38
+ __version__ = "0.1.0"
brevis/context.py ADDED
@@ -0,0 +1,334 @@
1
+ """Read what the steps before published; publish something for the ones after.
2
+
3
+ # Why this is a module and not a class
4
+
5
+ ``BrevisContext.get(...)`` as a classmethod suggests there could be two of them.
6
+ There cannot: a process is inside exactly one step of one run. The import says
7
+ that, and there is no object to pass around or to mock.
8
+
9
+ # How it reaches the engine
10
+
11
+ It does not. There is no API call, no token, no port and no network.
12
+
13
+ The engine hands a step ``BREVIS_INPUT`` when it starts it, and reads back the
14
+ file at ``BREVIS_OUTPUT`` when it ends -- under Kubernetes that path is
15
+ ``/dev/termination-log``, which the engine already reads to get the exit code.
16
+ So this package is a JSON parse and a file write, which is why it is pure
17
+ standard library and why its tests need no cluster, no database and no engine.
18
+
19
+ # The ceiling
20
+
21
+ 4096 bytes, and it is the platform's number rather than ours. The termination
22
+ message is TRUNCATED rather than refused, so an oversized object arrives cut
23
+ mid-string and reads downstream as "this step published nothing". Refusing
24
+ before writing is what turns that into a message.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import atexit
30
+ import json
31
+ import logging
32
+ import os
33
+ import tempfile
34
+ from typing import Any, Mapping
35
+
36
+ __all__ = [
37
+ "ContextError",
38
+ "NotVisible",
39
+ "TooLarge",
40
+ "get",
41
+ "of",
42
+ "published",
43
+ "set",
44
+ ]
45
+
46
+ ENV_INPUT = "BREVIS_INPUT"
47
+ ENV_OUTPUT = "BREVIS_OUTPUT"
48
+ ENV_RUN_ID = "BREVIS_RUN_ID"
49
+
50
+ #: Kubernetes truncates the termination message at this size. See the module
51
+ #: docstring: inheriting the platform's ceiling is the design.
52
+ MAX_BYTES = 4096
53
+
54
+ _log = logging.getLogger("brevis")
55
+
56
+
57
+ class ContextError(Exception):
58
+ """Anything this module refuses."""
59
+
60
+
61
+ class NotVisible(ContextError, KeyError):
62
+ """A step was read that this one does not depend on.
63
+
64
+ It subclasses KeyError so ``except KeyError`` around a lookup keeps working,
65
+ and ContextError so a caller can tell it from a plain missing dict key.
66
+ """
67
+
68
+
69
+ class TooLarge(ContextError):
70
+ """More was published than the termination message can carry."""
71
+
72
+
73
+ # --------------------------------------------------------------------------
74
+ # Reading
75
+ # --------------------------------------------------------------------------
76
+
77
+
78
+ def _incoming() -> dict[str, dict[str, Any]]:
79
+ raw = os.environ.get(ENV_INPUT, "").strip()
80
+ if not raw:
81
+ return {}
82
+ try:
83
+ parsed = json.loads(raw)
84
+ except json.JSONDecodeError as exc: # pragma: no cover - the engine writes it
85
+ raise ContextError(
86
+ f"{ENV_INPUT} is not valid JSON. The engine writes it, so this means a "
87
+ f"version mismatch rather than anything you did: {exc}"
88
+ ) from exc
89
+ if not isinstance(parsed, dict):
90
+ raise ContextError(f"{ENV_INPUT} has to be a JSON object, and it is {type(parsed).__name__}")
91
+ return parsed
92
+
93
+
94
+ def get(key: str, default: Any = None) -> Any:
95
+ """Read one value published by a step this one depends on.
96
+
97
+ bucket = context.get("extract.bucket")
98
+
99
+ The key is ALWAYS qualified by the step that wrote it, and a bare
100
+ ``get("bucket")`` is refused naming the steps that published it.
101
+
102
+ That is not ceremony. Steps are isolated by design -- ``extract`` and
103
+ ``transform`` cannot overwrite each other, because each writes only its own
104
+ namespace -- and a bare key throws that away the moment two of them publish
105
+ ``bucket``. Searching and picking one is precedence by accident: it works
106
+ until it silently does not, and somebody spends an afternoon on a value that
107
+ came from the wrong step.
108
+
109
+ An absent key is not an error; that is what ``default`` is for. An absent
110
+ STEP is, because it means the pipeline does not say what the code assumes.
111
+ """
112
+ step, sep, field = key.partition(".")
113
+ if not sep or not field:
114
+ raise ContextError(_unqualified(key))
115
+
116
+ incoming = _incoming()
117
+ if step not in incoming:
118
+ raise NotVisible(_not_visible(step, sorted(incoming)))
119
+ return incoming[step].get(field, default)
120
+
121
+
122
+ def of(step: str) -> Mapping[str, Any]:
123
+ """Everything one step published, as a read-only mapping.
124
+
125
+ For the case where a step publishes several related values and the reader
126
+ wants them together. It returns a copy: mutating what came in would look
127
+ like it changed something.
128
+ """
129
+ incoming = _incoming()
130
+ if step not in incoming:
131
+ raise NotVisible(_not_visible(step, sorted(incoming)))
132
+ return dict(incoming[step])
133
+
134
+
135
+ def _unqualified(key: str) -> str:
136
+ owners = [s for s, values in _incoming().items() if key in values]
137
+ if owners:
138
+ suggestion = ", ".join(f'"{s}.{key}"' for s in sorted(owners))
139
+ return (
140
+ f'get("{key}") does not say which step published it, and '
141
+ f"{len(owners)} did. Ask for one of: {suggestion}"
142
+ )
143
+ return (
144
+ f'get("{key}") does not say which step published it. The form is '
145
+ f'get("<step>.{key}") -- context is keyed by the step that wrote it, so '
146
+ f"two steps can publish the same name without either losing it"
147
+ )
148
+
149
+
150
+ def _not_visible(step: str, visible: list[str]) -> str:
151
+ if not visible:
152
+ return (
153
+ f'no context is visible to this step, so "{step}" cannot be read. '
154
+ f"Add it to this step's depends_on in the workflow"
155
+ )
156
+ return (
157
+ f'step "{step}" is not visible to this step. Visible: '
158
+ f"{', '.join(visible)}. Add \"{step}\" to depends_on"
159
+ )
160
+
161
+
162
+ # --------------------------------------------------------------------------
163
+ # Writing
164
+ # --------------------------------------------------------------------------
165
+
166
+ _pending: dict[str, Any] = {}
167
+ _written = False
168
+ _warned = False
169
+
170
+
171
+ def set(*mapping: Mapping[str, Any], **values: Any) -> None: # noqa: A001
172
+ """Publish values for the steps that depend on this one.
173
+
174
+ context.set(name="Daniel")
175
+ context.set(label="Nome")
176
+ context.set({"partitions": ["2026-09-06", "2026-09-07"]})
177
+
178
+ Calls MERGE. Those two lines leave both ``name`` and ``label`` published;
179
+ setting the same key twice keeps the last write. Replacing wholesale would
180
+ let a helper that publishes one key silently erase what the caller
181
+ published, which is the kind of bug that only shows up in the step after.
182
+
183
+ There is no step argument, and that is what makes the isolation structural:
184
+ the only namespace a process can write is its own.
185
+
186
+ Nothing reaches the disk until the process exits. A step that dies hard
187
+ publishes nothing -- which is correct, because a crashed step's output
188
+ described work that did not finish.
189
+ """
190
+ if _written:
191
+ raise ContextError(
192
+ "context.set() was called after the context was already written. That "
193
+ "happens when set() runs inside an atexit handler registered before "
194
+ "this module was imported; move the call into your program's normal flow"
195
+ )
196
+
197
+ merged: dict[str, Any] = {}
198
+ for m in mapping:
199
+ if not isinstance(m, Mapping):
200
+ raise ContextError(
201
+ f"context.set() takes keyword arguments or a mapping, and got "
202
+ f"{type(m).__name__}"
203
+ )
204
+ merged.update(m)
205
+ merged.update(values)
206
+
207
+ for key, value in merged.items():
208
+ if not isinstance(key, str):
209
+ raise ContextError(
210
+ f"context key {key!r} is a {type(key).__name__}, and JSON object keys "
211
+ f"are strings. It would come back as \"{key}\" and the lookup would miss"
212
+ )
213
+ _check_serializable(key, value)
214
+
215
+ if not _enabled():
216
+ return
217
+
218
+ _pending.update(merged)
219
+ _check_size()
220
+
221
+
222
+ def published() -> Mapping[str, Any]:
223
+ """What this step has published so far, for a test or an assertion."""
224
+ return dict(_pending)
225
+
226
+
227
+ def _check_serializable(key: str, value: Any) -> None:
228
+ try:
229
+ json.dumps(value)
230
+ except (TypeError, ValueError) as exc:
231
+ hint = ""
232
+ if hasattr(value, "isoformat"):
233
+ hint = ". For a date or a datetime, pass .isoformat()"
234
+ raise ContextError(
235
+ f"context key {key!r} holds a {type(value).__name__}, which is not JSON: "
236
+ f"{exc}{hint}"
237
+ ) from exc
238
+
239
+
240
+ def _check_size() -> None:
241
+ encoded = json.dumps(_pending, separators=(",", ":"), sort_keys=True)
242
+ size = len(encoded.encode("utf-8"))
243
+ if size <= MAX_BYTES:
244
+ return
245
+
246
+ # Naming the largest keys is the useful half: "4.6 KB is too big" leaves the
247
+ # caller to find which value it was.
248
+ largest = sorted(
249
+ ((k, len(json.dumps(v, separators=(",", ":")).encode("utf-8"))) for k, v in _pending.items()),
250
+ key=lambda kv: -kv[1],
251
+ )[:3]
252
+ culprits = ", ".join(f"{k} ({n} bytes)" for k, n in largest)
253
+ raise TooLarge(
254
+ f"the context is {size} bytes and the ceiling is {MAX_BYTES}. It travels in the "
255
+ f"container's termination message, which the platform TRUNCATES rather than "
256
+ f"refuses -- so an oversized object arrives cut in half and reads downstream as "
257
+ f"'this step published nothing'. Largest: {culprits}. Publish a reference (a "
258
+ f"path, a watermark, a count) and leave the data where it is"
259
+ )
260
+
261
+
262
+ # --------------------------------------------------------------------------
263
+ # Running outside Brevis
264
+ # --------------------------------------------------------------------------
265
+
266
+
267
+ def _enabled() -> bool:
268
+ """Whether anything published will actually be read.
269
+
270
+ Said once, at INFO, and not raised. A script that cannot be run by hand
271
+ cannot be developed -- and a set() that quietly did nothing in production is
272
+ exactly the silent failure this library is against, so the line is what
273
+ tells the two apart.
274
+ """
275
+ global _warned
276
+ if os.environ.get(ENV_OUTPUT):
277
+ return True
278
+ if not _warned:
279
+ _warned = True
280
+ _log.info(
281
+ "brevis: not running under Brevis (%s is unset), so context.set() is a "
282
+ "no-op. Under the engine it publishes to the steps that depend on this one",
283
+ ENV_OUTPUT,
284
+ )
285
+ return False
286
+
287
+
288
+ def _flush() -> None:
289
+ """Write once, at exit. Registered on import, so a caller need not."""
290
+ global _written
291
+ if _written or not _pending:
292
+ return
293
+ path = os.environ.get(ENV_OUTPUT)
294
+ if not path:
295
+ return
296
+
297
+ _written = True
298
+ payload = json.dumps(_pending, separators=(",", ":"), sort_keys=True)
299
+
300
+ try:
301
+ # /dev/termination-log is a character device: it is written directly,
302
+ # never renamed into place. A temp-file-and-rename would fail there, and
303
+ # atomicity is not needed because the engine reads this exactly once,
304
+ # after the process is gone.
305
+ with open(path, "w", encoding="utf-8") as fh:
306
+ fh.write(payload)
307
+ except OSError as exc:
308
+ # Loud, and on stderr, because the step is about to be reported as
309
+ # succeeded while the step after it will not find what it needs.
310
+ _log.error(
311
+ "brevis: could not publish the context to %s: %s. The steps depending on "
312
+ "this one will not see it",
313
+ path,
314
+ exc,
315
+ )
316
+
317
+
318
+ atexit.register(_flush)
319
+
320
+
321
+ def _reset_for_tests(output: str | None = None) -> str:
322
+ """Clear the module's state between tests, and point the output somewhere.
323
+
324
+ Exported with a leading underscore because it is not part of the contract:
325
+ a real step is one process, one step, one write, and has no reason to reset.
326
+ """
327
+ global _written, _warned
328
+ _pending.clear()
329
+ _written = False
330
+ _warned = False
331
+ if output is None:
332
+ output = os.path.join(tempfile.mkdtemp(), "output")
333
+ os.environ[ENV_OUTPUT] = output
334
+ return output
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: brevis
3
+ Version: 0.1.0
4
+ Summary: Pass context between the steps of a Brevis workflow
5
+ Project-URL: Homepage, https://github.com/AreteAcademy/brevis
6
+ Project-URL: Source, https://github.com/AreteAcademy/brevis/tree/master/lib/python-context
7
+ License-Expression: MIT
8
+ Keywords: brevis,data-engineering,etl,orchestration
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: System :: Distributed Computing
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # brevis
21
+
22
+ Pass context between the steps of a Brevis workflow. That is all it does.
23
+
24
+ ```bash
25
+ pip install brevis
26
+ ```
27
+
28
+ ```python
29
+ from brevis import context
30
+
31
+ bucket = context.get("extract.bucket")
32
+
33
+ context.set(rows=48213, watermark="2026-09-07T03:00:00Z")
34
+ ```
35
+
36
+ **No dependencies, ever.** It reads one environment variable and writes one
37
+ file. There is no API call, no token and no port: the engine hands a step its
38
+ input when it starts it and reads the output back when it ends.
39
+
40
+ This is **not** a port of the Go SDK. No drivers, no pagination, no ingestion
41
+ ids — Python already has better tools for those, and this exists so a team can
42
+ use pandas, Polars or dbt and still get the one thing an orchestrator is for.
43
+
44
+ ## Reading
45
+
46
+ A key is always qualified by the step that published it:
47
+
48
+ ```python
49
+ context.get("extract.bucket") # the value
50
+ context.get("extract.nope", default=0) # absent is not an error
51
+ context.of("extract") # everything that step published
52
+ ```
53
+
54
+ `context.get("bucket")` with no step is **refused**, naming the steps that
55
+ published it. Steps are isolated — `extract` and `transform` can both publish
56
+ `bucket` without either losing it — and a bare key throws that away the moment
57
+ two of them do. Searching and picking one is precedence by accident: it works
58
+ until it silently does not.
59
+
60
+ Reading a step this one does not `depends_on` is an error pointing at the fix.
61
+
62
+ ## Writing
63
+
64
+ ```python
65
+ context.set(name="Daniel")
66
+ context.set(label="Nome")
67
+ # both survive: {"name": "Daniel", "label": "Nome"}
68
+ ```
69
+
70
+ Calls **merge**. Setting the same key twice keeps the last write; setting a
71
+ different key leaves the first alone. There is no step argument — the only
72
+ namespace a process can write is its own, which is what makes the isolation
73
+ structural rather than a rule.
74
+
75
+ Nothing reaches the disk until the process exits, so a step that dies hard
76
+ publishes nothing. That is correct: a crashed step's output described work that
77
+ did not finish.
78
+
79
+ ## What it refuses
80
+
81
+ | | because |
82
+ |---|---|
83
+ | more than 4096 bytes | the platform **truncates** rather than refuses, so an oversized object arrives cut in half and reads downstream as "published nothing" |
84
+ | a value that is not JSON | a `datetime` becomes `"..."` or vanishes depending on who serializes it. The error names the key and suggests `.isoformat()` |
85
+ | a non-string key | `{1: "x"}` comes back as `{"1": "x"}` and the lookup misses |
86
+
87
+ The ceiling is the platform's number, not ours. Every "context between tasks"
88
+ feature fails the same way — somebody puts **data** where only **context** fits.
89
+ Four kilobytes hold a watermark, a count or a list of partitions. Publish a
90
+ reference and leave the rows where they are.
91
+
92
+ ## Running it by hand
93
+
94
+ Outside Brevis there is no input and nothing reads the output. `get()` returns
95
+ the default, `set()` accepts and discards, and the library says so once at INFO.
96
+ It does not raise: a script that cannot be run by hand cannot be developed. The
97
+ validation still runs, so a value that works on your laptop works in production.
98
+
99
+ ## This is not a secret store
100
+
101
+ Everything published is visible to anyone who can see the run — it is in the
102
+ pod's status and in the run history. Publish a path, not a signed URL.
103
+
104
+ ## Testing your step
105
+
106
+ The whole contract is two environment variables, so there is nothing to mock:
107
+
108
+ ```python
109
+ def test_my_step(monkeypatch, tmp_path):
110
+ monkeypatch.setenv("BREVIS_INPUT", '{"extract": {"rows": 42}}')
111
+ monkeypatch.setenv("BREVIS_OUTPUT", str(tmp_path / "out"))
112
+ assert context.get("extract.rows") == 42
113
+ ```
@@ -0,0 +1,5 @@
1
+ brevis/__init__.py,sha256=EqTSqfSwb969oQ2y9kybig8sy2QczazVCGMkBP1knTM,897
2
+ brevis/context.py,sha256=CIEQHRKRceVbKgllU2RKVtiGya6mSWga0zQhH3Mxe2c,11749
3
+ brevis-0.1.0.dist-info/METADATA,sha256=-efc0pK62q9eOqh1lkd8Qatc_LuY_gBlpULw_DXfgrU,4368
4
+ brevis-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
5
+ brevis-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any