resourcecap 0.2.1__tar.gz → 0.2.3__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.
- {resourcecap-0.2.1 → resourcecap-0.2.3}/PKG-INFO +19 -1
- {resourcecap-0.2.1 → resourcecap-0.2.3}/README.md +18 -0
- {resourcecap-0.2.1 → resourcecap-0.2.3}/pyproject.toml +1 -1
- {resourcecap-0.2.1 → resourcecap-0.2.3}/pyproject.toml.orig +1 -1
- resourcecap-0.2.3/src/resourcecap/__init__.py +5 -0
- {resourcecap-0.2.1 → resourcecap-0.2.3}/src/resourcecap/_budget.py +41 -10
- {resourcecap-0.2.1 → resourcecap-0.2.3}/src/resourcecap/_costs.py +1 -3
- {resourcecap-0.2.1 → resourcecap-0.2.3}/src/resourcecap/_resources.py +5 -6
- {resourcecap-0.2.1 → resourcecap-0.2.3}/src/resourcecap/_tracking.py +8 -14
- resourcecap-0.2.1/src/resourcecap/__init__.py +0 -5
- {resourcecap-0.2.1 → resourcecap-0.2.3}/src/resourcecap/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: resourcecap
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: A python package to limit the resource usage of any program
|
|
5
5
|
Author: purushottam-dafure
|
|
6
6
|
Author-email: purushottam-dafure <purushottamdafure@gmail.com>
|
|
@@ -97,6 +97,24 @@ Each call to `foo()` logs something like:
|
|
|
97
97
|
the above code will stop execution at that point and raise BudgetExhaustedError,
|
|
98
98
|
after the 4th call to `foo()` pushes the spend from 30 to 40.
|
|
99
99
|
|
|
100
|
+
`BudgetExhaustedError` carries structured fields, not just a message, so you
|
|
101
|
+
can handle it programmatically without parsing text:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
try:
|
|
105
|
+
with Budget(amount = 30):
|
|
106
|
+
[foo() for i in range(5)]
|
|
107
|
+
except BudgetExhaustedError as e:
|
|
108
|
+
print(e.resource, e.limit, e.spent) # "cost" 30 40.0
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
A `Budget` instance can be reused across separate, non-overlapping `with`
|
|
112
|
+
blocks (its spend resets each time), but it can't be open twice at once —
|
|
113
|
+
entering it while it's already open, from a concurrent task/thread or
|
|
114
|
+
otherwise, raises `BudgetReentryError`. If you need several concurrent
|
|
115
|
+
pieces of work to share one budget, open it once around all of them; if
|
|
116
|
+
each needs independent tracking, give each its own `Budget` instance.
|
|
117
|
+
|
|
100
118
|
## Multiple resources
|
|
101
119
|
|
|
102
120
|
`amount` on `costs`/`Budget` tracks a single, default resource. To track
|
|
@@ -83,6 +83,24 @@ Each call to `foo()` logs something like:
|
|
|
83
83
|
the above code will stop execution at that point and raise BudgetExhaustedError,
|
|
84
84
|
after the 4th call to `foo()` pushes the spend from 30 to 40.
|
|
85
85
|
|
|
86
|
+
`BudgetExhaustedError` carries structured fields, not just a message, so you
|
|
87
|
+
can handle it programmatically without parsing text:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
try:
|
|
91
|
+
with Budget(amount = 30):
|
|
92
|
+
[foo() for i in range(5)]
|
|
93
|
+
except BudgetExhaustedError as e:
|
|
94
|
+
print(e.resource, e.limit, e.spent) # "cost" 30 40.0
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A `Budget` instance can be reused across separate, non-overlapping `with`
|
|
98
|
+
blocks (its spend resets each time), but it can't be open twice at once —
|
|
99
|
+
entering it while it's already open, from a concurrent task/thread or
|
|
100
|
+
otherwise, raises `BudgetReentryError`. If you need several concurrent
|
|
101
|
+
pieces of work to share one budget, open it once around all of them; if
|
|
102
|
+
each needs independent tracking, give each its own `Budget` instance.
|
|
103
|
+
|
|
86
104
|
## Multiple resources
|
|
87
105
|
|
|
88
106
|
`amount` on `costs`/`Budget` tracks a single, default resource. To track
|
|
@@ -11,7 +11,31 @@ logger = logging.getLogger("resourcecap")
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class BudgetExhaustedError(Exception):
|
|
14
|
-
"""Raised when a `Budget`'s limit for some resource is exceeded.
|
|
14
|
+
"""Raised when a `Budget`'s limit for some resource is exceeded.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
resource: the key of the resource whose limit was exceeded.
|
|
18
|
+
limit: that resource's limit.
|
|
19
|
+
spent: how much had been spent when the limit was exceeded.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, resource: Hashable, limit: float, spent: float) -> None:
|
|
23
|
+
super().__init__(f"budget for resource {resource!r} of {limit} exhausted: spent {spent}")
|
|
24
|
+
self.resource = resource
|
|
25
|
+
self.limit = limit
|
|
26
|
+
self.spent = spent
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BudgetReentryError(RuntimeError):
|
|
30
|
+
"""Raised when a `Budget` is entered while it's already open.
|
|
31
|
+
|
|
32
|
+
A single `Budget` instance isn't safe to have open twice at once —
|
|
33
|
+
whether that's one task/thread re-entering it while still inside an
|
|
34
|
+
outer block on the same instance, or two concurrent tasks/threads
|
|
35
|
+
each entering it independently. Either enter it once around the
|
|
36
|
+
concurrent work (so everything inside shares that one open block),
|
|
37
|
+
or create a separate `Budget` instance per concurrent call.
|
|
38
|
+
"""
|
|
15
39
|
|
|
16
40
|
|
|
17
41
|
class Budget:
|
|
@@ -30,7 +54,9 @@ class Budget:
|
|
|
30
54
|
that limit before the decorated call runs rather than after.
|
|
31
55
|
|
|
32
56
|
Each resource's spend resets to zero every time the block is
|
|
33
|
-
entered, so a `Budget` instance can be reused across separate
|
|
57
|
+
entered, so a `Budget` instance can be reused across separate,
|
|
58
|
+
non-overlapping blocks. It is **not** safe to have the same
|
|
59
|
+
instance open twice at once — see `BudgetReentryError`.
|
|
34
60
|
"""
|
|
35
61
|
|
|
36
62
|
def __init__(
|
|
@@ -41,7 +67,7 @@ class Budget:
|
|
|
41
67
|
self.limits = merge_resources(amount, limits)
|
|
42
68
|
self.spent: dict[Hashable, float] = {}
|
|
43
69
|
self._lock = threading.Lock()
|
|
44
|
-
self._token: contextvars.Token[tuple[
|
|
70
|
+
self._token: contextvars.Token[tuple[Budget, ...]] | None = None
|
|
45
71
|
|
|
46
72
|
def exhausts_at_start(self, key: Hashable) -> bool:
|
|
47
73
|
"""Whether `key`'s limit should be enforced before the call runs."""
|
|
@@ -86,14 +112,18 @@ class Budget:
|
|
|
86
112
|
"resource=%s limit=%s exhausted: spent=%s", key, limit.amount, spent
|
|
87
113
|
)
|
|
88
114
|
else:
|
|
89
|
-
raise BudgetExhaustedError(
|
|
90
|
-
f"budget for resource {key!r} of {limit.amount} exhausted: spent {spent}"
|
|
91
|
-
)
|
|
115
|
+
raise BudgetExhaustedError(key, limit.amount, spent)
|
|
92
116
|
|
|
93
117
|
def __enter__(self) -> "Budget":
|
|
94
118
|
with self._lock:
|
|
119
|
+
if self._token is not None:
|
|
120
|
+
raise BudgetReentryError(
|
|
121
|
+
"this Budget is already open: a Budget instance can't be entered "
|
|
122
|
+
"while it's still open elsewhere. Enter it once around the "
|
|
123
|
+
"concurrent work, or use a separate Budget per concurrent call."
|
|
124
|
+
)
|
|
95
125
|
self.spent = {}
|
|
96
|
-
|
|
126
|
+
self._token = _tracking.push(self)
|
|
97
127
|
return self
|
|
98
128
|
|
|
99
129
|
def __exit__(
|
|
@@ -102,9 +132,10 @@ class Budget:
|
|
|
102
132
|
exc: BaseException | None,
|
|
103
133
|
tb: TracebackType | None,
|
|
104
134
|
) -> None:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
135
|
+
with self._lock:
|
|
136
|
+
assert self._token is not None
|
|
137
|
+
_tracking.pop(self._token)
|
|
138
|
+
self._token = None
|
|
108
139
|
|
|
109
140
|
async def __aenter__(self) -> "Budget":
|
|
110
141
|
return self.__enter__()
|
|
@@ -85,9 +85,7 @@ def costs[**P, R](
|
|
|
85
85
|
resolved.amount,
|
|
86
86
|
total,
|
|
87
87
|
)
|
|
88
|
-
_tracking.charge_active_budgets_after(
|
|
89
|
-
key, resolved.amount, partial_amounts.get(key, 0.0)
|
|
90
|
-
)
|
|
88
|
+
_tracking.charge_active_budgets_after(key, resolved.amount, partial_amounts[key])
|
|
91
89
|
|
|
92
90
|
if inspect.iscoroutinefunction(func):
|
|
93
91
|
async_func = cast(Callable[P, Awaitable[R]], func)
|
|
@@ -64,6 +64,9 @@ class Spendable:
|
|
|
64
64
|
def coerce(cls, value: "float | Spendable") -> "Spendable":
|
|
65
65
|
return value if isinstance(value, Spendable) else cls(value)
|
|
66
66
|
|
|
67
|
+
def _with_amount(self, amount: float) -> "Spendable":
|
|
68
|
+
return Spendable(amount, warn_only=self.warn_only, exhaust_at_start=self.exhaust_at_start)
|
|
69
|
+
|
|
67
70
|
def resolve(
|
|
68
71
|
self, args: tuple[Any, ...], kwargs: dict[str, Any], result: Any # noqa: ANN401
|
|
69
72
|
) -> "Spendable":
|
|
@@ -80,7 +83,7 @@ class Spendable:
|
|
|
80
83
|
total += self.from_args(*args, **kwargs)
|
|
81
84
|
if self.from_result is not None:
|
|
82
85
|
total += self.from_result(result)
|
|
83
|
-
return
|
|
86
|
+
return self._with_amount(total)
|
|
84
87
|
|
|
85
88
|
def resolve_partial(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> "Spendable":
|
|
86
89
|
"""Like `resolve`, but only the static `amount` and `from_args`.
|
|
@@ -91,11 +94,7 @@ class Spendable:
|
|
|
91
94
|
if self.from_args is None:
|
|
92
95
|
return self
|
|
93
96
|
|
|
94
|
-
return
|
|
95
|
-
self.amount + self.from_args(*args, **kwargs),
|
|
96
|
-
warn_only=self.warn_only,
|
|
97
|
-
exhaust_at_start=self.exhaust_at_start,
|
|
98
|
-
)
|
|
97
|
+
return self._with_amount(self.amount + self.from_args(*args, **kwargs))
|
|
99
98
|
|
|
100
99
|
|
|
101
100
|
def merge_resources(
|
|
@@ -1,32 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import contextvars
|
|
2
4
|
from collections.abc import Hashable
|
|
3
|
-
from typing import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class _Chargeable(Protocol):
|
|
7
|
-
def exhausts_at_start(self, key: Hashable) -> bool: ...
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ._budget import Budget
|
|
10
9
|
|
|
11
10
|
|
|
12
|
-
_active_budgets: contextvars.ContextVar[tuple[
|
|
11
|
+
_active_budgets: contextvars.ContextVar[tuple[Budget, ...]] = contextvars.ContextVar(
|
|
13
12
|
"resourcecap_active_budgets", default=()
|
|
14
13
|
)
|
|
15
14
|
|
|
16
15
|
|
|
17
|
-
def push(budget:
|
|
16
|
+
def push(budget: Budget) -> contextvars.Token[tuple[Budget, ...]]:
|
|
18
17
|
return _active_budgets.set((*_active_budgets.get(), budget))
|
|
19
18
|
|
|
20
19
|
|
|
21
|
-
def pop(token: contextvars.Token[tuple[
|
|
20
|
+
def pop(token: contextvars.Token[tuple[Budget, ...]]) -> None:
|
|
22
21
|
_active_budgets.reset(token)
|
|
23
22
|
|
|
24
23
|
|
|
25
|
-
def charge_active_budgets(key: Hashable, amount: float) -> None:
|
|
26
|
-
for budget in _active_budgets.get():
|
|
27
|
-
budget.charge(key, amount)
|
|
28
|
-
|
|
29
|
-
|
|
30
24
|
def charge_active_budgets_at_start(key: Hashable, partial_amount: float) -> None:
|
|
31
25
|
if not partial_amount:
|
|
32
26
|
return
|
|
File without changes
|