bulkman 1.0.3__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.
bulkman/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ Bulkman - Bulkhead Pattern Implementation with Trio
3
+
4
+ A robust implementation of the Bulkhead pattern for isolating resources
5
+ and preventing cascading failures in distributed systems.
6
+ Built on Trio for structured concurrency and resilient_circuit for circuit breaking.
7
+ """
8
+
9
+ from bulkman.config import BulkheadConfig, ExecutionResult
10
+ from bulkman.core import Bulkhead, BulkheadManager
11
+ from bulkman.exceptions import (
12
+ BulkheadCircuitOpenError,
13
+ BulkheadError,
14
+ BulkheadFullError,
15
+ BulkheadIsolationError,
16
+ BulkheadTimeoutError,
17
+ )
18
+ from bulkman.state import BulkheadState
19
+ from bulkman.sync_bridge import BulkheadSync
20
+
21
+ __version__ = "1.0.3"
22
+
23
+ __all__ = [
24
+ "Bulkhead",
25
+ "BulkheadSync",
26
+ "BulkheadConfig",
27
+ "BulkheadManager",
28
+ "BulkheadState",
29
+ "BulkheadError",
30
+ "BulkheadCircuitOpenError",
31
+ "BulkheadFullError",
32
+ "BulkheadIsolationError",
33
+ "BulkheadTimeoutError",
34
+ "ExecutionResult",
35
+ ]
bulkman/config.py ADDED
@@ -0,0 +1,63 @@
1
+ """Configuration for bulkhead pattern."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class BulkheadConfig:
9
+ """Configuration for a bulkhead.
10
+
11
+ Isolation Strategy:
12
+ ====================
13
+ Circuit breaker state isolation is controlled by (resource_key, namespace):
14
+
15
+ - **resource_key**: Set to bulkhead `name`. Different names = always isolated.
16
+ - **namespace**: Set at storage level. Same namespace = shared state space.
17
+
18
+ Examples:
19
+ ---------
20
+ 1. Different bulkheads in same app:
21
+ - Use different `name` for each bulkhead
22
+ - Use same storage (same namespace)
23
+ - Result: Isolated by resource_key
24
+
25
+ 2. Same bulkhead across app instances (persistence):
26
+ - Use same `name` for the bulkhead
27
+ - Use same storage (same namespace)
28
+ - Result: Share circuit breaker state (INTENDED)
29
+
30
+ 3. Different environments:
31
+ - Use storage with different namespace for each environment
32
+ - Result: Fully isolated prod/staging/dev
33
+
34
+ For distributed systems:
35
+ ------------------------
36
+ - Create storage with `create_storage(namespace="production")`
37
+ - All instances of the same app share the same namespace
38
+ - Different bulkheads (different names) are isolated within that namespace
39
+ - Different environments use different namespaces
40
+ """
41
+
42
+ name: str
43
+ max_concurrent_calls: int = 10
44
+ max_queue_size: int = 100
45
+ timeout_seconds: float | None = None
46
+ failure_threshold: int = 5
47
+ success_threshold: int = 3
48
+ isolation_duration: float = 30.0 # seconds
49
+ circuit_breaker_enabled: bool = True
50
+ health_check_interval: float = 5.0
51
+
52
+
53
+ @dataclass
54
+ class ExecutionResult:
55
+ """Result of a function execution through bulkhead."""
56
+
57
+ success: bool
58
+ result: Any
59
+ error: Exception | None
60
+ execution_time: float
61
+ bulkhead_name: str
62
+ queued_time: float = 0.0
63
+ execution_id: str = ""
bulkman/core.py ADDED
@@ -0,0 +1,395 @@
1
+ """Core bulkhead pattern implementation using Trio."""
2
+
3
+ import inspect
4
+ import logging
5
+ import uuid
6
+ from contextlib import asynccontextmanager
7
+ from datetime import timedelta
8
+ from fractions import Fraction
9
+ from functools import wraps
10
+ from typing import Any, Callable, TypeVar
11
+
12
+ import trio
13
+ from resilient_circuit import CircuitProtectorPolicy, CircuitState
14
+ from resilient_circuit.exceptions import ProtectedCallError
15
+ from resilient_circuit.storage import CircuitBreakerStorage
16
+
17
+ from bulkman.config import BulkheadConfig, ExecutionResult
18
+ from bulkman.exceptions import (
19
+ BulkheadCircuitOpenError,
20
+ BulkheadError,
21
+ BulkheadTimeoutError,
22
+ )
23
+ from bulkman.state import BulkheadState
24
+
25
+ logger = logging.getLogger("bulkman")
26
+
27
+ T = TypeVar("T")
28
+
29
+
30
+ class Bulkhead:
31
+ """
32
+ Implements the bulkhead pattern to isolate function executions
33
+ and prevent cascading failures using Trio for concurrency.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ config: BulkheadConfig,
39
+ circuit_storage: CircuitBreakerStorage | None = None,
40
+ ):
41
+ self.config = config
42
+ self.name = config.name
43
+
44
+ # Execution control using Trio primitives
45
+ self._semaphore = trio.Semaphore(config.max_concurrent_calls)
46
+ self._send_channel: trio.MemorySendChannel | None = None
47
+ self._receive_channel: trio.MemoryReceiveChannel | None = None
48
+ self._task_nursery: trio.Nursery | None = None
49
+
50
+ # Circuit breaker integration
51
+ self._circuit_breaker: CircuitProtectorPolicy | None = None
52
+ if config.circuit_breaker_enabled:
53
+ # Namespace is controlled by the storage, not the config
54
+ # Each bulkhead is identified by (resource_key, namespace)
55
+ # - resource_key = config.name (unique per bulkhead)
56
+ # - namespace = from storage (shared by app/environment)
57
+ self._circuit_breaker = CircuitProtectorPolicy(
58
+ resource_key=config.name,
59
+ storage=circuit_storage, # Namespace comes from storage
60
+ cooldown=timedelta(seconds=config.isolation_duration),
61
+ failure_limit=Fraction(config.failure_threshold, config.failure_threshold),
62
+ success_limit=Fraction(config.success_threshold, config.success_threshold),
63
+ on_status_change=self._on_circuit_status_change,
64
+ )
65
+
66
+ # Statistics
67
+ self._total_executions = 0
68
+ self._successful_executions = 0
69
+ self._failed_executions = 0
70
+ self._rejected_executions = 0
71
+ self._active_tasks = 0
72
+ self._stats_lock = trio.Lock()
73
+
74
+ # Running state
75
+ self._running = False
76
+ self._cancel_scope: trio.CancelScope | None = None
77
+
78
+ logger.info(
79
+ f"Bulkhead '{self.name}' initialized with {config.max_concurrent_calls} "
80
+ f"concurrent calls and queue size {config.max_queue_size}"
81
+ )
82
+
83
+ def _on_circuit_status_change(
84
+ self,
85
+ policy: CircuitProtectorPolicy,
86
+ old_status: CircuitState,
87
+ new_status: CircuitState,
88
+ ) -> None:
89
+ """Callback for circuit breaker status changes."""
90
+ logger.info(
91
+ f"Bulkhead '{self.name}' circuit breaker changed: {old_status.value} -> {new_status.value}"
92
+ )
93
+
94
+ async def _check_circuit(self) -> None:
95
+ """Check if circuit breaker allows execution."""
96
+ if self._circuit_breaker:
97
+ # Let circuit breaker validate (it will auto-transition from OPEN to HALF_OPEN after cooldown)
98
+ try:
99
+ # Run the validation in a thread since it's synchronous
100
+ await trio.to_thread.run_sync(self._circuit_breaker._status.validate_execution)
101
+ except ProtectedCallError:
102
+ async with self._stats_lock:
103
+ self._rejected_executions += 1
104
+ raise BulkheadCircuitOpenError(
105
+ f"Bulkhead '{self.name}' circuit is open - requests are blocked"
106
+ )
107
+
108
+ async def execute(
109
+ self,
110
+ func: Callable[..., T],
111
+ *args: Any,
112
+ **kwargs: Any,
113
+ ) -> ExecutionResult:
114
+ """
115
+ Execute a function through the bulkhead with isolation.
116
+
117
+ Args:
118
+ func: The function to execute (can be sync or async)
119
+ *args: Function arguments
120
+ **kwargs: Function keyword arguments
121
+
122
+ Returns:
123
+ ExecutionResult containing the result or error
124
+
125
+ Raises:
126
+ BulkheadCircuitOpenError: If circuit breaker is open
127
+ BulkheadFullError: If the bulkhead is at capacity
128
+ BulkheadTimeoutError: If operation times out
129
+ """
130
+ # Check circuit breaker
131
+ await self._check_circuit()
132
+
133
+ submission_time = trio.current_time()
134
+ execution_id = str(uuid.uuid4())
135
+
136
+ # Create timeout scope if needed
137
+ timeout = self.config.timeout_seconds if self.config.timeout_seconds else float('inf')
138
+
139
+ with trio.move_on_after(timeout) as cancel_scope:
140
+ async with self._semaphore:
141
+ # Update stats
142
+ async with self._stats_lock:
143
+ self._total_executions += 1
144
+ self._active_tasks += 1
145
+
146
+ start_time = trio.current_time()
147
+ queued_time = start_time - submission_time
148
+
149
+ try:
150
+ # Execute the function
151
+ if inspect.iscoroutinefunction(func):
152
+ result = await func(*args, **kwargs)
153
+ else:
154
+ # trio.to_thread.run_sync doesn't support **kwargs, so wrap in lambda
155
+ if kwargs:
156
+ result = await trio.to_thread.run_sync(lambda: func(*args, **kwargs))
157
+ else:
158
+ result = await trio.to_thread.run_sync(func, *args)
159
+
160
+ execution_time = trio.current_time() - start_time
161
+
162
+ # Record success
163
+ if self._circuit_breaker:
164
+ try:
165
+ self._circuit_breaker._status.mark_success()
166
+ self._circuit_breaker._save_state()
167
+ except Exception as e:
168
+ logger.warning(f"Failed to mark circuit success: {e}")
169
+
170
+ async with self._stats_lock:
171
+ self._successful_executions += 1
172
+ self._active_tasks -= 1
173
+
174
+ return ExecutionResult(
175
+ success=True,
176
+ result=result,
177
+ error=None,
178
+ execution_time=execution_time,
179
+ bulkhead_name=self.name,
180
+ queued_time=queued_time,
181
+ execution_id=execution_id,
182
+ )
183
+
184
+ except Exception as e:
185
+ execution_time = trio.current_time() - start_time
186
+
187
+ # Record failure
188
+ if self._circuit_breaker:
189
+ try:
190
+ self._circuit_breaker._status.mark_failure()
191
+ self._circuit_breaker._save_state()
192
+ except Exception as circuit_err:
193
+ logger.warning(f"Failed to mark circuit failure: {circuit_err}")
194
+
195
+ async with self._stats_lock:
196
+ self._failed_executions += 1
197
+ self._active_tasks -= 1
198
+
199
+ # Wrap exception if needed
200
+ if not isinstance(e, BulkheadError):
201
+ wrapped_error = BulkheadError(f"Execution failed: {e}")
202
+ wrapped_error.__cause__ = e
203
+ error = wrapped_error
204
+ else:
205
+ error = e
206
+
207
+ return ExecutionResult(
208
+ success=False,
209
+ result=None,
210
+ error=error,
211
+ execution_time=execution_time,
212
+ bulkhead_name=self.name,
213
+ queued_time=queued_time,
214
+ execution_id=execution_id,
215
+ )
216
+
217
+ # Check if we timed out
218
+ if cancel_scope.cancelled_caught:
219
+ async with self._stats_lock:
220
+ self._rejected_executions += 1
221
+ if self._active_tasks > 0:
222
+ self._active_tasks -= 1
223
+ raise BulkheadTimeoutError(
224
+ f"Bulkhead '{self.name}' operation timed out after {self.config.timeout_seconds} seconds"
225
+ )
226
+
227
+ @asynccontextmanager
228
+ async def context(self):
229
+ """
230
+ Context manager for bulkhead operations.
231
+
232
+ Example:
233
+ async with bulkhead.context():
234
+ result = await bulkhead.execute(my_function, arg1, arg2)
235
+ """
236
+ yield self
237
+
238
+ async def get_state(self) -> BulkheadState:
239
+ """Get the current state of the bulkhead."""
240
+ if self._circuit_breaker:
241
+ circuit_status = self._circuit_breaker.status
242
+ if circuit_status == CircuitState.CLOSED:
243
+ return BulkheadState.HEALTHY
244
+ elif circuit_status == CircuitState.HALF_OPEN:
245
+ return BulkheadState.DEGRADED
246
+ elif circuit_status == CircuitState.OPEN:
247
+ return BulkheadState.ISOLATED
248
+ return BulkheadState.HEALTHY
249
+
250
+ async def get_stats(self) -> dict[str, Any]:
251
+ """Get statistics for the bulkhead."""
252
+ async with self._stats_lock:
253
+ stats = {
254
+ "name": self.name,
255
+ "state": (await self.get_state()).value,
256
+ "total_executions": self._total_executions,
257
+ "successful_executions": self._successful_executions,
258
+ "failed_executions": self._failed_executions,
259
+ "rejected_executions": self._rejected_executions,
260
+ "active_tasks": self._active_tasks,
261
+ "max_concurrent_calls": self.config.max_concurrent_calls,
262
+ "max_queue_size": self.config.max_queue_size,
263
+ }
264
+
265
+ # Add circuit breaker info if enabled
266
+ if self._circuit_breaker:
267
+ stats["circuit_breaker_enabled"] = True
268
+ stats["circuit_status"] = self._circuit_breaker.status.value
269
+
270
+ return stats
271
+
272
+ async def reset_stats(self) -> None:
273
+ """Reset bulkhead statistics."""
274
+ async with self._stats_lock:
275
+ self._total_executions = 0
276
+ self._successful_executions = 0
277
+ self._failed_executions = 0
278
+ self._rejected_executions = 0
279
+
280
+ async def is_healthy(self) -> bool:
281
+ """Check if bulkhead is healthy."""
282
+ state = await self.get_state()
283
+ return state in (BulkheadState.HEALTHY, BulkheadState.DEGRADED)
284
+
285
+
286
+ class BulkheadManager:
287
+ """
288
+ Manages multiple bulkheads for different system components.
289
+
290
+ Args:
291
+ circuit_storage: Optional PostgreSQL storage for circuit breaker persistence.
292
+ All bulkheads created by this manager will share this storage.
293
+ The namespace is set at storage creation time.
294
+
295
+ Example:
296
+ # Production environment
297
+ storage = create_storage(namespace="production")
298
+ manager = BulkheadManager(circuit_storage=storage)
299
+
300
+ # All bulkheads share the "production" namespace
301
+ # but are isolated by their names
302
+ """
303
+
304
+ def __init__(
305
+ self,
306
+ circuit_storage: CircuitBreakerStorage | None = None,
307
+ ):
308
+ self._bulkheads: dict[str, Bulkhead] = {}
309
+ self._lock = trio.Lock()
310
+ self._circuit_storage = circuit_storage
311
+
312
+ async def create_bulkhead(self, config: BulkheadConfig) -> Bulkhead:
313
+ """Create and register a new bulkhead."""
314
+ async with self._lock:
315
+ if config.name in self._bulkheads:
316
+ raise ValueError(f"Bulkhead with name '{config.name}' already exists")
317
+
318
+ bulkhead = Bulkhead(config, circuit_storage=self._circuit_storage)
319
+ self._bulkheads[config.name] = bulkhead
320
+ return bulkhead
321
+
322
+ async def get_bulkhead(self, name: str) -> Bulkhead | None:
323
+ """Get a bulkhead by name."""
324
+ async with self._lock:
325
+ return self._bulkheads.get(name)
326
+
327
+ async def get_or_create_bulkhead(self, config: BulkheadConfig) -> Bulkhead:
328
+ """Get existing bulkhead or create new one."""
329
+ async with self._lock:
330
+ if config.name in self._bulkheads:
331
+ return self._bulkheads[config.name]
332
+
333
+ # Create bulkhead directly without acquiring lock again
334
+ bulkhead = Bulkhead(config, circuit_storage=self._circuit_storage)
335
+ self._bulkheads[config.name] = bulkhead
336
+ return bulkhead
337
+
338
+ async def execute_in_bulkhead(
339
+ self,
340
+ bulkhead_name: str,
341
+ func: Callable[..., T],
342
+ *args: Any,
343
+ **kwargs: Any,
344
+ ) -> ExecutionResult:
345
+ """Execute a function in a specific bulkhead."""
346
+ bulkhead = await self.get_bulkhead(bulkhead_name)
347
+ if not bulkhead:
348
+ raise ValueError(f"Bulkhead '{bulkhead_name}' not found")
349
+ return await bulkhead.execute(func, *args, **kwargs)
350
+
351
+ async def get_all_stats(self) -> dict[str, dict[str, Any]]:
352
+ """Get statistics for all bulkheads."""
353
+ async with self._lock:
354
+ stats = {}
355
+ for name, bulkhead in self._bulkheads.items():
356
+ stats[name] = await bulkhead.get_stats()
357
+ return stats
358
+
359
+ async def get_health_status(self) -> dict[str, bool]:
360
+ """Get health status for all bulkheads."""
361
+ async with self._lock:
362
+ status = {}
363
+ for name, bulkhead in self._bulkheads.items():
364
+ status[name] = await bulkhead.is_healthy()
365
+ return status
366
+
367
+ @asynccontextmanager
368
+ async def context(self):
369
+ """Context manager for bulkhead manager."""
370
+ yield self
371
+
372
+
373
+ def with_bulkhead(
374
+ bulkhead: Bulkhead,
375
+ ) -> Callable[[Callable[..., T]], Callable[..., T]]:
376
+ """
377
+ Decorator to execute an async function through a bulkhead.
378
+
379
+ Example:
380
+ @with_bulkhead(my_bulkhead)
381
+ async def query_database(query):
382
+ return await db.execute(query)
383
+ """
384
+
385
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
386
+ @wraps(func)
387
+ async def wrapper(*args: Any, **kwargs: Any) -> T:
388
+ result = await bulkhead.execute(func, *args, **kwargs)
389
+ if not result.success:
390
+ raise result.error or BulkheadError("Execution failed")
391
+ return result.result
392
+
393
+ return wrapper
394
+
395
+ return decorator
bulkman/exceptions.py ADDED
@@ -0,0 +1,21 @@
1
+ """Exception definitions for bulkhead pattern."""
2
+
3
+
4
+ class BulkheadError(Exception):
5
+ """Base exception for all bulkhead-related errors."""
6
+
7
+
8
+ class BulkheadIsolationError(BulkheadError):
9
+ """Exception raised when bulkhead is isolated."""
10
+
11
+
12
+ class BulkheadTimeoutError(BulkheadError):
13
+ """Exception raised when bulkhead operation times out."""
14
+
15
+
16
+ class BulkheadFullError(BulkheadError):
17
+ """Exception raised when bulkhead queue is full."""
18
+
19
+
20
+ class BulkheadCircuitOpenError(BulkheadError):
21
+ """Exception raised when bulkhead circuit is open."""
bulkman/state.py ADDED
@@ -0,0 +1,12 @@
1
+ """State definitions for bulkhead pattern."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class BulkheadState(Enum):
7
+ """Enum representing the state of a bulkhead."""
8
+
9
+ HEALTHY = "healthy"
10
+ DEGRADED = "degraded"
11
+ ISOLATED = "isolated"
12
+ FAILED = "failed"
bulkman/sync_bridge.py ADDED
@@ -0,0 +1,181 @@
1
+ """
2
+ Synchronous bridge for Bulkman to enable threading-based usage.
3
+
4
+ This module provides a synchronous wrapper around Bulkman's async API,
5
+ allowing it to be used in threading-based code (like Highway's orchestrator).
6
+ """
7
+
8
+ import atexit
9
+ import concurrent.futures
10
+ import logging
11
+ import threading
12
+ from typing import Any, Callable
13
+
14
+ import trio
15
+ from resilient_circuit.storage import CircuitBreakerStorage
16
+
17
+ from bulkman.config import BulkheadConfig, ExecutionResult
18
+ from bulkman.core import Bulkhead
19
+ from bulkman.exceptions import BulkheadError
20
+
21
+ logger = logging.getLogger("bulkman.sync")
22
+
23
+
24
+ class TrioThread:
25
+ """Manages a background thread running a Trio event loop."""
26
+
27
+ def __init__(self):
28
+ self._portal: trio.lowlevel.TrioToken | None = None
29
+ self._thread: threading.Thread | None = None
30
+ self._started = threading.Event()
31
+ self._stopping = threading.Event()
32
+ self._lock = threading.Lock()
33
+
34
+ def start(self) -> None:
35
+ """Start the Trio thread if not already running."""
36
+ with self._lock:
37
+ if self._thread is not None:
38
+ return # Already running
39
+
40
+ def trio_thread_main():
41
+ async def trio_main():
42
+ # Store portal for cross-thread calls
43
+ self._portal = trio.lowlevel.current_trio_token()
44
+ self._started.set()
45
+
46
+ # Keep trio running until stopped
47
+ with trio.CancelScope() as cancel_scope:
48
+ self._cancel_scope = cancel_scope
49
+ await trio.sleep_forever()
50
+
51
+ try:
52
+ trio.run(trio_main)
53
+ except Exception as e:
54
+ logger.error(f"Trio thread crashed: {e}")
55
+
56
+ self._thread = threading.Thread(target=trio_thread_main, daemon=True, name="TrioRunner")
57
+ self._thread.start()
58
+ self._started.wait(timeout=5.0)
59
+ if not self._portal:
60
+ raise RuntimeError("Failed to start Trio thread")
61
+
62
+ logger.info("Trio runner thread started")
63
+
64
+ def stop(self) -> None:
65
+ """Stop the Trio thread."""
66
+ with self._lock:
67
+ if self._thread is None:
68
+ return
69
+
70
+ self._stopping.set()
71
+ if hasattr(self, "_cancel_scope"):
72
+ self._cancel_scope.cancel()
73
+
74
+ self._thread.join(timeout=5.0)
75
+ self._thread = None
76
+ self._portal = None
77
+ logger.info("Trio runner thread stopped")
78
+
79
+ def run_sync(self, async_fn: Callable, *args: Any) -> Any:
80
+ """Run an async function from a sync context."""
81
+ if not self._portal:
82
+ raise RuntimeError("Trio thread not started")
83
+
84
+ return trio.from_thread.run(async_fn, *args, trio_token=self._portal)
85
+
86
+
87
+ # Global Trio thread instance
88
+ _trio_thread: TrioThread | None = None
89
+ _trio_lock = threading.Lock()
90
+
91
+
92
+ def _get_trio_thread() -> TrioThread:
93
+ """Get or create the global Trio thread."""
94
+ global _trio_thread
95
+ with _trio_lock:
96
+ if _trio_thread is None:
97
+ _trio_thread = TrioThread()
98
+ _trio_thread.start()
99
+ atexit.register(_trio_thread.stop)
100
+ return _trio_thread
101
+
102
+
103
+ class BulkheadSync:
104
+ """
105
+ Synchronous wrapper for Bulkman's async Bulkhead.
106
+
107
+ Provides threading-based API compatible with concurrent.futures.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ config: BulkheadConfig,
113
+ circuit_storage: CircuitBreakerStorage | None = None,
114
+ ):
115
+ self.config = config
116
+ self.name = config.name
117
+ self._trio_thread = _get_trio_thread()
118
+ self._bulkhead: Bulkhead | None = None
119
+ self._executor = concurrent.futures.ThreadPoolExecutor(
120
+ max_workers=config.max_concurrent_calls,
121
+ thread_name_prefix=f"Bulkhead-{config.name}",
122
+ )
123
+
124
+ # Create async bulkhead in Trio thread
125
+ async def create_bulkhead():
126
+ self._bulkhead = Bulkhead(config, circuit_storage)
127
+
128
+ self._trio_thread.run_sync(create_bulkhead)
129
+ logger.info(f"BulkheadSync '{self.name}' initialized")
130
+
131
+ def execute(
132
+ self,
133
+ func: Callable[..., Any],
134
+ *args: Any,
135
+ **kwargs: Any,
136
+ ) -> concurrent.futures.Future[ExecutionResult]:
137
+ """
138
+ Execute a function through the bulkhead.
139
+
140
+ Returns:
141
+ concurrent.futures.Future containing ExecutionResult
142
+ """
143
+ future: concurrent.futures.Future[ExecutionResult] = concurrent.futures.Future()
144
+
145
+ def run_in_executor():
146
+ """Run the function via Trio bulkhead and set result."""
147
+ try:
148
+ # Execute via async bulkhead from sync context
149
+ async def async_wrapper():
150
+ return await self._bulkhead.execute(func, *args, **kwargs)
151
+
152
+ execution_result = self._trio_thread.run_sync(async_wrapper)
153
+ future.set_result(execution_result)
154
+
155
+ except Exception as e:
156
+ # Wrap in ExecutionResult for consistency
157
+ if not isinstance(e, BulkheadError):
158
+ e = BulkheadError(f"Bulkhead execution failed: {e}")
159
+ future.set_exception(e)
160
+
161
+ # Submit to thread pool
162
+ self._executor.submit(run_in_executor)
163
+ return future
164
+
165
+ def get_stats(self) -> dict[str, Any]:
166
+ """Get bulkhead statistics."""
167
+ async def async_get_stats():
168
+ return await self._bulkhead.get_stats()
169
+
170
+ return self._trio_thread.run_sync(async_get_stats)
171
+
172
+ def shutdown(self, wait: bool = True, timeout: float = 5.0) -> None:
173
+ """Shutdown the bulkhead."""
174
+ async def async_shutdown():
175
+ if self._bulkhead:
176
+ await self._bulkhead.shutdown()
177
+
178
+ try:
179
+ self._trio_thread.run_sync(async_shutdown)
180
+ finally:
181
+ self._executor.shutdown(wait=wait, timeout=timeout)
@@ -0,0 +1,367 @@
1
+ Metadata-Version: 2.4
2
+ Name: bulkman
3
+ Version: 1.0.3
4
+ Summary: Bulkhead pattern implementation with Trio for structured concurrency and resilient circuit breaking
5
+ Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/rodmena-limited/bulkman
8
+ Project-URL: Repository, https://github.com/rodmena-limited/bulkman
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: System :: Distributed Computing
19
+ Classifier: Topic :: System :: Networking
20
+ Classifier: Framework :: Trio
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: trio>=0.22.0
25
+ Requires-Dist: resilient-circuit[postgres]>=0.4.1
26
+ Provides-Extra: dev
27
+ Requires-Dist: black>=23.0.0; extra == "dev"
28
+ Requires-Dist: isort>=5.0.0; extra == "dev"
29
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
30
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
31
+ Requires-Dist: pytest-trio>=0.8.0; extra == "dev"
32
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
33
+ Requires-Dist: pytest-mock>=3.0.0; extra == "dev"
34
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # Bulkman
38
+
39
+ **Bulkman** is a robust implementation of the **Bulkhead Pattern** for Python, built on [Trio](https://trio.readthedocs.io/) for structured concurrency and [resilient-circuit](https://github.com/rodmena-limited/resilient-circuit) for circuit breaking.
40
+
41
+ The bulkhead pattern isolates resources and prevents cascading failures in distributed systems by limiting concurrent access to critical resources.
42
+
43
+ ## Features
44
+
45
+ - **Structured Concurrency**: Built on Trio for proper async/await support
46
+ - **Circuit Breaker Integration**: Uses `resilient-circuit` with PostgreSQL support for distributed systems
47
+ - **Resource Isolation**: Limit concurrent executions to prevent resource exhaustion
48
+ - **Automatic Failure Detection**: Circuit breaker opens automatically after threshold failures
49
+ - **Comprehensive Metrics**: Track executions, failures, queue sizes, and circuit states
50
+ - **Type Safe**: Full type hints for better IDE support
51
+ - **Well Tested**: 92%+ test coverage
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install bulkman
57
+ ```
58
+
59
+ For development with all dependencies:
60
+ ```bash
61
+ pip install bulkman[dev]
62
+ ```
63
+
64
+ ## Quick Start
65
+
66
+ ```python
67
+ import trio
68
+ from bulkman import Bulkhead, BulkheadConfig
69
+
70
+ async def main():
71
+ # Create a bulkhead with configuration
72
+ config = BulkheadConfig(
73
+ name="api_calls",
74
+ max_concurrent_calls=5,
75
+ timeout_seconds=10.0,
76
+ circuit_breaker_enabled=True,
77
+ )
78
+ bulkhead = Bulkhead(config)
79
+
80
+ # Execute a function through the bulkhead
81
+ result = await bulkhead.execute(my_function, arg1, arg2)
82
+
83
+ if result.success:
84
+ print(f"Result: {result.result}")
85
+ else:
86
+ print(f"Error: {result.error}")
87
+
88
+ trio.run(main)
89
+ ```
90
+
91
+ ## Basic Usage
92
+
93
+ ### Simple Function Execution
94
+
95
+ ```python
96
+ import trio
97
+ from bulkman import Bulkhead, BulkheadConfig
98
+
99
+ async def fetch_data(url: str) -> dict:
100
+ # Your async function
101
+ await trio.sleep(0.1)
102
+ return {"data": "example"}
103
+
104
+ async def main():
105
+ config = BulkheadConfig(name="api", max_concurrent_calls=3)
106
+ bulkhead = Bulkhead(config)
107
+
108
+ result = await bulkhead.execute(fetch_data, "https://api.example.com")
109
+ print(result.result)
110
+
111
+ trio.run(main)
112
+ ```
113
+
114
+ ### Using Decorators
115
+
116
+ ```python
117
+ from bulkman import Bulkhead, BulkheadConfig
118
+ from bulkman.core import with_bulkhead
119
+
120
+ config = BulkheadConfig(name="database", max_concurrent_calls=10)
121
+ bulkhead = Bulkhead(config)
122
+
123
+ @with_bulkhead(bulkhead)
124
+ async def query_database(query: str):
125
+ # Your database query
126
+ return await db.execute(query)
127
+
128
+ # The decorator automatically wraps execution
129
+ result = await query_database("SELECT * FROM users")
130
+ ```
131
+
132
+ ### Managing Multiple Bulkheads
133
+
134
+ ```python
135
+ import trio
136
+ from bulkman import BulkheadManager, BulkheadConfig
137
+
138
+ async def main():
139
+ manager = BulkheadManager()
140
+
141
+ # Create multiple bulkheads for different resources
142
+ await manager.create_bulkhead(
143
+ BulkheadConfig(name="database", max_concurrent_calls=20)
144
+ )
145
+ await manager.create_bulkhead(
146
+ BulkheadConfig(name="external_api", max_concurrent_calls=5)
147
+ )
148
+
149
+ # Execute in specific bulkhead
150
+ result = await manager.execute_in_bulkhead(
151
+ "database",
152
+ lambda: db.query("SELECT * FROM users")
153
+ )
154
+
155
+ # Get health status of all bulkheads
156
+ health = await manager.get_health_status()
157
+ print(health) # {'database': True, 'external_api': True}
158
+
159
+ trio.run(main)
160
+ ```
161
+
162
+ ## Configuration
163
+
164
+ ### BulkheadConfig Options
165
+
166
+ ```python
167
+ from bulkman import BulkheadConfig
168
+
169
+ config = BulkheadConfig(
170
+ name="my_bulkhead", # Unique name for the bulkhead
171
+ max_concurrent_calls=10, # Max concurrent executions
172
+ max_queue_size=100, # Max queued tasks (currently for reference)
173
+ timeout_seconds=30.0, # Execution timeout in seconds
174
+ failure_threshold=5, # Failures before circuit opens
175
+ success_threshold=3, # Successes to close circuit
176
+ isolation_duration=30.0, # Seconds circuit stays open
177
+ circuit_breaker_enabled=True, # Enable/disable circuit breaker
178
+ health_check_interval=5.0, # Health check interval (for reference)
179
+ )
180
+ ```
181
+
182
+ ## Circuit Breaker Integration
183
+
184
+ Bulkman integrates with `resilient-circuit` for sophisticated circuit breaking:
185
+
186
+ ```python
187
+ from bulkman import Bulkhead, BulkheadConfig
188
+ from resilient_circuit.storage import create_storage
189
+
190
+ # Use PostgreSQL for distributed circuit breaker state
191
+ storage = create_storage(namespace="my_app")
192
+
193
+ config = BulkheadConfig(
194
+ name="external_service",
195
+ failure_threshold=3,
196
+ isolation_duration=60.0,
197
+ )
198
+
199
+ bulkhead = Bulkhead(config, circuit_storage=storage)
200
+ ```
201
+
202
+ The circuit breaker has three states:
203
+ - **CLOSED** (Healthy): Normal operation
204
+ - **OPEN** (Isolated): Blocking requests after failures
205
+ - **HALF_OPEN** (Degraded): Testing if service recovered
206
+
207
+ ## Monitoring and Metrics
208
+
209
+ ### Get Statistics
210
+
211
+ ```python
212
+ stats = await bulkhead.get_stats()
213
+ print(stats)
214
+ # {
215
+ # 'name': 'api',
216
+ # 'state': 'healthy',
217
+ # 'total_executions': 150,
218
+ # 'successful_executions': 145,
219
+ # 'failed_executions': 5,
220
+ # 'rejected_executions': 0,
221
+ # 'active_tasks': 3,
222
+ # 'max_concurrent_calls': 10,
223
+ # 'max_queue_size': 100,
224
+ # 'circuit_breaker_enabled': True,
225
+ # 'circuit_status': 'CLOSED'
226
+ # }
227
+ ```
228
+
229
+ ### Check Health
230
+
231
+ ```python
232
+ is_healthy = await bulkhead.is_healthy()
233
+ state = await bulkhead.get_state() # BulkheadState enum
234
+ ```
235
+
236
+ ### Reset Statistics
237
+
238
+ ```python
239
+ await bulkhead.reset_stats()
240
+ ```
241
+
242
+ ## Advanced Usage
243
+
244
+ ### Context Manager
245
+
246
+ ```python
247
+ async with bulkhead.context():
248
+ result1 = await bulkhead.execute(func1)
249
+ result2 = await bulkhead.execute(func2)
250
+ ```
251
+
252
+ ### Handling Sync and Async Functions
253
+
254
+ Bulkman automatically detects and handles both sync and async functions:
255
+
256
+ ```python
257
+ # Async function
258
+ async def async_operation():
259
+ await trio.sleep(1)
260
+ return "async result"
261
+
262
+ # Sync function
263
+ def sync_operation():
264
+ import time
265
+ time.sleep(1)
266
+ return "sync result"
267
+
268
+ # Both work seamlessly
269
+ result1 = await bulkhead.execute(async_operation)
270
+ result2 = await bulkhead.execute(sync_operation)
271
+ ```
272
+
273
+ ### Concurrent Execution
274
+
275
+ ```python
276
+ import trio
277
+ from bulkman import Bulkhead, BulkheadConfig
278
+
279
+ async def main():
280
+ config = BulkheadConfig(name="workers", max_concurrent_calls=5)
281
+ bulkhead = Bulkhead(config)
282
+
283
+ async def worker(task_id: int):
284
+ result = await bulkhead.execute(process_task, task_id)
285
+ return result
286
+
287
+ # Run many tasks concurrently, bulkhead limits concurrency
288
+ async with trio.open_nursery() as nursery:
289
+ for i in range(100):
290
+ nursery.start_soon(worker, i)
291
+
292
+ trio.run(main)
293
+ ```
294
+
295
+ ## Error Handling
296
+
297
+ Bulkman provides specific exceptions for different failure scenarios:
298
+
299
+ ```python
300
+ from bulkman.exceptions import (
301
+ BulkheadError,
302
+ BulkheadCircuitOpenError,
303
+ BulkheadTimeoutError,
304
+ BulkheadFullError,
305
+ )
306
+
307
+ try:
308
+ result = await bulkhead.execute(my_function)
309
+ if not result.success:
310
+ print(f"Execution failed: {result.error}")
311
+ except BulkheadCircuitOpenError:
312
+ print("Circuit breaker is open")
313
+ except BulkheadTimeoutError:
314
+ print("Operation timed out")
315
+ except BulkheadFullError:
316
+ print("Bulkhead is at capacity")
317
+ ```
318
+
319
+ ## Testing
320
+
321
+ Run the test suite:
322
+
323
+ ```bash
324
+ # Install dev dependencies
325
+ pip install -e ".[dev]"
326
+
327
+ # Run tests with coverage
328
+ pytest tests/ --cov=bulkman --cov-report=term-missing
329
+
330
+ # Run specific test file
331
+ pytest tests/test_bulkhead.py -v
332
+ ```
333
+
334
+ ## Architecture
335
+
336
+ Bulkman uses:
337
+ - **Trio Semaphores** for concurrency control
338
+ - **Trio Locks** for thread-safe statistics
339
+ - **resilient-circuit** for circuit breaking logic
340
+ - **Structured concurrency** for clean resource management
341
+
342
+ ## Contributing
343
+
344
+ Contributions are welcome! Please feel free to submit a Pull Request.
345
+
346
+ ## License
347
+
348
+ This project is licensed under the Apache Software License 2.0 - see the LICENSE file for details.
349
+
350
+ ## Credits
351
+
352
+ - Built with [Trio](https://trio.readthedocs.io/) for structured concurrency
353
+ - Circuit breaker powered by [resilient-circuit](https://github.com/rodmena-limited/resilient-circuit)
354
+ - Inspired by Michael Nygard's "Release It!" and Martin Fowler's circuit breaker pattern
355
+
356
+ ## Related Patterns
357
+
358
+ - **Circuit Breaker**: Prevents cascading failures (integrated via `resilient-circuit`)
359
+ - **Rate Limiting**: Controls request rate (complementary pattern)
360
+ - **Retry**: Automatically retries failed operations (can be combined)
361
+ - **Timeout**: Prevents indefinite waits (built-in via `timeout_seconds`)
362
+
363
+ ## Links
364
+
365
+ - **PyPI**: [https://pypi.org/project/bulkman/](https://pypi.org/project/bulkman/)
366
+ - **Trio Documentation**: [https://trio.readthedocs.io/](https://trio.readthedocs.io/)
367
+ - **resilient-circuit**: [https://github.com/rodmena-limited/resilient-circuit](https://github.com/rodmena-limited/resilient-circuit)
@@ -0,0 +1,11 @@
1
+ bulkman/__init__.py,sha256=y_CjO_eCNnivIKiuQujCUnTojB9fXlf3v2pJYU4kVf8,932
2
+ bulkman/config.py,sha256=tShtVUxohSe-FHjR-mYeb5jnHXYPX5Yr71b8yAg7zJY,1917
3
+ bulkman/core.py,sha256=ni1EF5RJ_F31KF-PPKbD0ftuDwsu5BwRME1sAgkoVdM,14777
4
+ bulkman/exceptions.py,sha256=zTMaFWFcBW2mJUx19e5MjzA8lfRr8EhNvTTO7iyjpNA,555
5
+ bulkman/state.py,sha256=xAxnyp92vfSWig-rH1bno1SbU_2CAJIkY1Jy-ozOX9U,250
6
+ bulkman/sync_bridge.py,sha256=ruPwdfS8b_CAYRPgnqZyPdey0DxQONJnDz2v-Qs39ZA,5942
7
+ bulkman-1.0.3.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
8
+ bulkman-1.0.3.dist-info/METADATA,sha256=ov_r1XO4FFQ17zv6JL3NoKzs3tdY2ClcsDT095xKnZc,10306
9
+ bulkman-1.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ bulkman-1.0.3.dist-info/top_level.txt,sha256=P7oXMEeU9XTK2CREQ0agi1mSVGK537gRzCYDJ7Qkmh8,8
11
+ bulkman-1.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1 @@
1
+ bulkman