brokoli 0.5.0__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.

Potentially problematic release.


This version of brokoli might be problematic. Click here for more details.

Files changed (62) hide show
  1. brokoli-0.5.0/PKG-INFO +503 -0
  2. brokoli-0.5.0/README.md +468 -0
  3. brokoli-0.5.0/brokoli/__init__.py +143 -0
  4. brokoli-0.5.0/brokoli/async_client.py +322 -0
  5. brokoli-0.5.0/brokoli/cli.py +1073 -0
  6. brokoli-0.5.0/brokoli/client.py +550 -0
  7. brokoli-0.5.0/brokoli/compatibility.py +216 -0
  8. brokoli-0.5.0/brokoli/decorators.py +446 -0
  9. brokoli-0.5.0/brokoli/exceptions.py +65 -0
  10. brokoli-0.5.0/brokoli/ir.py +146 -0
  11. brokoli-0.5.0/brokoli/nodes.py +998 -0
  12. brokoli-0.5.0/brokoli/pagination.py +218 -0
  13. brokoli-0.5.0/brokoli/parsing.py +181 -0
  14. brokoli-0.5.0/brokoli/pipeline.py +2203 -0
  15. brokoli-0.5.0/brokoli/py.typed +0 -0
  16. brokoli-0.5.0/brokoli/resources.py +166 -0
  17. brokoli-0.5.0/brokoli/result.py +73 -0
  18. brokoli-0.5.0/brokoli/sentinel.py +34 -0
  19. brokoli-0.5.0/brokoli/testing.py +283 -0
  20. brokoli-0.5.0/brokoli/validation.py +506 -0
  21. brokoli-0.5.0/brokoli.egg-info/PKG-INFO +503 -0
  22. brokoli-0.5.0/brokoli.egg-info/SOURCES.txt +60 -0
  23. brokoli-0.5.0/brokoli.egg-info/dependency_links.txt +1 -0
  24. brokoli-0.5.0/brokoli.egg-info/entry_points.txt +2 -0
  25. brokoli-0.5.0/brokoli.egg-info/requires.txt +17 -0
  26. brokoli-0.5.0/brokoli.egg-info/top_level.txt +1 -0
  27. brokoli-0.5.0/pyproject.toml +65 -0
  28. brokoli-0.5.0/setup.cfg +4 -0
  29. brokoli-0.5.0/tests/test_async_client.py +265 -0
  30. brokoli-0.5.0/tests/test_authoring_context.py +104 -0
  31. brokoli-0.5.0/tests/test_authoring_honesty.py +92 -0
  32. brokoli-0.5.0/tests/test_cli_deploy.py +275 -0
  33. brokoli-0.5.0/tests/test_cli_environments.py +105 -0
  34. brokoli-0.5.0/tests/test_cli_ir.py +360 -0
  35. brokoli-0.5.0/tests/test_cli_lifecycle.py +172 -0
  36. brokoli-0.5.0/tests/test_cli_loader_packaging.py +95 -0
  37. brokoli-0.5.0/tests/test_cli_run_status.py +210 -0
  38. brokoli-0.5.0/tests/test_cli_version.py +15 -0
  39. brokoli-0.5.0/tests/test_client.py +279 -0
  40. brokoli-0.5.0/tests/test_closure_capture.py +149 -0
  41. brokoli-0.5.0/tests/test_codegen.py +121 -0
  42. brokoli-0.5.0/tests/test_compatibility.py +295 -0
  43. brokoli-0.5.0/tests/test_decorator_packaging.py +150 -0
  44. brokoli-0.5.0/tests/test_decorators.py +269 -0
  45. brokoli-0.5.0/tests/test_deterministic_ids.py +513 -0
  46. brokoli-0.5.0/tests/test_discovery_side_effects.py +90 -0
  47. brokoli-0.5.0/tests/test_feature_preflight.py +143 -0
  48. brokoli-0.5.0/tests/test_ir.py +177 -0
  49. brokoli-0.5.0/tests/test_ir_digest.py +65 -0
  50. brokoli-0.5.0/tests/test_m2_imports_decorators.py +93 -0
  51. brokoli-0.5.0/tests/test_module_packaging.py +214 -0
  52. brokoli-0.5.0/tests/test_options_and_hooks.py +87 -0
  53. brokoli-0.5.0/tests/test_pagination.py +421 -0
  54. brokoli-0.5.0/tests/test_phase0.py +419 -0
  55. brokoli-0.5.0/tests/test_pipeline.py +804 -0
  56. brokoli-0.5.0/tests/test_resources.py +138 -0
  57. brokoli-0.5.0/tests/test_schema_parity.py +114 -0
  58. brokoli-0.5.0/tests/test_testing_harness.py +200 -0
  59. brokoli-0.5.0/tests/test_transform_rename.py +113 -0
  60. brokoli-0.5.0/tests/test_typed_refs.py +377 -0
  61. brokoli-0.5.0/tests/test_validate.py +294 -0
  62. brokoli-0.5.0/tests/test_validation_completeness.py +98 -0
brokoli-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,503 @@
1
+ Metadata-Version: 2.4
2
+ Name: brokoli
3
+ Version: 0.5.0
4
+ Summary: Brokoli Python SDK — define data pipelines in Python, run them visually
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://brokoli.orkestri.site
7
+ Project-URL: Documentation, https://docs-brokoli.orkestri.site
8
+ Project-URL: Repository, https://github.com/Tnsor-Labs/brokoli-sdk
9
+ Project-URL: Issues, https://github.com/Tnsor-Labs/brokoli-sdk/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Database
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: pyyaml>=6.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: jsonschema>=4.0; extra == "dev"
26
+ Requires-Dist: ruff>=0.6; extra == "dev"
27
+ Requires-Dist: mypy>=1.8; extra == "dev"
28
+ Requires-Dist: build>=1.0; extra == "dev"
29
+ Provides-Extra: watch
30
+ Requires-Dist: sodp-client>=0.2.2; extra == "watch"
31
+ Provides-Extra: pandas
32
+ Requires-Dist: pandas>=1.5; extra == "pandas"
33
+ Provides-Extra: pyarrow
34
+ Requires-Dist: pyarrow>=12.0; extra == "pyarrow"
35
+
36
+ # Brokoli Python SDK
37
+
38
+ Define data pipelines in Python, run them visually.
39
+
40
+ See the [developer-experience roadmap](docs/developer-experience-roadmap.md)
41
+ for the current support boundaries and planned SDK/backend work.
42
+
43
+ ```bash
44
+ pip install brokoli
45
+ ```
46
+
47
+ ## Quick Start
48
+
49
+ ```python
50
+ from brokoli import Pipeline, task, source_api, quality_check, sink_file
51
+
52
+ with Pipeline("my_pipeline", schedule="0 6 * * *") as p:
53
+
54
+ data = source_api("Fetch Data", url="https://api.example.com/data", retries=3)
55
+
56
+ @task("Transform")
57
+ def clean(raw):
58
+ return [r for r in raw if r.get("status") == "active"]
59
+
60
+ cleaned = clean(data)
61
+
62
+ quality_check("Validate", cleaned, rules=["not_null(id)", "unique(id)", "row_count(min=1)"])
63
+
64
+ cleaned >> sink_file("Save", path="/tmp/output.csv")
65
+ ```
66
+
67
+ ```bash
68
+ # Deploy to Brokoli server (validates first)
69
+ brokoli deploy my_pipeline.py --server http://localhost:9900
70
+
71
+ # Pipeline appears in the visual editor instantly
72
+ ```
73
+
74
+ ## Core Concepts
75
+
76
+ ### Pipeline
77
+
78
+ Context manager that collects nodes and edges into a DAG.
79
+
80
+ ```python
81
+ with Pipeline(
82
+ "name",
83
+ description="...",
84
+ schedule="0 6 * * *", # cron expression
85
+ sla="07:30 America/New_York", # must finish by this time
86
+ depends_on=["other_pipeline"], # wait for upstream
87
+ tags=["etl", "production"],
88
+ catch_up=True, # backfill missed runs
89
+ webhook=True, # enable HTTP trigger
90
+ on_success=lambda p, run: ..., # lifecycle hooks
91
+ on_failure=lambda p, run, err: ...,
92
+ ) as p:
93
+ ...
94
+ ```
95
+
96
+ ### Nodes
97
+
98
+ 14 built-in node types:
99
+
100
+ ```python
101
+ # Sources
102
+ source_db("Name", query="SELECT ...", conn_id="pg", retries=3)
103
+ source_api("Name", url="https://...", headers={...}, retries=3, timeout=30)
104
+ source_file("Name", path="/data/input.csv", format="csv")
105
+
106
+ # Processing
107
+ transform("Name", input, rules=["drop_null(id)", "deduplicate(email)"])
108
+ join("Name", left, right, on="id=customer_id", how="left")
109
+ quality_check("Name", input, rules=["not_null(id)", "unique(id)", "min(amount, 0)"])
110
+
111
+ # Outputs
112
+ sink_db("Name", input, table="output", mode="append", conn_id="pg")
113
+ sink_file("Name", input, path="/tmp/out.csv", format="csv")
114
+ sink_api("Name", input, url="https://hooks.slack.com/...", method="POST")
115
+ ```
116
+
117
+ Every node-producing API accepts `node_key=` for explicit logical identity:
118
+
119
+ ```python
120
+ orders = source_db("Daily Orders", query="SELECT ...", node_key="orders-source")
121
+ ```
122
+
123
+ Keys are used exactly as IDs and must match
124
+ `^[a-z][a-z0-9_-]{0,63}$`; invalid or duplicate keys fail pipeline
125
+ construction. Without a key, IDs are deterministic within each pipeline:
126
+ the canonical display-name base plus a per-base counter, such as
127
+ `daily_orders_1` and `daily_orders_2`. Adding another node with the same
128
+ canonical name can therefore renumber later same-name nodes; use explicit
129
+ keys where identity must survive reordering or display-name changes.
130
+
131
+ **Migration:** releases before deterministic identity generated random node
132
+ IDs. The first deployment after upgrading will replace those old IDs once;
133
+ assign `node_key` to important nodes before that deployment if downstream
134
+ history or references need a deliberate stable identity.
135
+
136
+ ### @task — Python Functions as Nodes
137
+
138
+ Real Python functions with full IDE support. Source code is extracted at deploy time.
139
+
140
+ ```python
141
+ @task("Compute Features", retries=2, timeout=120)
142
+ def compute(df):
143
+ import pandas as pd
144
+ df = pd.DataFrame(df)
145
+ df["score"] = df["revenue"] * 0.3 + df["usage"] * 0.7
146
+ return df.to_dict("records")
147
+
148
+ result = compute(input_data)
149
+ ```
150
+
151
+ - Functions receive `rows` (list of dicts) from upstream node
152
+ - Return a list of dicts (rows) or a pandas DataFrame
153
+ - Imports inside functions are fine — only needed on the server, not at deploy time
154
+ - Full pytest support — test functions locally before deploying
155
+
156
+ #### Module context — constants, helpers, and imports
157
+
158
+ By default (`package="auto"`), a task's deployed source isn't just its
159
+ isolated function body — any module-level constant or same-module helper
160
+ function it references is auto-detected (by inspecting the function's
161
+ bytecode) and included, and any module-level import it needs is re-emitted
162
+ too:
163
+
164
+ ```python
165
+ API_BASE = "https://api.example.com" # module-level constant
166
+
167
+ def _normalize(row, base): # module-level helper
168
+ row["base"] = base
169
+ return row
170
+
171
+ @task
172
+ def clean(rows):
173
+ return [_normalize(r, API_BASE) for r in rows]
174
+ ```
175
+
176
+ `clean`'s deployed package includes `API_BASE`, `_normalize`, and `clean`
177
+ itself. If a task references something that can't be safely auto-included
178
+ this way — an imported class instance, a bound method, or any other object
179
+ that isn't JSON-serializable data, a same-module function, or an imported
180
+ name — pipeline construction fails **locally**, naming exactly what's
181
+ missing, instead of deploying something that only breaks once it runs
182
+ remotely.
183
+
184
+ For cases auto-detection can't handle (e.g. a task that legitimately needs
185
+ a whole helper module), pass `package="module"` to skip auto-detection and
186
+ deploy the task's entire containing module verbatim instead. This is
187
+ broader and heavier than the default — the whole file ships, including any
188
+ unrelated top-level code in it — so treat it as an escape hatch, not the
189
+ default choice:
190
+
191
+ ```python
192
+ @task(package="module")
193
+ def clean(rows):
194
+ return heavy_helpers.transform(rows)
195
+ ```
196
+
197
+ > Deploying a task with a custom Python `runtime=`/`requirements=` or a
198
+ > container `image=` isn't supported yet — both need backend runtime/image
199
+ > dispatch that doesn't exist yet.
200
+
201
+ ### Conditional branching
202
+
203
+ Use a runtime-supported condition expression and label each outgoing branch
204
+ explicitly. Conditional pipelines emit IR 2.1; ordinary pipelines remain on
205
+ IR 2.0.
206
+
207
+ ```python
208
+ gate = condition_node("Has rows?", expression="row_count > 100", input=data)
209
+ gate.when(sink_db("Production", table="output", conn_id="prod"))
210
+ gate.otherwise(sink_file("Quarantine", path="/tmp/quarantine.csv"))
211
+ ```
212
+
213
+ `@condition` predicates are rejected until the runtime IR can distinguish a
214
+ predicate result from the unchanged branch payload. Nested conditional routing
215
+ is also rejected instead of compiling an ambiguous graph.
216
+
217
+ ### Operators — Chaining & Fan-out
218
+
219
+ ```python
220
+ # Sequential
221
+ a >> b >> c
222
+
223
+ # Fan-out (one source, multiple destinations)
224
+ source >> [sink_db(...), sink_file(...), sink_api(...)]
225
+
226
+ # Fan-in (multiple sources converge)
227
+ a = source_db("A", ...)
228
+ b = source_db("B", ...)
229
+ joined = join("Merge", a, b, on="id")
230
+
231
+ # Fan-out then fan-in
232
+ source >> [branch_a, branch_b] >> merge >> output
233
+ ```
234
+
235
+ ### Typed References & Dynamic Expansion
236
+
237
+ > **Scope note:** everything in this section is SDK API surface and IR
238
+ > compilation only. There's no backend support yet for actually
239
+ > scheduling dynamic per-item task instances, combining dataset
240
+ > manifests, or running partition transforms — the primitives below let
241
+ > you author and validate the IR shape today; execution is
242
+ > physical-planner work that hasn't landed yet.
243
+
244
+ Node-building functions return one of five typed references (all are
245
+ `NodeRef` subclasses, so `>>`, fan-out/fan-in, and everything else above
246
+ keeps working unchanged):
247
+
248
+ - **`DatasetRef`** — a tabular dataset (rows). Returned by `source_db`,
249
+ `source_file`, `source_api(..., response="dataset")` (the default),
250
+ `transform`, `join`, `migrate`, `dbt`.
251
+ - **`ScalarRef`** — a single value. Returned by
252
+ `source_api(..., response="scalar")`.
253
+ - **`ArtifactRef`** — an opaque file/binary blob. Returned by
254
+ `source_api(..., response="artifact")`.
255
+ - **`CollectionRef`** — a dynamic collection of items whose size isn't
256
+ known until the pipeline runs (e.g. one entry per file/page). Not
257
+ returned by any built-in source yet — only produced by
258
+ `@task.expand()` below.
259
+ - **`ConditionRef`** — a condition node with `.when()` and `.otherwise()`
260
+ methods for explicit true/false routing.
261
+
262
+ Sinks, `quality_check`, `code`, and `notify` keep returning a plain
263
+ `NodeRef` — their output shape is either a
264
+ side-effect/gate or genuinely ambiguous (a `code` node can produce
265
+ anything), so they aren't force-fit into one of the typed kinds.
266
+
267
+ #### `.expand()` — dynamic fan-out
268
+
269
+ `a >> [b, c]` fans a node out to a fixed, Python-source-literal list of
270
+ destinations. `.expand()` does the same thing driven by *runtime* data —
271
+ one dynamic task instance per item of an upstream `CollectionRef` —
272
+ compiling to a **single** IR node with an `expansion` policy block, not N
273
+ static nodes:
274
+
275
+ ```python
276
+ @task("Parse File")
277
+ def parse(rows):
278
+ ...
279
+
280
+ # `files` is a CollectionRef (e.g. from a future paginated/listing source)
281
+ parsed = parse.expand(
282
+ file=files,
283
+ key=lambda f: f["path"], # per-item expansion identity
284
+ node_key="parse-files", # identity of the logical expand node
285
+ )
286
+ ```
287
+
288
+ `key=` is optional and gives each dynamic instance a stable identity
289
+ across re-runs. It's never executed locally or turned into a runnable
290
+ script — only a name/description reference is recorded; the real per-item
291
+ keying happens server-side once backend support for dynamic instances
292
+ exists. `.expand()` returns a `CollectionRef` (the dynamic collection of
293
+ per-instance outputs) — chain `.collect(mode="union")` on it to merge
294
+ results back into one dataset.
295
+
296
+ `key=` and `node_key=` are distinct: `key=` derives each runtime item's
297
+ expansion identity, while `node_key=` identifies the single logical node in
298
+ the compiled graph.
299
+
300
+ For backward compatibility, a task parameter literally named `node_key`
301
+ still works: `parse.expand(node_key=files, other=metadata)` treats a
302
+ `CollectionRef` value as an expansion input and uses the decorator/default
303
+ logical identity. A string value, such as
304
+ `parse.expand(node_key="parse-files", file=files)`, is the logical node ID.
305
+ The per-item `key=callable` behavior is unchanged.
306
+
307
+ #### `union()` / `.collect(mode="union")` — combine into one dataset
308
+
309
+ Both compile to the same `union` IR node (capabilities `compute`,
310
+ `dataset-output`) — a dedicated dataset-manifest-combination node, not a
311
+ chain of individual merge edges:
312
+
313
+ ```python
314
+ from brokoli import union
315
+
316
+ # Explicit refs known at authoring time
317
+ combined = union("Combine Pages", page_a, page_b, page_c)
318
+
319
+ # Equivalent, for a single upstream dynamic collection:
320
+ combined = parsed.collect(mode="union")
321
+ ```
322
+
323
+ #### `DatasetRef.map()` / `.filter()` vs. `@map` / `@filter`
324
+
325
+ Two similar-looking styles exist on purpose, for different jobs — pick
326
+ based on whether you want something that actually runs today:
327
+
328
+ | | `@map` / `@filter` decorators | `DatasetRef.map()` / `.filter()` |
329
+ |---|---|---|
330
+ | Executes today? | **Yes** — generates a runnable script, registers a `code` node | **No** — IR only; the function is recorded as a name reference |
331
+ | Operates on | The whole node output at once (a list of row dicts) | Conceptually, one partition at a time (RFC §12.1) |
332
+ | Node type | `code` | `dataset_map` / `dataset_filter` |
333
+
334
+ ```python
335
+ @map("Enrich") # runs today, whole-output
336
+ def enrich(row): ...
337
+
338
+ data.map(enrich_partition) # IR-only, per-partition (no backend support yet)
339
+ ```
340
+
341
+ Use `@map`/`@filter` for anything you need to actually run right now. Use
342
+ `DatasetRef.map()`/`.filter()` only when you're deliberately describing a
343
+ future per-partition transform ahead of backend support.
344
+
345
+ ### Quality Rules
346
+
347
+ String-based quality rules — no boilerplate:
348
+
349
+ ```python
350
+ quality_check("Validate", data, rules=[
351
+ "not_null(email)", # column must not have nulls
352
+ "unique(id)", # all values unique
353
+ "min(amount, 0)", # minimum value
354
+ "max(amount, 1000000)", # maximum value
355
+ "range(score, 0, 100)", # value range
356
+ "row_count(min=100)", # minimum row count
357
+ "row_count(min=1, max=10000)", # row count range
358
+ "regex(email, .*@.*\\..*)", # regex pattern match
359
+ "freshness(updated_at, max_hours=24)", # data freshness
360
+ ])
361
+ ```
362
+
363
+ ## CLI
364
+
365
+ ```bash
366
+ # Deploy pipeline to server (validates before pushing)
367
+ brokoli deploy pipeline.py --server http://localhost:9900 --api-key $API_KEY
368
+
369
+ # Deploy all pipelines in a directory
370
+ brokoli deploy pipelines/ --server http://localhost:9900
371
+
372
+ # Validate without deploying
373
+ brokoli validate pipeline.py --server http://localhost:9900
374
+
375
+ # Export pipeline as JSON (no server needed)
376
+ brokoli export pipeline.py -o pipeline.json
377
+
378
+ # Write a canonical normalized JSON comparison snapshot
379
+ brokoli compile pipeline.py --normalized > pipeline.snapshot.json
380
+
381
+ # Validate and normalize locally without printing IR or calling the server
382
+ brokoli compile pipeline.py --check
383
+
384
+ # Compare local pipeline semantics with deployed definitions
385
+ brokoli diff pipeline.py --server http://localhost:9900 --api-key $API_KEY
386
+
387
+ # Skip validation (not recommended)
388
+ brokoli deploy pipeline.py --skip-validation
389
+
390
+ # Trusted legacy servers without GET /api/capabilities only
391
+ brokoli deploy pipeline.py --allow-legacy-server
392
+ ```
393
+
394
+ `deploy` and `validate` verify the target server's supported pipeline IR
395
+ versions before ordinary validation or persistence. Compatibility failures
396
+ block by default, including when `--skip-validation` is used. The
397
+ `--allow-legacy-server` escape hatch only permits a trusted server whose
398
+ capability endpoint is unavailable; it cannot override a version mismatch
399
+ reported by a reachable server.
400
+
401
+ Normalized snapshots are stable comparison artifacts, not deployment payloads.
402
+ They omit server metadata and layout and normalize only semantically unordered
403
+ values or equivalent defaults. Node, capability, tag, and `depends_on` order is
404
+ normalized; edge order is preserved because it can define input order.
405
+ `--normalized` always emits JSON and overrides the compile format; multiple
406
+ pipelines are emitted as one JSON array. Use the
407
+ ordinary deploy/export paths for wire output. Assign explicit `node_key` values
408
+ to important nodes for durable snapshots, especially where same-name node
409
+ insertion could renumber generated IDs.
410
+
411
+ `compile`, `diff`, and other file-based commands import pipeline modules.
412
+ Imports still execute module top-level code and its side effects.
413
+
414
+ ### Validation
415
+
416
+ The SDK validates before deploying:
417
+
418
+ - Missing required fields (query, url, path, table, conn_id)
419
+ - Empty code nodes
420
+ - Missing quality check rules
421
+ - Missing join keys
422
+ - Disconnected nodes
423
+ - Referenced connections that don't exist on the server
424
+
425
+ ```
426
+ $ brokoli deploy pipeline.py
427
+ Validating Revenue Pipeline...
428
+ ✗ [ERROR] Write to DWH: Connection 'dwh_postgres' does not exist on the server
429
+ Deploy BLOCKED — fix 1 error(s) above
430
+ ```
431
+
432
+ ### Authentication
433
+
434
+ ```bash
435
+ # Via CLI flag
436
+ brokoli deploy pipeline.py --api-key eyJhbG...
437
+
438
+ # Via environment variable
439
+ export BROKOLI_TOKEN=eyJhbG...
440
+ brokoli deploy pipeline.py
441
+ ```
442
+
443
+ ## Template Variables
444
+
445
+ Use Jinja-style templates in node configs — resolved at runtime:
446
+
447
+ ```python
448
+ source_db("Extract", query="SELECT * FROM orders WHERE date = '{{ ds }}'")
449
+ source_api("Fetch", url="https://api.example.com/data?date={{ ds }}")
450
+ source_api("Auth API", headers={"Authorization": "Bearer {{ secret.api_key }}"})
451
+ sink_file("Save", path="/data/output_{{ ds }}.csv")
452
+ ```
453
+
454
+ | Variable | Description |
455
+ |----------|-------------|
456
+ | `{{ ds }}` | Execution date (YYYY-MM-DD) |
457
+ | `{{ next_ds }}` | Next execution date |
458
+ | `{{ ts }}` | Execution timestamp (ISO 8601) |
459
+ | `{{ var.key }}` | Variable from Brokoli Variables store |
460
+ | `{{ secret.key }}` | Secret from environment (`BROKED_SECRET_*`) |
461
+ | `{{ param.key }}` | Runtime parameter |
462
+
463
+ ## Examples
464
+
465
+ See [`examples/`](examples/) for runnable and architecture-reference pipelines:
466
+
467
+ | Example | Use Case |
468
+ |---------|----------|
469
+ | [Hello World](examples/01_hello_world.py) | Public API enrichment and CSV export |
470
+ | [API to Database](examples/02_api_to_database.py) | REST ingestion, validation, and warehouse load |
471
+ | [Join and Quality](examples/03_join_and_quality.py) | Multi-source join, quality gate, and fan-out |
472
+ | [dbt with Alerts](examples/04_dbt_with_alerts.py) | dbt orchestration with conditional notifications |
473
+ | [Custom Sensor Pipeline](examples/05_sensor_custom_pipeline.py) | Decorator-only ingestion with a file sensor |
474
+
475
+ ## How It Works
476
+
477
+ 1. You write Python pipelines using the SDK
478
+ 2. `brokoli deploy` extracts function source code via `inspect.getsource()`
479
+ 3. The SDK generates Brokoli pipeline JSON with auto-computed visual layout
480
+ 4. Validates all nodes, edges, and server connections
481
+ 5. Pushes to the Brokoli server via API
482
+ 6. Pipeline appears in the visual editor — editable in both code and UI
483
+
484
+ ```
485
+ Python SDK Brokoli Server Visual Editor
486
+ ┌──────────┐ ┌──────────────┐ ┌──────────────┐
487
+ │ @task │ │ │ │ │
488
+ │ condition │──────▶│ Pipeline │──────▶│ Drag & Drop │
489
+ │ >> │deploy │ Engine │render │ Canvas │
490
+ │ quality_ │ │ + Profiling │ │ + Preview │
491
+ │ check() │ │ + Alerts │ │ + Profiling │
492
+ └──────────┘ └──────────────┘ └──────────────┘
493
+ ```
494
+
495
+ ## Requirements
496
+
497
+ - Python 3.9+
498
+ - Brokoli server running (for deploy)
499
+ - No external dependencies (stdlib only)
500
+
501
+ ## License
502
+
503
+ Apache 2.0