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 +38 -0
- brevis/context.py +334 -0
- brevis-0.1.0.dist-info/METADATA +113 -0
- brevis-0.1.0.dist-info/RECORD +5 -0
- brevis-0.1.0.dist-info/WHEEL +4 -0
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,,
|