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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: resourcecap
3
- Version: 0.2.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "resourcecap"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "A python package to limit the resource usage of any program"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "resourcecap"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "A python package to limit the resource usage of any program"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,5 @@
1
+ from ._budget import Budget, BudgetExhaustedError, BudgetReentryError
2
+ from ._costs import costs
3
+ from ._resources import Spendable
4
+
5
+ __all__ = ["Budget", "BudgetExhaustedError", "BudgetReentryError", "Spendable", "costs"]
@@ -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 blocks.
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[_tracking._Chargeable, ...]] | None = None
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
- self._token = _tracking.push(self)
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
- assert self._token is not None
106
- _tracking.pop(self._token)
107
- self._token = None
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 Spendable(total, warn_only=self.warn_only, exhaust_at_start=self.exhaust_at_start)
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 Spendable(
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 Protocol
4
-
5
-
6
- class _Chargeable(Protocol):
7
- def exhausts_at_start(self, key: Hashable) -> bool: ...
5
+ from typing import TYPE_CHECKING
8
6
 
9
- def charge(self, key: Hashable, amount: float) -> None: ...
7
+ if TYPE_CHECKING:
8
+ from ._budget import Budget
10
9
 
11
10
 
12
- _active_budgets: contextvars.ContextVar[tuple[_Chargeable, ...]] = contextvars.ContextVar(
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: _Chargeable) -> contextvars.Token[tuple[_Chargeable, ...]]:
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[_Chargeable, ...]]) -> None:
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
@@ -1,5 +0,0 @@
1
- from ._budget import Budget, BudgetExhaustedError
2
- from ._costs import costs
3
- from ._resources import Spendable
4
-
5
- __all__ = ["Budget", "BudgetExhaustedError", "Spendable", "costs"]