broccoli-workers 0.1.0__tar.gz → 0.1.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.
Files changed (45) hide show
  1. broccoli_workers-0.1.2/PKG-INFO +539 -0
  2. broccoli_workers-0.1.2/README.md +491 -0
  3. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/cli.py +263 -40
  4. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/core/__init__.py +15 -15
  5. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/core/chain/chain.py +17 -3
  6. broccoli_workers-0.1.2/broccoli/core/chain/chain_queue.py +21 -0
  7. broccoli_workers-0.1.2/broccoli/core/chain/task_chain.py +169 -0
  8. broccoli_workers-0.1.2/broccoli/core/gpu/gpu_queue.py +12 -0
  9. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/core/health.py +20 -20
  10. broccoli_workers-0.1.2/broccoli/core/redis_controller.py +66 -0
  11. broccoli_workers-0.1.2/broccoli/core/result.py +60 -0
  12. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/core/task/task.py +36 -6
  13. broccoli_workers-0.1.2/broccoli/core/task/task_queue.py +517 -0
  14. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/core/task/task_registry.py +47 -47
  15. broccoli_workers-0.1.2/broccoli/logging_config.py +42 -0
  16. broccoli_workers-0.1.2/broccoli/workers/GPUAutoScalingPool.py +68 -0
  17. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/workers/__init__.py +2 -0
  18. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/workers/async_worker.py +21 -2
  19. broccoli_workers-0.1.2/broccoli/workers/auto_scale_worker.py +114 -0
  20. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/workers/base_worker.py +98 -25
  21. broccoli_workers-0.1.2/broccoli/workers/chain_worker.py +131 -0
  22. broccoli_workers-0.1.2/broccoli/workers/gpu_worker.py +60 -0
  23. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/workers/hybrid_worker.py +29 -10
  24. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/workers/threaded_worker.py +20 -8
  25. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/workers/worker_pool.py +144 -99
  26. broccoli_workers-0.1.2/broccoli_workers.egg-info/PKG-INFO +539 -0
  27. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli_workers.egg-info/SOURCES.txt +5 -1
  28. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/pyproject.toml +1 -1
  29. broccoli_workers-0.1.0/PKG-INFO +0 -594
  30. broccoli_workers-0.1.0/README.md +0 -546
  31. broccoli_workers-0.1.0/broccoli/core/chain/chain_mixin.py +0 -49
  32. broccoli_workers-0.1.0/broccoli/core/chain/chain_queue.py +0 -299
  33. broccoli_workers-0.1.0/broccoli/core/chain/task_chain.py +0 -201
  34. broccoli_workers-0.1.0/broccoli/core/redis_controller.py +0 -35
  35. broccoli_workers-0.1.0/broccoli/core/result.py +0 -78
  36. broccoli_workers-0.1.0/broccoli/core/task/task_queue.py +0 -300
  37. broccoli_workers-0.1.0/broccoli/workers/chain_worker.py +0 -94
  38. broccoli_workers-0.1.0/broccoli_workers.egg-info/PKG-INFO +0 -594
  39. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/LICENSE +0 -0
  40. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli/__init__.py +0 -0
  41. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli_workers.egg-info/dependency_links.txt +0 -0
  42. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli_workers.egg-info/entry_points.txt +0 -0
  43. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli_workers.egg-info/requires.txt +0 -0
  44. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/broccoli_workers.egg-info/top_level.txt +0 -0
  45. {broccoli_workers-0.1.0 → broccoli_workers-0.1.2}/setup.cfg +0 -0
@@ -0,0 +1,539 @@
1
+ Metadata-Version: 2.4
2
+ Name: broccoli-workers
3
+ Version: 0.1.2
4
+ Summary: Distributed task queue with priorities, dependencies, and chains, backed by Redis
5
+ Author-email: Success Oguntuyi <oguntuyisuccess@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Broccoli Contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Classifier: Development Status :: 4 - Beta
29
+ Classifier: Intended Audience :: Developers
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Programming Language :: Python :: 3
32
+ Classifier: Programming Language :: Python :: 3.11
33
+ Classifier: Programming Language :: Python :: 3.12
34
+ Classifier: Programming Language :: Python :: 3.13
35
+ Classifier: Operating System :: OS Independent
36
+ Requires-Python: >=3.11
37
+ Description-Content-Type: text/markdown
38
+ License-File: LICENSE
39
+ Requires-Dist: redis>=4.0.0
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=7.0; extra == "dev"
42
+ Requires-Dist: pytest-cov; extra == "dev"
43
+ Requires-Dist: black>=23.0; extra == "dev"
44
+ Requires-Dist: isort>=5.0; extra == "dev"
45
+ Requires-Dist: mypy>=1.0; extra == "dev"
46
+ Requires-Dist: flake8; extra == "dev"
47
+ Dynamic: license-file
48
+
49
+ # Broccoli
50
+
51
+ Broccoli is a Redis-backed Python task queue for running background work with:
52
+
53
+ - **Priority scheduling** (strict priority tiers + FIFO ordering inside each tier)
54
+ - **Dependency-aware tasks** (`depends_on`)
55
+ - **Retries + dead-letter handling**
56
+ - **Crash/stall recovery**
57
+ - **Multiple worker runtimes** (base, threaded, async, hybrid, chain, gpu)
58
+ - **CLI tooling** for operational inspection and control
59
+
60
+ It is designed for teams that want Celery-like queue behavior with a smaller, explicit codebase.
61
+
62
+ ---
63
+
64
+ ## Table of contents
65
+
66
+ - [Features](#features)
67
+ - [Architecture and Redis data model](#architecture-and-redis-data-model)
68
+ - [Requirements](#requirements)
69
+ - [Installation](#installation)
70
+ - [Quick start](#quick-start)
71
+ - [Core concepts](#core-concepts)
72
+ - [Task lifecycle](#task-lifecycle)
73
+ - [Worker types](#worker-types)
74
+ - [Task dependencies](#task-dependencies)
75
+ - [Dead-letter and recovery](#dead-letter-and-recovery)
76
+ - [CLI reference](#cli-reference)
77
+ - [Environment variables](#environment-variables)
78
+ - [Programmatic API reference](#programmatic-api-reference)
79
+ - [Operational guidance](#operational-guidance)
80
+ - [Troubleshooting](#troubleshooting)
81
+ - [Development](#development)
82
+ - [License](#license)
83
+
84
+ ---
85
+
86
+ ## Features
87
+
88
+ ### Queueing and scheduling
89
+
90
+ - Push tasks with explicit priorities (`0` is highest priority)
91
+ - FIFO ordering is preserved within each priority band
92
+ - Queue operations are persisted in Redis for durability
93
+
94
+ ### Dependency-aware execution
95
+
96
+ - A task can declare `depends_on=[<task_id>, ...]` for fan-out/fan-in style waits
97
+ - Dependent tasks are marked `waiting` until all listed parents complete
98
+ - Dependency release is handled automatically on parent completion
99
+
100
+ ### Worker execution models
101
+
102
+ - Single-threaded worker (`BaseWorker`)
103
+ - Thread pool worker (`ThreadedWorker`)
104
+ - Asyncio worker (`AsyncWorker`)
105
+ - Hybrid worker (`HybridWorker`: async dispatch + threaded execution)
106
+ - Chain-specific worker (`ChainWorker`)
107
+ - GPU worker (`GPUWorker`: hybrid worker pinned to a GPU queue/device)
108
+
109
+ ### Reliability and failure handling
110
+
111
+ - Built-in retry handling (`max_retries`, default `3`)
112
+ - Dead-letter capture for permanently failed tasks
113
+ - Requeue dead-letter tasks for replay
114
+ - Stalled task recovery (manual and startup auto-recovery)
115
+
116
+ ### Operations and observability
117
+
118
+ - Queue depth and processing stats
119
+ - Dead-letter inspection CLI commands
120
+ - Health checks for Redis reachability
121
+
122
+ ---
123
+
124
+ ## Architecture and Redis data model
125
+
126
+ Broccoli primarily uses Redis sorted sets and hashes:
127
+
128
+ - `<base>:queue` — runnable tasks, scored by `(priority tier + FIFO sequence)`
129
+ - `<base>:processing` — in-flight tasks, scored by processing timestamp
130
+ - `<base>:sequence` — monotonic sequence for FIFO ordering
131
+ - `<task_prefix>:<task_id>` — task metadata hash
132
+ - `dependency:<task_id>` — set of blocked dependents waiting for parent
133
+ - `<task_prefix>:dead_letter` — dead-letter task IDs with failure timestamps
134
+ - `dl:<task_id>` — dead-letter task snapshot
135
+ - `result:<task_id>` / `result:<chain_id>` — result records with TTL
136
+
137
+ For chains, additional keys like `chain:<chain_id>` and `chain:<chain_id>:tasks` are used.
138
+
139
+ ---
140
+
141
+ ## Requirements
142
+
143
+ - Python **3.11+**
144
+ - Redis **6+**
145
+
146
+ ---
147
+
148
+ ## Installation
149
+
150
+ ```bash
151
+ pip install broccoli-workers
152
+ ```
153
+
154
+ Local development install:
155
+
156
+ ```bash
157
+ pip install -e .
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Quick start
163
+
164
+ ### 1) Create a producer
165
+
166
+ ```python
167
+ from broccoli.core.task.task import Task
168
+ from broccoli.core.task.task_queue import TaskQueue
169
+
170
+ queue = TaskQueue(
171
+ redis_url="redis://localhost:6379",
172
+ decode_responses=True, # set False to work with raw Redis bytes
173
+ )
174
+
175
+ task = Task(task_type="send_email", payload={"to": "user@example.com"})
176
+ queue.push(task, priority=1)
177
+ print(task.task_id)
178
+ ```
179
+
180
+ ### 2) Create a worker and register handlers
181
+
182
+ ```python
183
+ from broccoli.workers.threaded_worker import ThreadedWorker
184
+
185
+ worker = ThreadedWorker(redis_url="redis://localhost:6379", max_workers=4)
186
+
187
+ @worker.registry.register("send_email")
188
+ def send_email(payload):
189
+ print(f"Sending email to {payload['to']}")
190
+ return {"status": "sent", "to": payload["to"]}
191
+
192
+ worker.start()
193
+ ```
194
+
195
+ ---
196
+
197
+ ## Core concepts
198
+
199
+ ### Task model
200
+
201
+ `Task` fields include:
202
+
203
+ - `task_id`: UUID (auto-generated unless provided)
204
+ - `task_type`: handler key in registry
205
+ - `payload`: arbitrary JSON-serializable dictionary
206
+ - `status`: `pending | waiting | in_progress | completed | failed`
207
+ - `retries`: current retry count
208
+ - `max_retries`: retry limit
209
+ - `depends_on`: optional list of parent task IDs
210
+ - `result`: handler output
211
+ - `error`: error message for failed attempts
212
+
213
+ ### Registry model
214
+
215
+ `TaskRegistry` is singleton-backed, so handlers are globally visible in-process.
216
+
217
+ You can register with a decorator:
218
+
219
+ ```python
220
+ @worker.registry.register("my_task")
221
+ def my_task(payload):
222
+ return {"ok": True}
223
+ ```
224
+
225
+ Or manually:
226
+
227
+ ```python
228
+ worker.registry.register_manually("my_task", my_task_handler)
229
+ ```
230
+
231
+ ---
232
+
233
+ ## Task lifecycle
234
+
235
+ 1. **Push**: task metadata hash is saved.
236
+ 2. **Queue placement**:
237
+ - no dependency -> `pending` in runnable queue
238
+ - with unresolved dependency -> `waiting`
239
+ 3. **Pop**: worker moves task to `in_progress` and processing set.
240
+ 4. **Execution**:
241
+ - success -> `completed`
242
+ - failure + retries left -> `pending` (requeued)
243
+ - failure + retries exhausted -> `failed` (dead-letter eligible)
244
+ 5. **Post-processing**:
245
+ - results persisted
246
+ - task hash cleanup for terminal states
247
+ - handlers invoked
248
+
249
+ ---
250
+
251
+ ## Worker types
252
+
253
+ ### BaseWorker
254
+
255
+ - Sequential task execution
256
+ - Simple and predictable behavior
257
+ - Useful for debugging and low-throughput workloads
258
+
259
+ ### ThreadedWorker
260
+
261
+ - Executes tasks concurrently using `ThreadPoolExecutor`
262
+ - Good for mixed and blocking workloads
263
+ - Config: `max_workers`
264
+
265
+ ### AsyncWorker
266
+
267
+ - Uses `asyncio` loop with bounded concurrency
268
+ - Runs task handlers via executor with timeout enforcement
269
+ - Config: `max_concurrent`
270
+
271
+ ### HybridWorker
272
+
273
+ - Async dispatch + thread pool execution
274
+ - Suitable for high-throughput mixed workloads
275
+ - Config: `thread_workers`, `async_tasks`
276
+
277
+ ### ChainWorker
278
+
279
+ - Specialized for chain orchestration
280
+ - Updates chain progress and completion state
281
+ - Works with `TaskChain`
282
+
283
+ ### GPUWorker
284
+
285
+ - Dedicated worker for GPU workloads (`gpu_tasks:queue`)
286
+ - Pins execution to a selected GPU via `--gpu-id`
287
+ - Uses `HybridWorker` execution model with GPU cache cleanup
288
+
289
+ ---
290
+
291
+ ## Task dependencies
292
+
293
+ When pushing dependent tasks:
294
+
295
+ - If all parents already completed, dependent is enqueued immediately.
296
+ - If one or more parents are incomplete, dependent enters `waiting` and is linked under each unresolved `dependency:<parent_id>` set.
297
+
298
+ On parent completion, waiting tasks decrement their remaining dependency count and enqueue only when all dependencies are satisfied.
299
+
300
+ Helpful APIs:
301
+
302
+ - `queue.get_waiting_for(parent_id)`
303
+ - `queue.get_waiting_tasks()`
304
+
305
+ ---
306
+
307
+ ## Dead-letter and recovery
308
+
309
+ ### Dead-letter behavior
310
+
311
+ On permanent failure, task IDs are stored in `<task_prefix>:dead_letter` and a snapshot is persisted at `dl:<task_id>`.
312
+
313
+ ### Requeue dead-letter task
314
+
315
+ ```python
316
+ queue.requeue_dead(task_id)
317
+ ```
318
+
319
+ CLI:
320
+
321
+ ```bash
322
+ broccoli dead list
323
+ broccoli dead requeue <task_id>
324
+ ```
325
+
326
+ ### Recover stalled processing tasks
327
+
328
+ Manual recovery before startup:
329
+
330
+ ```bash
331
+ broccoli worker start --type threaded --recover-stalled 600
332
+ ```
333
+
334
+ Automatic startup recovery (default enabled):
335
+
336
+ ```bash
337
+ broccoli worker start --type threaded --recover-on-startup
338
+ broccoli worker start --type threaded --no-recover-on-startup
339
+ ```
340
+
341
+ ---
342
+
343
+ ## CLI reference
344
+
345
+ ### Global
346
+
347
+ ```bash
348
+ broccoli -v ...
349
+ broccoli -vv ...
350
+ ```
351
+
352
+ ### Worker startup
353
+
354
+ ```bash
355
+ broccoli worker start --type threaded
356
+ broccoli worker start --type async --concurrency 20
357
+ broccoli worker start --type hybrid --thread-workers 8 --async-tasks 50
358
+ broccoli worker start --type chain --chain-queue-name chain_tasks:queue
359
+ broccoli worker start --type gpu --gpu-id 0
360
+ broccoli worker start --type threaded --pool --num-workers 4
361
+ ```
362
+
363
+ Common worker flags:
364
+
365
+ - `--redis-url`
366
+ - `--queue-name`
367
+ - `--chain-queue-name`
368
+ - `--task-prefix`
369
+ - `--worker-id`
370
+ - `--recover-stalled`
371
+ - `--recover-stalled-timeout`
372
+ - `--recover-on-startup` / `--no-recover-on-startup`
373
+ - `--decode-responses` / `--no-decode-responses`
374
+ - `--redis-socket-timeout`
375
+ - `--redis-socket-connect-timeout`
376
+ - `--redis-health-check-interval`
377
+ - `--redis-retry-on-timeout` / `--no-redis-retry-on-timeout`
378
+ - `--redis-max-connections`
379
+
380
+ ### Queue inspection
381
+
382
+ ```bash
383
+ broccoli queue stats --format table
384
+ broccoli queue stats --format json
385
+ broccoli queue list --status pending --limit 20
386
+ broccoli queue get <task_id>
387
+ broccoli queue waiting <parent_id>
388
+ ```
389
+
390
+ ### Dead-letter operations
391
+
392
+ ```bash
393
+ broccoli dead list
394
+ broccoli dead list --format json
395
+ broccoli dead requeue <task_id>
396
+ ```
397
+
398
+ ### Chain inspection
399
+
400
+ ```bash
401
+ broccoli chain status <chain_id>
402
+ broccoli chain tasks <chain_id>
403
+ ```
404
+
405
+ ### Health
406
+
407
+ ```bash
408
+ broccoli health
409
+ ```
410
+
411
+ ---
412
+
413
+ ## Environment variables
414
+
415
+ Broccoli CLI defaults can come from environment variables:
416
+
417
+ - `BROCCOLI_REDIS_URL` (default: `redis://localhost:6379`)
418
+ - `BROCCOLI_QUEUE_NAME` (default: `tasks:queue`)
419
+ - `BROCCOLI_CHAIN_QUEUE_NAME` (default: `chain_tasks:queue`)
420
+ - `BROCCOLI_TASK_PREFIX` (default: `task`)
421
+ - `BROCCOLI_REDIS_DECODE_RESPONSES` (default: `true`)
422
+ - `BROCCOLI_REDIS_SOCKET_TIMEOUT` (optional, seconds)
423
+ - `BROCCOLI_REDIS_SOCKET_CONNECT_TIMEOUT` (optional, seconds)
424
+ - `BROCCOLI_REDIS_HEALTH_CHECK_INTERVAL` (default: `30`)
425
+ - `BROCCOLI_REDIS_RETRY_ON_TIMEOUT` (default: `true`)
426
+ - `BROCCOLI_REDIS_MAX_CONNECTIONS` (optional)
427
+
428
+ ---
429
+
430
+ ## Programmatic API reference
431
+
432
+ ### TaskQueue
433
+
434
+ - constructor: `TaskQueue(redis_url=..., queue_name=..., task_prefix=..., decode_responses=True, redis_config={...})`
435
+ - `push(task, priority=0) -> task_id`
436
+ - `pop() -> Task | None`
437
+ - `complete(task)`
438
+ - `fail(task)`
439
+ - `requeue(task_id, priority=None)`
440
+ - `requeue_dead(task_id) -> bool`
441
+ - `recover_stalled(timeout_seconds=3600) -> int`
442
+ - `get_task(task_id) -> Task | None`
443
+ - `stats() -> dict`
444
+ - `processing_stats() -> dict`
445
+ - `get_waiting_for(task_id) -> list[str]`
446
+ - `get_waiting_tasks() -> list[str]`
447
+ - `is_fully_drained() -> bool`
448
+
449
+ ### TaskChain
450
+
451
+ - `chain(tasks, shared_payload=None, completion_task=None, completion_payload=None) -> chain_id`
452
+ - `get_chain_status(chain_id) -> dict`
453
+
454
+ ### Worker hooks
455
+
456
+ All workers support lifecycle hooks:
457
+
458
+ - `add_completion_handler(handler)`
459
+ - `add_failure_handler(handler)`
460
+ - `add_pre_process_handler(handler)`
461
+ - `add_post_process_handler(handler)`
462
+ - decorator aliases: `on_complete`, `on_failure`, `on_pre_process`, `on_post_process`
463
+
464
+ ---
465
+
466
+ ## Operational guidance
467
+
468
+ ### 1. Use shared registration modules
469
+
470
+ Keep task registrations in one importable module and register into each worker instance at startup.
471
+
472
+ ### 2. Prefer idempotent handlers
473
+
474
+ Retries can execute handlers multiple times. Side effects should be safe to repeat.
475
+
476
+ ### 3. Set realistic timeout and recovery values
477
+
478
+ Choose `recover_stalled_timeout` high enough to avoid recovering long-running but valid tasks.
479
+
480
+ ### 4. Monitor dead-letter growth
481
+
482
+ A growing dead-letter set often indicates handler bugs or dependency/data issues.
483
+
484
+ ### 5. Separate queues by workload profile
485
+
486
+ Use distinct queue names/prefixes for high-latency jobs, chain jobs, or tenant isolation.
487
+
488
+ ---
489
+
490
+ ## Troubleshooting
491
+
492
+ ### Worker starts but does nothing
493
+
494
+ - Ensure tasks are pushed to the same `queue_name` the worker consumes.
495
+ - Verify `task_prefix` alignment between producer and worker.
496
+ - Confirm handlers are registered for every `task_type`.
497
+
498
+ ### `No handler registered for task type`
499
+
500
+ - Register the handler in the same process that runs the worker.
501
+ - Import your registration module before calling `worker.start()`.
502
+
503
+ ### Tasks remain `waiting`
504
+
505
+ - Check parent task status with `broccoli queue get <parent_id>`.
506
+ - Confirm parent was completed (not failed/deleted).
507
+
508
+ ### Stuck processing tasks after crash
509
+
510
+ - Enable startup recovery or run `--recover-stalled` manually.
511
+
512
+ ### Redis connectivity errors
513
+
514
+ - Run `broccoli health`.
515
+ - Verify Redis URL/auth/network.
516
+
517
+ ---
518
+
519
+ ## Development
520
+
521
+ Install development dependencies:
522
+
523
+ ```bash
524
+ pip install -e .[dev]
525
+ ```
526
+
527
+ Run tests:
528
+
529
+ ```bash
530
+ pytest
531
+ ```
532
+
533
+ Format/lint/type-check tools are configured in `pyproject.toml` (`black`, `isort`, `flake8`, `mypy`).
534
+
535
+ ---
536
+
537
+ ## License
538
+
539
+ MIT License. See `/home/runner/work/Broccoli/Broccoli/LICENSE`.