bulklink 0.2.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.
bulklink-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bulklink Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,246 @@
1
+ Metadata-Version: 2.4
2
+ Name: bulklink
3
+ Version: 0.2.0
4
+ Summary: Predictable bulkhead isolation and bounded concurrency for Python asyncio.
5
+ Author: Bulklink Contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/igors93/bulklink
8
+ Project-URL: Repository, https://github.com/igors93/bulklink
9
+ Project-URL: Documentation, https://github.com/igors93/bulklink/tree/main/docs
10
+ Project-URL: Issues, https://github.com/igors93/bulklink/issues
11
+ Project-URL: Changelog, https://github.com/igors93/bulklink/blob/main/CHANGELOG.md
12
+ Keywords: asyncio,bulkhead,concurrency,backpressure,resilience,load-shedding
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
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
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: dev
26
+ Requires-Dist: build>=1.2; extra == "dev"
27
+ Requires-Dist: pytest>=8.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
29
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.11; extra == "dev"
31
+ Requires-Dist: mypy>=1.15; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ <div align="center">
35
+
36
+ # Bulklink
37
+
38
+ **Simple admission control. Strong isolation. Predictable behavior under load.**
39
+
40
+ Bulklink is a small, typed, zero-dependency library for bulkhead isolation and
41
+ bounded concurrency in Python `asyncio` applications.
42
+
43
+ Current package version: **0.2.0**. The documented `0.2.x` public contract is stable.
44
+
45
+ </div>
46
+
47
+ ## What problem does it solve?
48
+
49
+ Without admission control, one slow dependency can attract hundreds of concurrent
50
+ operations, consume connections and memory, and damage unrelated parts of an
51
+ application.
52
+
53
+ Bulklink creates independent compartments:
54
+
55
+ ```python
56
+ from bulklink import AsyncBulkhead
57
+
58
+ payments = AsyncBulkhead(
59
+ label="payments",
60
+ parallelism=10,
61
+ waiting_room=50,
62
+ wait_limit=2.0,
63
+ )
64
+
65
+ reports = AsyncBulkhead(
66
+ label="reports",
67
+ parallelism=2,
68
+ waiting_room=5,
69
+ )
70
+ ```
71
+
72
+ Slow reports can use at most two execution slots. They cannot consume the ten slots
73
+ reserved for payments.
74
+
75
+ ## Quick start
76
+
77
+ ```python
78
+ async def send_payment(order: object) -> object:
79
+ async with payments.slot():
80
+ return await payment_api.send(order)
81
+ ```
82
+
83
+ Or:
84
+
85
+ ```python
86
+ result = await payments.execute(payment_api.send, order)
87
+ ```
88
+
89
+ Reject instead of waiting when immediate capacity is required:
90
+
91
+ ```python
92
+ result = await payments.execute_now(payment_api.send, order)
93
+ ```
94
+
95
+ Use a shorter limit for one call without extending the bulkhead default:
96
+
97
+ ```python
98
+ result = await payments.execute_within(0.25, payment_api.send, order)
99
+ ```
100
+
101
+ Or decorate an async function:
102
+
103
+ ```python
104
+ @payments
105
+ async def send_payment(order: object) -> object:
106
+ return await payment_api.send(order)
107
+ ```
108
+
109
+ ## Behavior
110
+
111
+ For each bulkhead:
112
+
113
+ 1. up to `parallelism` operations may execute;
114
+ 2. up to `waiting_room` operations may wait in FIFO order;
115
+ 3. an operation is rejected immediately when both areas are full;
116
+ 4. a waiting operation is rejected when `wait_limit` expires;
117
+ 5. exceptions and task cancellation release capacity safely;
118
+ 6. `close()` rejects queued and future operations without interrupting active work;
119
+ 7. `wait_closed()` waits until all active operations have released their slots;
120
+ 8. `resize()` changes capacity without cancelling active work or bypassing FIFO order.
121
+
122
+ ## Graceful shutdown
123
+
124
+ ```python
125
+ await payments.close_and_wait()
126
+ ```
127
+
128
+ `close_and_wait()` stops new admission, rejects queued work, and waits for operations
129
+ already running to finish. Cancelling the caller does not cancel protected operations.
130
+
131
+
132
+ ## Observe state transitions
133
+
134
+ ```python
135
+ from bulklink import BulkheadEvent
136
+
137
+
138
+ def record_event(event: BulkheadEvent) -> None:
139
+ print(event.kind.value, event.in_flight, event.waiting)
140
+
141
+
142
+ payments.add_event_handler(record_event)
143
+ ```
144
+
145
+ Handlers are synchronous, run outside the coordinator lock, and receive immutable
146
+ metadata only. They never receive operation arguments, results, or exceptions. Handler
147
+ failures are reported through the event loop exception handler without changing
148
+ bulkhead state.
149
+
150
+ ## Diagnose capacity pressure
151
+
152
+ ```python
153
+ report = await payments.capacity_report()
154
+
155
+ print(report.summary)
156
+ for finding in report.findings:
157
+ print(finding.severity.value, finding.message)
158
+ ```
159
+
160
+ The report combines the current snapshot with cumulative admission history. It is
161
+ immutable, conservative with small samples, and never changes the bulkhead
162
+ configuration.
163
+
164
+ ## Change capacity safely
165
+
166
+ ```python
167
+ await payments.resize(20)
168
+ ```
169
+
170
+ Increasing capacity admits queued operations in FIFO order. Reducing capacity never
171
+ cancels active work; existing operations drain naturally before admission resumes at
172
+ the new limit.
173
+
174
+ ## Manage named bulkheads together
175
+
176
+ ```python
177
+ from bulklink import BulkheadRegistry
178
+
179
+ registry = BulkheadRegistry()
180
+ payments = registry.create("payments", parallelism=10, waiting_room=20)
181
+ reports = registry.create("reports", parallelism=2)
182
+
183
+ await registry.close_and_wait()
184
+ ```
185
+
186
+ The registry is optional. It enforces unique names, returns immutable ordered snapshots,
187
+ and coordinates shutdown without replacing direct `AsyncBulkhead` usage.
188
+
189
+ ## Designed to coexist with Relinker
190
+
191
+ Bulklink and Relinker solve different stages:
192
+
193
+ - **Bulklink** decides whether one operation may start;
194
+ - **Relinker** decides whether a failed operation should be attempted again.
195
+
196
+ Bulklink deliberately uses `AsyncBulkhead`, `execute()`, `slot()`, and `status()`,
197
+ rather than Relinker's policy, retry, result, budget, `run_async()`, and `snapshot()`
198
+ terminology.
199
+
200
+ See [Using Bulklink with Relinker](docs/guides/with-relinker.md).
201
+
202
+ ## Non-goals
203
+
204
+ Bulklink does not provide retries, backoff, jitter, circuit breakers, HTTP-specific
205
+ behavior, requests-per-second limits, or distributed coordination.
206
+
207
+ ## Development
208
+
209
+ ```bash
210
+ python -m pip install -e ".[dev]"
211
+ ./scripts/ci.sh
212
+ ```
213
+
214
+ ## Documentation
215
+
216
+ - [Documentation index](docs/README.md)
217
+ - [Getting started](docs/guides/getting-started.md)
218
+ - [Using Bulklink with Relinker](docs/guides/with-relinker.md)
219
+ - [Production checklist](docs/guides/production-checklist.md)
220
+ - [Capacity diagnostics](docs/concepts/capacity-diagnostics.md)
221
+ - [Dynamic capacity](docs/concepts/dynamic-capacity.md)
222
+ - [Named bulkhead registry](docs/concepts/registry.md)
223
+ - [Architecture](docs/maintainers/architecture.md)
224
+
225
+ ## License
226
+
227
+ MIT.
228
+
229
+ ## Validation and benchmarks
230
+
231
+ The release candidate is checked on Python 3.10 through 3.14 on Linux, with additional
232
+ Windows and macOS validation. The suite includes deterministic race tests, generated
233
+ model-oriented sequences, adversarial stress, executable examples, clean-wheel
234
+ installation, and consumer-facing typing checks.
235
+
236
+ Run the complete local verification on Linux or macOS:
237
+
238
+ ```bash
239
+ ./scripts/ci.sh
240
+ ```
241
+
242
+ Record a local performance baseline without enforcing unstable timing thresholds:
243
+
244
+ ```bash
245
+ python -m benchmarks.run --output benchmark-results.json
246
+ ```
@@ -0,0 +1,213 @@
1
+ <div align="center">
2
+
3
+ # Bulklink
4
+
5
+ **Simple admission control. Strong isolation. Predictable behavior under load.**
6
+
7
+ Bulklink is a small, typed, zero-dependency library for bulkhead isolation and
8
+ bounded concurrency in Python `asyncio` applications.
9
+
10
+ Current package version: **0.2.0**. The documented `0.2.x` public contract is stable.
11
+
12
+ </div>
13
+
14
+ ## What problem does it solve?
15
+
16
+ Without admission control, one slow dependency can attract hundreds of concurrent
17
+ operations, consume connections and memory, and damage unrelated parts of an
18
+ application.
19
+
20
+ Bulklink creates independent compartments:
21
+
22
+ ```python
23
+ from bulklink import AsyncBulkhead
24
+
25
+ payments = AsyncBulkhead(
26
+ label="payments",
27
+ parallelism=10,
28
+ waiting_room=50,
29
+ wait_limit=2.0,
30
+ )
31
+
32
+ reports = AsyncBulkhead(
33
+ label="reports",
34
+ parallelism=2,
35
+ waiting_room=5,
36
+ )
37
+ ```
38
+
39
+ Slow reports can use at most two execution slots. They cannot consume the ten slots
40
+ reserved for payments.
41
+
42
+ ## Quick start
43
+
44
+ ```python
45
+ async def send_payment(order: object) -> object:
46
+ async with payments.slot():
47
+ return await payment_api.send(order)
48
+ ```
49
+
50
+ Or:
51
+
52
+ ```python
53
+ result = await payments.execute(payment_api.send, order)
54
+ ```
55
+
56
+ Reject instead of waiting when immediate capacity is required:
57
+
58
+ ```python
59
+ result = await payments.execute_now(payment_api.send, order)
60
+ ```
61
+
62
+ Use a shorter limit for one call without extending the bulkhead default:
63
+
64
+ ```python
65
+ result = await payments.execute_within(0.25, payment_api.send, order)
66
+ ```
67
+
68
+ Or decorate an async function:
69
+
70
+ ```python
71
+ @payments
72
+ async def send_payment(order: object) -> object:
73
+ return await payment_api.send(order)
74
+ ```
75
+
76
+ ## Behavior
77
+
78
+ For each bulkhead:
79
+
80
+ 1. up to `parallelism` operations may execute;
81
+ 2. up to `waiting_room` operations may wait in FIFO order;
82
+ 3. an operation is rejected immediately when both areas are full;
83
+ 4. a waiting operation is rejected when `wait_limit` expires;
84
+ 5. exceptions and task cancellation release capacity safely;
85
+ 6. `close()` rejects queued and future operations without interrupting active work;
86
+ 7. `wait_closed()` waits until all active operations have released their slots;
87
+ 8. `resize()` changes capacity without cancelling active work or bypassing FIFO order.
88
+
89
+ ## Graceful shutdown
90
+
91
+ ```python
92
+ await payments.close_and_wait()
93
+ ```
94
+
95
+ `close_and_wait()` stops new admission, rejects queued work, and waits for operations
96
+ already running to finish. Cancelling the caller does not cancel protected operations.
97
+
98
+
99
+ ## Observe state transitions
100
+
101
+ ```python
102
+ from bulklink import BulkheadEvent
103
+
104
+
105
+ def record_event(event: BulkheadEvent) -> None:
106
+ print(event.kind.value, event.in_flight, event.waiting)
107
+
108
+
109
+ payments.add_event_handler(record_event)
110
+ ```
111
+
112
+ Handlers are synchronous, run outside the coordinator lock, and receive immutable
113
+ metadata only. They never receive operation arguments, results, or exceptions. Handler
114
+ failures are reported through the event loop exception handler without changing
115
+ bulkhead state.
116
+
117
+ ## Diagnose capacity pressure
118
+
119
+ ```python
120
+ report = await payments.capacity_report()
121
+
122
+ print(report.summary)
123
+ for finding in report.findings:
124
+ print(finding.severity.value, finding.message)
125
+ ```
126
+
127
+ The report combines the current snapshot with cumulative admission history. It is
128
+ immutable, conservative with small samples, and never changes the bulkhead
129
+ configuration.
130
+
131
+ ## Change capacity safely
132
+
133
+ ```python
134
+ await payments.resize(20)
135
+ ```
136
+
137
+ Increasing capacity admits queued operations in FIFO order. Reducing capacity never
138
+ cancels active work; existing operations drain naturally before admission resumes at
139
+ the new limit.
140
+
141
+ ## Manage named bulkheads together
142
+
143
+ ```python
144
+ from bulklink import BulkheadRegistry
145
+
146
+ registry = BulkheadRegistry()
147
+ payments = registry.create("payments", parallelism=10, waiting_room=20)
148
+ reports = registry.create("reports", parallelism=2)
149
+
150
+ await registry.close_and_wait()
151
+ ```
152
+
153
+ The registry is optional. It enforces unique names, returns immutable ordered snapshots,
154
+ and coordinates shutdown without replacing direct `AsyncBulkhead` usage.
155
+
156
+ ## Designed to coexist with Relinker
157
+
158
+ Bulklink and Relinker solve different stages:
159
+
160
+ - **Bulklink** decides whether one operation may start;
161
+ - **Relinker** decides whether a failed operation should be attempted again.
162
+
163
+ Bulklink deliberately uses `AsyncBulkhead`, `execute()`, `slot()`, and `status()`,
164
+ rather than Relinker's policy, retry, result, budget, `run_async()`, and `snapshot()`
165
+ terminology.
166
+
167
+ See [Using Bulklink with Relinker](docs/guides/with-relinker.md).
168
+
169
+ ## Non-goals
170
+
171
+ Bulklink does not provide retries, backoff, jitter, circuit breakers, HTTP-specific
172
+ behavior, requests-per-second limits, or distributed coordination.
173
+
174
+ ## Development
175
+
176
+ ```bash
177
+ python -m pip install -e ".[dev]"
178
+ ./scripts/ci.sh
179
+ ```
180
+
181
+ ## Documentation
182
+
183
+ - [Documentation index](docs/README.md)
184
+ - [Getting started](docs/guides/getting-started.md)
185
+ - [Using Bulklink with Relinker](docs/guides/with-relinker.md)
186
+ - [Production checklist](docs/guides/production-checklist.md)
187
+ - [Capacity diagnostics](docs/concepts/capacity-diagnostics.md)
188
+ - [Dynamic capacity](docs/concepts/dynamic-capacity.md)
189
+ - [Named bulkhead registry](docs/concepts/registry.md)
190
+ - [Architecture](docs/maintainers/architecture.md)
191
+
192
+ ## License
193
+
194
+ MIT.
195
+
196
+ ## Validation and benchmarks
197
+
198
+ The release candidate is checked on Python 3.10 through 3.14 on Linux, with additional
199
+ Windows and macOS validation. The suite includes deterministic race tests, generated
200
+ model-oriented sequences, adversarial stress, executable examples, clean-wheel
201
+ installation, and consumer-facing typing checks.
202
+
203
+ Run the complete local verification on Linux or macOS:
204
+
205
+ ```bash
206
+ ./scripts/ci.sh
207
+ ```
208
+
209
+ Record a local performance baseline without enforcing unstable timing thresholds:
210
+
211
+ ```bash
212
+ python -m benchmarks.run --output benchmark-results.json
213
+ ```
@@ -0,0 +1,80 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bulklink"
7
+ version = "0.2.0"
8
+ description = "Predictable bulkhead isolation and bounded concurrency for Python asyncio."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Bulklink Contributors" }]
14
+ keywords = ["asyncio", "bulkhead", "concurrency", "backpressure", "resilience", "load-shedding"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "build>=1.2",
31
+ "pytest>=8.0",
32
+ "pytest-asyncio>=0.24",
33
+ "pytest-cov>=5.0",
34
+ "ruff>=0.11",
35
+ "mypy>=1.15",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/igors93/bulklink"
40
+ Repository = "https://github.com/igors93/bulklink"
41
+ Documentation = "https://github.com/igors93/bulklink/tree/main/docs"
42
+ Issues = "https://github.com/igors93/bulklink/issues"
43
+ Changelog = "https://github.com/igors93/bulklink/blob/main/CHANGELOG.md"
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
47
+
48
+ [tool.setuptools.package-data]
49
+ bulklink = ["py.typed"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ pythonpath = ["src"]
54
+ asyncio_mode = "auto"
55
+ addopts = ["--import-mode=importlib"]
56
+ markers = [
57
+ "stress: deterministic adversarial concurrency tests",
58
+ "model: generated model-oriented state transition tests",
59
+ ]
60
+
61
+ [tool.ruff]
62
+ line-length = 100
63
+ target-version = "py310"
64
+
65
+ [tool.ruff.lint]
66
+ select = ["E", "F", "I", "B", "UP", "SIM"]
67
+
68
+ [tool.mypy]
69
+ python_version = "3.10"
70
+ strict = true
71
+ mypy_path = "src"
72
+
73
+ [tool.coverage.run]
74
+ branch = true
75
+ source = ["bulklink"]
76
+
77
+ [tool.coverage.report]
78
+ fail_under = 90
79
+ show_missing = true
80
+ skip_covered = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,43 @@
1
+ """Stable Bulklink public API."""
2
+
3
+ from bulklink.bulkhead import AsyncBulkhead
4
+ from bulklink.capacity import (
5
+ CapacityFinding,
6
+ CapacityFindingCode,
7
+ CapacityReport,
8
+ CapacitySeverity,
9
+ )
10
+ from bulklink.errors import (
11
+ BulkheadClosedError,
12
+ BulkheadQueueTimeoutError,
13
+ BulkheadSaturatedError,
14
+ BulklinkError,
15
+ )
16
+ from bulklink.events import BulkheadEvent, BulkheadEventHandler, BulkheadEventKind
17
+ from bulklink.registry import (
18
+ BulkheadRegistry,
19
+ BulkheadRegistryFailure,
20
+ BulkheadRegistryOperationError,
21
+ )
22
+ from bulklink.status import BulkheadStatus
23
+
24
+ __all__ = [
25
+ "AsyncBulkhead",
26
+ "CapacityFinding",
27
+ "CapacityFindingCode",
28
+ "CapacityReport",
29
+ "CapacitySeverity",
30
+ "BulkheadClosedError",
31
+ "BulkheadEvent",
32
+ "BulkheadEventHandler",
33
+ "BulkheadEventKind",
34
+ "BulkheadQueueTimeoutError",
35
+ "BulkheadRegistry",
36
+ "BulkheadRegistryFailure",
37
+ "BulkheadRegistryOperationError",
38
+ "BulkheadSaturatedError",
39
+ "BulkheadStatus",
40
+ "BulklinkError",
41
+ ]
42
+
43
+ __version__ = "0.2.0"
@@ -0,0 +1,4 @@
1
+ """Private Bulklink implementation details.
2
+
3
+ Nothing in this package is part of the stable public API.
4
+ """
@@ -0,0 +1,34 @@
1
+ """Helpers for completing critical cleanup during task cancellation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Coroutine
7
+ from typing import Any, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ async def complete_cleanup(action: Coroutine[Any, Any, T]) -> T:
13
+ """Finish one cleanup coroutine despite repeated cancellation requests.
14
+
15
+ Cancellation is remembered and propagated only after the cleanup task has
16
+ finished. An exception raised by the cleanup itself takes precedence because
17
+ it indicates that internal state may not have been restored.
18
+ """
19
+ cleanup_task = asyncio.create_task(action)
20
+ cancellation: asyncio.CancelledError | None = None
21
+
22
+ while not cleanup_task.done():
23
+ try:
24
+ await asyncio.shield(cleanup_task)
25
+ except asyncio.CancelledError as error:
26
+ if cancellation is None:
27
+ cancellation = error
28
+
29
+ result = cleanup_task.result()
30
+
31
+ if cancellation is not None:
32
+ raise cancellation
33
+
34
+ return result