deltav 0.0.0__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.
- deltav/__init__.py +53 -0
- deltav/core.py +178 -0
- deltav/store_convex.py +276 -0
- deltav/store_local.py +63 -0
- deltav/store_postgres.py +205 -0
- deltav/store_remote.py +140 -0
- deltav-0.0.0.dist-info/METADATA +47 -0
- deltav-0.0.0.dist-info/RECORD +9 -0
- deltav-0.0.0.dist-info/WHEEL +4 -0
deltav/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from .core import (
|
|
2
|
+
Breach,
|
|
3
|
+
Budget,
|
|
4
|
+
BudgetStore,
|
|
5
|
+
Decision,
|
|
6
|
+
Reservation,
|
|
7
|
+
Spend,
|
|
8
|
+
SpendRequest,
|
|
9
|
+
Usage,
|
|
10
|
+
UsageMap,
|
|
11
|
+
canonicalize,
|
|
12
|
+
check,
|
|
13
|
+
check_stack,
|
|
14
|
+
empty_usage,
|
|
15
|
+
release,
|
|
16
|
+
release_stack,
|
|
17
|
+
reserve,
|
|
18
|
+
reserve_stack,
|
|
19
|
+
settle,
|
|
20
|
+
settle_stack,
|
|
21
|
+
)
|
|
22
|
+
from .store_convex import ConvexBudgetStore, create_budget_operations
|
|
23
|
+
from .store_local import InMemoryBudgetStore
|
|
24
|
+
from .store_postgres import DriverBudgetStore, PostgresBudgetStore
|
|
25
|
+
from .store_remote import RemoteBudgetStore
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Breach",
|
|
29
|
+
"Budget",
|
|
30
|
+
"BudgetStore",
|
|
31
|
+
"ConvexBudgetStore",
|
|
32
|
+
"Decision",
|
|
33
|
+
"DriverBudgetStore",
|
|
34
|
+
"InMemoryBudgetStore",
|
|
35
|
+
"PostgresBudgetStore",
|
|
36
|
+
"RemoteBudgetStore",
|
|
37
|
+
"Reservation",
|
|
38
|
+
"Spend",
|
|
39
|
+
"SpendRequest",
|
|
40
|
+
"Usage",
|
|
41
|
+
"UsageMap",
|
|
42
|
+
"canonicalize",
|
|
43
|
+
"check",
|
|
44
|
+
"check_stack",
|
|
45
|
+
"create_budget_operations",
|
|
46
|
+
"empty_usage",
|
|
47
|
+
"release",
|
|
48
|
+
"release_stack",
|
|
49
|
+
"reserve",
|
|
50
|
+
"reserve_stack",
|
|
51
|
+
"settle",
|
|
52
|
+
"settle_stack",
|
|
53
|
+
]
|
deltav/core.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
Spend = dict[str, float]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Budget:
|
|
12
|
+
id: str
|
|
13
|
+
layer: str
|
|
14
|
+
limits: dict[str, float]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Usage:
|
|
19
|
+
cumulative: Spend = field(default_factory=dict)
|
|
20
|
+
concurrent: Spend = field(default_factory=dict)
|
|
21
|
+
peak: Spend = field(default_factory=dict)
|
|
22
|
+
reserved: Spend = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SpendRequest:
|
|
27
|
+
estimate: Spend
|
|
28
|
+
run_id: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Reservation:
|
|
33
|
+
id: str
|
|
34
|
+
estimate: Spend
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Breach:
|
|
39
|
+
budget_id: str
|
|
40
|
+
layer: str
|
|
41
|
+
dimension: str
|
|
42
|
+
limit: float
|
|
43
|
+
would_be: float
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Decision:
|
|
48
|
+
ok: bool
|
|
49
|
+
reservation: Reservation | None = None
|
|
50
|
+
breach: Breach | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
UsageMap = dict[str, Usage]
|
|
54
|
+
_next_reservation = 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BudgetStore(Protocol):
|
|
58
|
+
async def load(self, stack_id: str) -> tuple[list[Budget], UsageMap]: ...
|
|
59
|
+
async def try_reserve(self, stack_id: str, req: SpendRequest) -> Decision: ...
|
|
60
|
+
async def settle(self, stack_id: str, reservation: Reservation, actual: Spend) -> None: ...
|
|
61
|
+
async def release(self, stack_id: str, reservation: Reservation) -> None: ...
|
|
62
|
+
async def snapshot(self, stack_id: str) -> UsageMap: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def empty_usage() -> Usage:
|
|
66
|
+
return Usage()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check(budget: Budget, usage: Usage, req: SpendRequest) -> Decision:
|
|
70
|
+
for dimension, amount in req.estimate.items():
|
|
71
|
+
limit = budget.limits.get(dimension)
|
|
72
|
+
if limit is None:
|
|
73
|
+
continue
|
|
74
|
+
if amount < 0:
|
|
75
|
+
return Decision(
|
|
76
|
+
ok=False,
|
|
77
|
+
breach=Breach(budget.id, budget.layer, dimension, 0, amount),
|
|
78
|
+
)
|
|
79
|
+
would_be = usage.cumulative.get(dimension, 0) + usage.reserved.get(dimension, 0) + amount
|
|
80
|
+
if would_be > limit:
|
|
81
|
+
return Decision(
|
|
82
|
+
ok=False,
|
|
83
|
+
breach=Breach(budget.id, budget.layer, dimension, limit, would_be),
|
|
84
|
+
)
|
|
85
|
+
return Decision(ok=True, reservation=_reservation(req.estimate))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_stack(stack: list[Budget], usages: UsageMap, req: SpendRequest) -> Decision:
|
|
89
|
+
reservation: Reservation | None = None
|
|
90
|
+
for budget in stack:
|
|
91
|
+
decision = check(budget, usages.get(budget.id, empty_usage()), req)
|
|
92
|
+
if not decision.ok:
|
|
93
|
+
return decision
|
|
94
|
+
reservation = decision.reservation
|
|
95
|
+
return Decision(ok=True, reservation=reservation or _reservation(req.estimate))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def reserve(usage: Usage, req: SpendRequest) -> Usage:
|
|
99
|
+
return Usage(
|
|
100
|
+
cumulative=dict(usage.cumulative),
|
|
101
|
+
concurrent=dict(usage.concurrent),
|
|
102
|
+
peak=dict(usage.peak),
|
|
103
|
+
reserved=_add_delta(usage.reserved, req.estimate),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def settle(usage: Usage, reservation: Reservation, actual: Spend) -> Usage:
|
|
108
|
+
cumulative = dict(usage.cumulative)
|
|
109
|
+
for dimension, amount in actual.items():
|
|
110
|
+
cumulative[dimension] = cumulative.get(dimension, 0) + amount
|
|
111
|
+
return Usage(
|
|
112
|
+
cumulative=cumulative,
|
|
113
|
+
concurrent=dict(usage.concurrent),
|
|
114
|
+
peak=dict(usage.peak),
|
|
115
|
+
reserved=_add_delta(usage.reserved, {k: -v for k, v in reservation.estimate.items()}),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def release(usage: Usage, reservation: Reservation) -> Usage:
|
|
120
|
+
return Usage(
|
|
121
|
+
cumulative=dict(usage.cumulative),
|
|
122
|
+
concurrent=dict(usage.concurrent),
|
|
123
|
+
peak=dict(usage.peak),
|
|
124
|
+
reserved=_add_delta(usage.reserved, {k: -v for k, v in reservation.estimate.items()}),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def reserve_stack(stack: list[Budget], usages: UsageMap, req: SpendRequest) -> UsageMap:
|
|
129
|
+
next_usages = dict(usages)
|
|
130
|
+
for budget in stack:
|
|
131
|
+
next_usages[budget.id] = reserve(next_usages.get(budget.id, empty_usage()), req)
|
|
132
|
+
return next_usages
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def settle_stack(
|
|
136
|
+
stack: list[Budget], usages: UsageMap, reservation: Reservation, actual: Spend
|
|
137
|
+
) -> UsageMap:
|
|
138
|
+
next_usages = dict(usages)
|
|
139
|
+
for budget in stack:
|
|
140
|
+
next_usages[budget.id] = settle(
|
|
141
|
+
next_usages.get(budget.id, empty_usage()), reservation, actual
|
|
142
|
+
)
|
|
143
|
+
return next_usages
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def release_stack(stack: list[Budget], usages: UsageMap, reservation: Reservation) -> UsageMap:
|
|
147
|
+
next_usages = dict(usages)
|
|
148
|
+
for budget in stack:
|
|
149
|
+
next_usages[budget.id] = release(next_usages.get(budget.id, empty_usage()), reservation)
|
|
150
|
+
return next_usages
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def canonicalize(value: object) -> str:
|
|
154
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=_json_default)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _reservation(estimate: Spend) -> Reservation:
|
|
158
|
+
global _next_reservation
|
|
159
|
+
reservation = Reservation(id=f"reservation_{_next_reservation}", estimate=dict(estimate))
|
|
160
|
+
_next_reservation += 1
|
|
161
|
+
return reservation
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _add_delta(values: Spend, delta: Spend) -> Spend:
|
|
165
|
+
out = dict(values)
|
|
166
|
+
for dimension, amount in delta.items():
|
|
167
|
+
value = out.get(dimension, 0) + amount
|
|
168
|
+
if value == 0:
|
|
169
|
+
out.pop(dimension, None)
|
|
170
|
+
else:
|
|
171
|
+
out[dimension] = value
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _json_default(value: object) -> object:
|
|
176
|
+
if hasattr(value, "__dict__"):
|
|
177
|
+
return value.__dict__
|
|
178
|
+
raise TypeError(f"Cannot serialize {type(value)!r}")
|
deltav/store_convex.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from dockbay import (
|
|
7
|
+
ConvexOperationContext,
|
|
8
|
+
ConvexOperationDriver,
|
|
9
|
+
ConvexStoreOperation,
|
|
10
|
+
JsonValue,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .core import (
|
|
14
|
+
Budget,
|
|
15
|
+
Decision,
|
|
16
|
+
Reservation,
|
|
17
|
+
Spend,
|
|
18
|
+
SpendRequest,
|
|
19
|
+
Usage,
|
|
20
|
+
UsageMap,
|
|
21
|
+
check_stack,
|
|
22
|
+
release_stack,
|
|
23
|
+
reserve_stack,
|
|
24
|
+
settle_stack,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
LOAD = "budget.load"
|
|
28
|
+
TRY_RESERVE = "budget.tryReserve"
|
|
29
|
+
SETTLE = "budget.settle"
|
|
30
|
+
RELEASE = "budget.release"
|
|
31
|
+
SNAPSHOT = "budget.snapshot"
|
|
32
|
+
|
|
33
|
+
InitialState = dict[str, tuple[list[Budget], UsageMap]]
|
|
34
|
+
_StackState = dict[str, Any]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConvexBudgetStore:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
driver: ConvexOperationDriver,
|
|
41
|
+
*,
|
|
42
|
+
operations: dict[str, str] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._driver = driver
|
|
45
|
+
self._operations = {
|
|
46
|
+
"load": LOAD,
|
|
47
|
+
"try_reserve": TRY_RESERVE,
|
|
48
|
+
"settle": SETTLE,
|
|
49
|
+
"release": RELEASE,
|
|
50
|
+
"snapshot": SNAPSHOT,
|
|
51
|
+
**(operations or {}),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async def load(self, stack_id: str) -> tuple[list[Budget], UsageMap]:
|
|
55
|
+
result = await self._driver.call(self._operations["load"], {"stackId": stack_id})
|
|
56
|
+
assert isinstance(result, dict)
|
|
57
|
+
return _row_to_stack(result), _row_to_usage_map(result["usages"])
|
|
58
|
+
|
|
59
|
+
async def try_reserve(self, stack_id: str, req: SpendRequest) -> Decision:
|
|
60
|
+
result = await self._driver.call(
|
|
61
|
+
self._operations["try_reserve"],
|
|
62
|
+
{"stackId": stack_id, "req": _spend_request_to_row(req)},
|
|
63
|
+
)
|
|
64
|
+
assert isinstance(result, dict)
|
|
65
|
+
return _row_to_decision(result)
|
|
66
|
+
|
|
67
|
+
async def settle(self, stack_id: str, reservation: Reservation, actual: Spend) -> None:
|
|
68
|
+
await self._driver.call(
|
|
69
|
+
self._operations["settle"],
|
|
70
|
+
{
|
|
71
|
+
"stackId": stack_id,
|
|
72
|
+
"reservation": _reservation_to_row(reservation),
|
|
73
|
+
"actual": actual,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def release(self, stack_id: str, reservation: Reservation) -> None:
|
|
78
|
+
await self._driver.call(
|
|
79
|
+
self._operations["release"],
|
|
80
|
+
{"stackId": stack_id, "reservation": _reservation_to_row(reservation)},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def snapshot(self, stack_id: str) -> UsageMap:
|
|
84
|
+
result = await self._driver.call(self._operations["snapshot"], {"stackId": stack_id})
|
|
85
|
+
return _row_to_usage_map(result)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def create_budget_operations(initial: InitialState) -> list[ConvexStoreOperation]:
|
|
89
|
+
state: dict[str, _StackState] = {
|
|
90
|
+
stack_id: {
|
|
91
|
+
"stack": [_budget_to_row(budget) for budget in stack],
|
|
92
|
+
"usages": _usage_map_to_row(usages),
|
|
93
|
+
"openReservations": [],
|
|
94
|
+
}
|
|
95
|
+
for stack_id, (stack, usages) in initial.items()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async def load(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
|
|
99
|
+
stack_id = _stack_id_from(input_value)
|
|
100
|
+
current = _require_stack(state, stack_id)
|
|
101
|
+
return {
|
|
102
|
+
"stack": deepcopy(current["stack"]),
|
|
103
|
+
"usages": deepcopy(current["usages"]),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async def try_reserve(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
|
|
107
|
+
assert isinstance(input_value, dict)
|
|
108
|
+
stack_id = _stack_id_from(input_value)
|
|
109
|
+
current = _require_stack(state, stack_id)
|
|
110
|
+
stack = _row_to_stack(current)
|
|
111
|
+
usages = _row_to_usage_map(current["usages"])
|
|
112
|
+
decision = check_stack(stack, usages, _row_to_spend_request(input_value["req"]))
|
|
113
|
+
if not decision.ok or decision.reservation is None:
|
|
114
|
+
return _decision_to_row(decision)
|
|
115
|
+
state[stack_id] = {
|
|
116
|
+
**current,
|
|
117
|
+
"usages": _usage_map_to_row(
|
|
118
|
+
reserve_stack(stack, usages, _row_to_spend_request(input_value["req"]))
|
|
119
|
+
),
|
|
120
|
+
"openReservations": [*current["openReservations"], decision.reservation.id],
|
|
121
|
+
}
|
|
122
|
+
return _decision_to_row(decision)
|
|
123
|
+
|
|
124
|
+
async def settle(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
|
|
125
|
+
assert isinstance(input_value, dict)
|
|
126
|
+
stack_id = _stack_id_from(input_value)
|
|
127
|
+
current = _require_stack(state, stack_id)
|
|
128
|
+
reservation = _row_to_reservation(input_value["reservation"])
|
|
129
|
+
if reservation.id not in current["openReservations"]:
|
|
130
|
+
return None
|
|
131
|
+
stack = _row_to_stack(current)
|
|
132
|
+
usages = _row_to_usage_map(current["usages"])
|
|
133
|
+
state[stack_id] = {
|
|
134
|
+
**current,
|
|
135
|
+
"usages": _usage_map_to_row(
|
|
136
|
+
settle_stack(stack, usages, reservation, input_value["actual"])
|
|
137
|
+
),
|
|
138
|
+
"openReservations": [
|
|
139
|
+
item for item in current["openReservations"] if item != reservation.id
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
async def release(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
|
|
145
|
+
assert isinstance(input_value, dict)
|
|
146
|
+
stack_id = _stack_id_from(input_value)
|
|
147
|
+
current = _require_stack(state, stack_id)
|
|
148
|
+
reservation = _row_to_reservation(input_value["reservation"])
|
|
149
|
+
if reservation.id not in current["openReservations"]:
|
|
150
|
+
return None
|
|
151
|
+
stack = _row_to_stack(current)
|
|
152
|
+
usages = _row_to_usage_map(current["usages"])
|
|
153
|
+
state[stack_id] = {
|
|
154
|
+
**current,
|
|
155
|
+
"usages": _usage_map_to_row(release_stack(stack, usages, reservation)),
|
|
156
|
+
"openReservations": [
|
|
157
|
+
item for item in current["openReservations"] if item != reservation.id
|
|
158
|
+
],
|
|
159
|
+
}
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
async def snapshot(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
|
|
163
|
+
stack_id = _stack_id_from(input_value)
|
|
164
|
+
return deepcopy(_require_stack(state, stack_id)["usages"])
|
|
165
|
+
|
|
166
|
+
return [
|
|
167
|
+
ConvexStoreOperation(name=LOAD, kind="query", run=load),
|
|
168
|
+
ConvexStoreOperation(name=TRY_RESERVE, kind="mutation", run=try_reserve),
|
|
169
|
+
ConvexStoreOperation(name=SETTLE, kind="mutation", run=settle),
|
|
170
|
+
ConvexStoreOperation(name=RELEASE, kind="mutation", run=release),
|
|
171
|
+
ConvexStoreOperation(name=SNAPSHOT, kind="query", run=snapshot),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _require_stack(state: dict[str, _StackState], stack_id: str) -> _StackState:
|
|
176
|
+
try:
|
|
177
|
+
return state[stack_id]
|
|
178
|
+
except KeyError as exc:
|
|
179
|
+
raise KeyError(f"Unknown budget stack: {stack_id}") from exc
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _stack_id_from(input_value: JsonValue) -> str:
|
|
183
|
+
assert isinstance(input_value, dict)
|
|
184
|
+
stack_id = input_value.get("stackId")
|
|
185
|
+
if not isinstance(stack_id, str):
|
|
186
|
+
raise ValueError("Convex budget operation requires stackId")
|
|
187
|
+
return stack_id
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _budget_to_row(budget: Budget) -> dict[str, Any]:
|
|
191
|
+
return {"id": budget.id, "layer": budget.layer, "limits": dict(budget.limits)}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _row_to_budget(row: Any) -> Budget:
|
|
195
|
+
return Budget(id=row["id"], layer=row["layer"], limits=dict(row["limits"]))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _usage_to_row(usage: Usage) -> dict[str, Any]:
|
|
199
|
+
return {
|
|
200
|
+
"cumulative": dict(usage.cumulative),
|
|
201
|
+
"concurrent": dict(usage.concurrent),
|
|
202
|
+
"peak": dict(usage.peak),
|
|
203
|
+
"reserved": dict(usage.reserved),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _row_to_usage(row: Any) -> Usage:
|
|
208
|
+
return Usage(
|
|
209
|
+
cumulative=dict(row["cumulative"]),
|
|
210
|
+
concurrent=dict(row["concurrent"]),
|
|
211
|
+
peak=dict(row["peak"]),
|
|
212
|
+
reserved=dict(row["reserved"]),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _usage_map_to_row(usages: UsageMap) -> dict[str, Any]:
|
|
217
|
+
return {budget_id: _usage_to_row(usage) for budget_id, usage in usages.items()}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _row_to_usage_map(row: Any) -> UsageMap:
|
|
221
|
+
return {budget_id: _row_to_usage(usage) for budget_id, usage in deepcopy(row).items()}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _row_to_stack(row: Any) -> list[Budget]:
|
|
225
|
+
return [_row_to_budget(budget) for budget in deepcopy(row["stack"])]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _spend_request_to_row(req: SpendRequest) -> dict[str, Any]:
|
|
229
|
+
return {"estimate": dict(req.estimate), "run_id": req.run_id}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _row_to_spend_request(row: Any) -> SpendRequest:
|
|
233
|
+
return SpendRequest(estimate=dict(row["estimate"]), run_id=row.get("run_id"))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _reservation_to_row(reservation: Reservation) -> dict[str, Any]:
|
|
237
|
+
return {"id": reservation.id, "estimate": dict(reservation.estimate)}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _row_to_reservation(row: Any) -> Reservation:
|
|
241
|
+
return Reservation(id=row["id"], estimate=dict(row["estimate"]))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _decision_to_row(decision: Decision) -> dict[str, Any]:
|
|
245
|
+
if decision.ok:
|
|
246
|
+
assert decision.reservation is not None
|
|
247
|
+
return {"ok": True, "reservation": _reservation_to_row(decision.reservation)}
|
|
248
|
+
assert decision.breach is not None
|
|
249
|
+
return {
|
|
250
|
+
"ok": False,
|
|
251
|
+
"breach": {
|
|
252
|
+
"budget_id": decision.breach.budget_id,
|
|
253
|
+
"layer": decision.breach.layer,
|
|
254
|
+
"dimension": decision.breach.dimension,
|
|
255
|
+
"limit": decision.breach.limit,
|
|
256
|
+
"would_be": decision.breach.would_be,
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _row_to_decision(row: Any) -> Decision:
|
|
262
|
+
if row["ok"]:
|
|
263
|
+
return Decision(ok=True, reservation=_row_to_reservation(row["reservation"]))
|
|
264
|
+
breach = row["breach"]
|
|
265
|
+
from .core import Breach
|
|
266
|
+
|
|
267
|
+
return Decision(
|
|
268
|
+
ok=False,
|
|
269
|
+
breach=Breach(
|
|
270
|
+
budget_id=breach["budget_id"],
|
|
271
|
+
layer=breach["layer"],
|
|
272
|
+
dimension=breach["dimension"],
|
|
273
|
+
limit=breach["limit"],
|
|
274
|
+
would_be=breach["would_be"],
|
|
275
|
+
),
|
|
276
|
+
)
|
deltav/store_local.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
|
|
6
|
+
from .core import (
|
|
7
|
+
Budget,
|
|
8
|
+
Decision,
|
|
9
|
+
Reservation,
|
|
10
|
+
Spend,
|
|
11
|
+
SpendRequest,
|
|
12
|
+
UsageMap,
|
|
13
|
+
check_stack,
|
|
14
|
+
release_stack,
|
|
15
|
+
reserve_stack,
|
|
16
|
+
settle_stack,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InMemoryBudgetStore:
|
|
21
|
+
def __init__(self, initial: dict[str, tuple[list[Budget], UsageMap]] | None = None) -> None:
|
|
22
|
+
self._stacks: dict[str, tuple[list[Budget], UsageMap]] = initial or {}
|
|
23
|
+
self._open: dict[str, set[str]] = {stack_id: set() for stack_id in self._stacks}
|
|
24
|
+
self._lock = asyncio.Lock()
|
|
25
|
+
|
|
26
|
+
async def load(self, stack_id: str) -> tuple[list[Budget], UsageMap]:
|
|
27
|
+
async with self._lock:
|
|
28
|
+
stack, usages = self._require(stack_id)
|
|
29
|
+
return list(stack), deepcopy(usages)
|
|
30
|
+
|
|
31
|
+
async def try_reserve(self, stack_id: str, req: SpendRequest) -> Decision:
|
|
32
|
+
async with self._lock:
|
|
33
|
+
stack, usages = self._require(stack_id)
|
|
34
|
+
decision = check_stack(stack, usages, req)
|
|
35
|
+
if decision.ok and decision.reservation is not None:
|
|
36
|
+
self._stacks[stack_id] = (stack, reserve_stack(stack, usages, req))
|
|
37
|
+
self._open.setdefault(stack_id, set()).add(decision.reservation.id)
|
|
38
|
+
return decision
|
|
39
|
+
|
|
40
|
+
async def settle(self, stack_id: str, reservation: Reservation, actual: Spend) -> None:
|
|
41
|
+
async with self._lock:
|
|
42
|
+
if reservation.id not in self._open.setdefault(stack_id, set()):
|
|
43
|
+
return
|
|
44
|
+
stack, usages = self._require(stack_id)
|
|
45
|
+
self._stacks[stack_id] = (stack, settle_stack(stack, usages, reservation, actual))
|
|
46
|
+
self._open[stack_id].remove(reservation.id)
|
|
47
|
+
|
|
48
|
+
async def release(self, stack_id: str, reservation: Reservation) -> None:
|
|
49
|
+
async with self._lock:
|
|
50
|
+
if reservation.id not in self._open.setdefault(stack_id, set()):
|
|
51
|
+
return
|
|
52
|
+
stack, usages = self._require(stack_id)
|
|
53
|
+
self._stacks[stack_id] = (stack, release_stack(stack, usages, reservation))
|
|
54
|
+
self._open[stack_id].remove(reservation.id)
|
|
55
|
+
|
|
56
|
+
async def snapshot(self, stack_id: str) -> UsageMap:
|
|
57
|
+
async with self._lock:
|
|
58
|
+
return deepcopy(self._require(stack_id)[1])
|
|
59
|
+
|
|
60
|
+
def _require(self, stack_id: str) -> tuple[list[Budget], UsageMap]:
|
|
61
|
+
if stack_id not in self._stacks:
|
|
62
|
+
raise KeyError(f"Unknown budget stack: {stack_id}")
|
|
63
|
+
return self._stacks[stack_id]
|
deltav/store_postgres.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from dockbay import (
|
|
8
|
+
PostgresStoreDriver,
|
|
9
|
+
PostgresStoreDriverOptions,
|
|
10
|
+
Row,
|
|
11
|
+
StoreDriver,
|
|
12
|
+
Transaction,
|
|
13
|
+
create_postgres_driver,
|
|
14
|
+
)
|
|
15
|
+
from psycopg_pool import AsyncConnectionPool
|
|
16
|
+
|
|
17
|
+
from .core import (
|
|
18
|
+
Budget,
|
|
19
|
+
Decision,
|
|
20
|
+
Reservation,
|
|
21
|
+
Spend,
|
|
22
|
+
SpendRequest,
|
|
23
|
+
Usage,
|
|
24
|
+
UsageMap,
|
|
25
|
+
check_stack,
|
|
26
|
+
release_stack,
|
|
27
|
+
reserve_stack,
|
|
28
|
+
settle_stack,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
TABLE = "budget_stacks"
|
|
32
|
+
MAX_CAS_ATTEMPTS = 25
|
|
33
|
+
|
|
34
|
+
InitialState = dict[str, tuple[list[Budget], UsageMap]]
|
|
35
|
+
_Update = Callable[[Row], tuple[Any, Row]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DriverBudgetStore:
|
|
39
|
+
def __init__(self, driver: StoreDriver, initial: InitialState | None = None) -> None:
|
|
40
|
+
self._driver = driver
|
|
41
|
+
self._initial = initial or {}
|
|
42
|
+
|
|
43
|
+
async def load(self, stack_id: str) -> tuple[list[Budget], UsageMap]:
|
|
44
|
+
async def work(txn: Transaction) -> tuple[list[Budget], UsageMap]:
|
|
45
|
+
state = await self._load_state(txn, stack_id)
|
|
46
|
+
return _row_to_stack(state), _row_to_usage_map(state["usages"])
|
|
47
|
+
|
|
48
|
+
return await self._driver.transaction(work)
|
|
49
|
+
|
|
50
|
+
async def try_reserve(self, stack_id: str, req: SpendRequest) -> Decision:
|
|
51
|
+
def update(state: Row) -> tuple[Decision, Row]:
|
|
52
|
+
stack = _row_to_stack(state)
|
|
53
|
+
usages = _row_to_usage_map(state["usages"])
|
|
54
|
+
decision = check_stack(stack, usages, req)
|
|
55
|
+
if not decision.ok or decision.reservation is None:
|
|
56
|
+
return decision, state
|
|
57
|
+
next_state = {
|
|
58
|
+
**state,
|
|
59
|
+
"usages": _usage_map_to_row(reserve_stack(stack, usages, req)),
|
|
60
|
+
"openReservations": [*state["openReservations"], decision.reservation.id],
|
|
61
|
+
}
|
|
62
|
+
return decision, next_state
|
|
63
|
+
|
|
64
|
+
return await self._cas(stack_id, update)
|
|
65
|
+
|
|
66
|
+
async def settle(self, stack_id: str, reservation: Reservation, actual: Spend) -> None:
|
|
67
|
+
def update(state: Row) -> tuple[None, Row]:
|
|
68
|
+
if reservation.id not in state["openReservations"]:
|
|
69
|
+
return None, state
|
|
70
|
+
stack = _row_to_stack(state)
|
|
71
|
+
usages = _row_to_usage_map(state["usages"])
|
|
72
|
+
next_state = {
|
|
73
|
+
**state,
|
|
74
|
+
"usages": _usage_map_to_row(settle_stack(stack, usages, reservation, actual)),
|
|
75
|
+
"openReservations": [
|
|
76
|
+
item for item in state["openReservations"] if item != reservation.id
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
return None, next_state
|
|
80
|
+
|
|
81
|
+
await self._cas(stack_id, update)
|
|
82
|
+
|
|
83
|
+
async def release(self, stack_id: str, reservation: Reservation) -> None:
|
|
84
|
+
def update(state: Row) -> tuple[None, Row]:
|
|
85
|
+
if reservation.id not in state["openReservations"]:
|
|
86
|
+
return None, state
|
|
87
|
+
stack = _row_to_stack(state)
|
|
88
|
+
usages = _row_to_usage_map(state["usages"])
|
|
89
|
+
next_state = {
|
|
90
|
+
**state,
|
|
91
|
+
"usages": _usage_map_to_row(release_stack(stack, usages, reservation)),
|
|
92
|
+
"openReservations": [
|
|
93
|
+
item for item in state["openReservations"] if item != reservation.id
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
return None, next_state
|
|
97
|
+
|
|
98
|
+
await self._cas(stack_id, update)
|
|
99
|
+
|
|
100
|
+
async def snapshot(self, stack_id: str) -> UsageMap:
|
|
101
|
+
async def work(txn: Transaction) -> UsageMap:
|
|
102
|
+
state = await self._load_state(txn, stack_id)
|
|
103
|
+
return _row_to_usage_map(state["usages"])
|
|
104
|
+
|
|
105
|
+
return await self._driver.transaction(work)
|
|
106
|
+
|
|
107
|
+
async def close(self) -> None:
|
|
108
|
+
await self._driver.close()
|
|
109
|
+
|
|
110
|
+
async def _cas(self, stack_id: str, update: _Update) -> Any:
|
|
111
|
+
for _ in range(MAX_CAS_ATTEMPTS):
|
|
112
|
+
outcome = await self._driver.transaction(
|
|
113
|
+
_attempt_update(stack_id, self._load_state, update)
|
|
114
|
+
)
|
|
115
|
+
if outcome["applied"]:
|
|
116
|
+
return outcome["result"]
|
|
117
|
+
raise RuntimeError(f"Budget stack update conflicted too often: {stack_id}")
|
|
118
|
+
|
|
119
|
+
async def _load_state(self, txn: Transaction, stack_id: str) -> Row:
|
|
120
|
+
existing = await txn.get(TABLE, _stack_key(stack_id))
|
|
121
|
+
if existing is not None:
|
|
122
|
+
return existing
|
|
123
|
+
seed = self._initial.get(stack_id)
|
|
124
|
+
if seed is None:
|
|
125
|
+
raise KeyError(f"Unknown budget stack: {stack_id}")
|
|
126
|
+
stack, usages = seed
|
|
127
|
+
state = {
|
|
128
|
+
"stack": [_budget_to_row(budget) for budget in stack],
|
|
129
|
+
"usages": _usage_map_to_row(usages),
|
|
130
|
+
"openReservations": [],
|
|
131
|
+
}
|
|
132
|
+
await txn.compare_and_apply(TABLE, _stack_key(stack_id), None, state)
|
|
133
|
+
return state
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class PostgresBudgetStore(DriverBudgetStore):
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
pool: AsyncConnectionPool,
|
|
140
|
+
*,
|
|
141
|
+
table: str = "deltav_budget_store",
|
|
142
|
+
initial: InitialState | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
self.postgres_driver: PostgresStoreDriver = create_postgres_driver(
|
|
145
|
+
pool, PostgresStoreDriverOptions(table=table)
|
|
146
|
+
)
|
|
147
|
+
super().__init__(self.postgres_driver, initial)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _attempt_update(
|
|
151
|
+
stack_id: str,
|
|
152
|
+
load_state: Callable[[Transaction, str], Awaitable[Row]],
|
|
153
|
+
update: _Update,
|
|
154
|
+
) -> Callable[[Transaction], Awaitable[Row]]:
|
|
155
|
+
async def work(txn: Transaction) -> Row:
|
|
156
|
+
state = await load_state(txn, stack_id)
|
|
157
|
+
result, next_state = update(state)
|
|
158
|
+
if state == next_state:
|
|
159
|
+
return {"applied": True, "result": result}
|
|
160
|
+
applied = await txn.compare_and_apply(TABLE, _stack_key(stack_id), state, next_state)
|
|
161
|
+
return {"applied": applied, "result": result}
|
|
162
|
+
|
|
163
|
+
return work
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _stack_key(stack_id: str) -> Row:
|
|
167
|
+
return {"stackId": stack_id}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _budget_to_row(budget: Budget) -> Row:
|
|
171
|
+
return {"id": budget.id, "layer": budget.layer, "limits": dict(budget.limits)}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _row_to_budget(row: Row) -> Budget:
|
|
175
|
+
return Budget(id=row["id"], layer=row["layer"], limits=dict(row["limits"]))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _usage_to_row(usage: Usage) -> Row:
|
|
179
|
+
return {
|
|
180
|
+
"cumulative": dict(usage.cumulative),
|
|
181
|
+
"concurrent": dict(usage.concurrent),
|
|
182
|
+
"peak": dict(usage.peak),
|
|
183
|
+
"reserved": dict(usage.reserved),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _row_to_usage(row: Row) -> Usage:
|
|
188
|
+
return Usage(
|
|
189
|
+
cumulative=dict(row["cumulative"]),
|
|
190
|
+
concurrent=dict(row["concurrent"]),
|
|
191
|
+
peak=dict(row["peak"]),
|
|
192
|
+
reserved=dict(row["reserved"]),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _usage_map_to_row(usages: UsageMap) -> Row:
|
|
197
|
+
return {budget_id: _usage_to_row(usage) for budget_id, usage in usages.items()}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _row_to_usage_map(row: Any) -> UsageMap:
|
|
201
|
+
return {budget_id: _row_to_usage(usage) for budget_id, usage in deepcopy(row).items()}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _row_to_stack(row: Row) -> list[Budget]:
|
|
205
|
+
return [_row_to_budget(budget) for budget in deepcopy(row["stack"])]
|
deltav/store_remote.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from .core import Breach, Budget, Decision, Reservation, Spend, SpendRequest, Usage, UsageMap
|
|
8
|
+
|
|
9
|
+
HttpRequest = Callable[[str, str, object | None], Awaitable[object]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class RemoteBudgetStore:
|
|
14
|
+
request: HttpRequest
|
|
15
|
+
default_stack_id: str
|
|
16
|
+
|
|
17
|
+
async def load(self, stack_id: str) -> tuple[list[Budget], UsageMap]:
|
|
18
|
+
body = await self.request("POST", "/budget/load", {"stackId": stack_id})
|
|
19
|
+
if not isinstance(body, dict):
|
|
20
|
+
raise TypeError("Remote budget load returned a non-object response")
|
|
21
|
+
body_map = cast(dict[str, object], body)
|
|
22
|
+
stack_value = body_map.get("stack", [])
|
|
23
|
+
usages_value = body_map.get("usages", {})
|
|
24
|
+
return (
|
|
25
|
+
[_budget(value) for value in _list(stack_value)],
|
|
26
|
+
_usage_map(usages_value),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
async def try_reserve(self, stack_id: str, req: SpendRequest) -> Decision:
|
|
30
|
+
body = await self.request(
|
|
31
|
+
"POST", "/budget/try-reserve", {"stackId": stack_id, "request": req}
|
|
32
|
+
)
|
|
33
|
+
return _decision(body)
|
|
34
|
+
|
|
35
|
+
async def settle(self, stack_id: str, reservation: Reservation, actual: Spend) -> None:
|
|
36
|
+
await self.request(
|
|
37
|
+
"POST",
|
|
38
|
+
"/budget/settle",
|
|
39
|
+
{"stackId": stack_id, "reservation": reservation, "actual": actual},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def release(self, stack_id: str, reservation: Reservation) -> None:
|
|
43
|
+
await self.request(
|
|
44
|
+
"POST", "/budget/release", {"stackId": stack_id, "reservation": reservation}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
async def snapshot(self, stack_id: str) -> UsageMap:
|
|
48
|
+
body = await self.request("POST", "/budget/snapshot", {"stackId": stack_id})
|
|
49
|
+
return _usage_map(body)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _budget(value: object) -> Budget:
|
|
53
|
+
if isinstance(value, Budget):
|
|
54
|
+
return value
|
|
55
|
+
if not isinstance(value, dict):
|
|
56
|
+
raise TypeError("Budget value must be an object")
|
|
57
|
+
value_map = cast(dict[str, object], value)
|
|
58
|
+
limits = value_map["limits"]
|
|
59
|
+
if not isinstance(limits, dict):
|
|
60
|
+
raise TypeError("Budget limits must be an object")
|
|
61
|
+
return Budget(
|
|
62
|
+
id=str(value_map["id"]),
|
|
63
|
+
layer=str(value_map["layer"]),
|
|
64
|
+
limits={str(key): _number(amount) for key, amount in limits.items()},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _usage(value: object) -> Usage:
|
|
69
|
+
if isinstance(value, Usage):
|
|
70
|
+
return value
|
|
71
|
+
if not isinstance(value, dict):
|
|
72
|
+
raise TypeError("Usage value must be an object")
|
|
73
|
+
value_map = cast(dict[str, object], value)
|
|
74
|
+
return Usage(
|
|
75
|
+
cumulative=_spend(value_map.get("cumulative", {})),
|
|
76
|
+
concurrent=_spend(value_map.get("concurrent", {})),
|
|
77
|
+
peak=_spend(value_map.get("peak", {})),
|
|
78
|
+
reserved=_spend(value_map.get("reserved", {})),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _usage_map(value: object) -> UsageMap:
|
|
83
|
+
if not isinstance(value, dict):
|
|
84
|
+
raise TypeError("Usage map must be an object")
|
|
85
|
+
value_map = cast(dict[str, object], value)
|
|
86
|
+
return {str(key): _usage(item) for key, item in value_map.items()}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _decision(value: object) -> Decision:
|
|
90
|
+
if isinstance(value, Decision):
|
|
91
|
+
return value
|
|
92
|
+
if not isinstance(value, dict):
|
|
93
|
+
raise TypeError("Decision value must be an object")
|
|
94
|
+
value_map = cast(dict[str, object], value)
|
|
95
|
+
if value_map.get("ok") is True:
|
|
96
|
+
return Decision(ok=True, reservation=_reservation(value_map.get("reservation")))
|
|
97
|
+
return Decision(ok=False, breach=_breach(value_map.get("breach")))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _reservation(value: object) -> Reservation:
|
|
101
|
+
if isinstance(value, Reservation):
|
|
102
|
+
return value
|
|
103
|
+
if not isinstance(value, dict):
|
|
104
|
+
raise TypeError("Reservation value must be an object")
|
|
105
|
+
value_map = cast(dict[str, object], value)
|
|
106
|
+
return Reservation(id=str(value_map["id"]), estimate=_spend(value_map["estimate"]))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _breach(value: object) -> Breach:
|
|
110
|
+
if isinstance(value, Breach):
|
|
111
|
+
return value
|
|
112
|
+
if not isinstance(value, dict):
|
|
113
|
+
raise TypeError("Breach value must be an object")
|
|
114
|
+
value_map = cast(dict[str, object], value)
|
|
115
|
+
return Breach(
|
|
116
|
+
budget_id=str(value_map["budget_id"]),
|
|
117
|
+
layer=str(value_map["layer"]),
|
|
118
|
+
dimension=str(value_map["dimension"]),
|
|
119
|
+
limit=_number(value_map["limit"]),
|
|
120
|
+
would_be=_number(value_map["would_be"]),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _spend(value: object) -> Spend:
|
|
125
|
+
if not isinstance(value, dict):
|
|
126
|
+
raise TypeError("Spend value must be an object")
|
|
127
|
+
value_map = cast(dict[str, object], value)
|
|
128
|
+
return {str(key): _number(amount) for key, amount in value_map.items()}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _list(value: object) -> list[object]:
|
|
132
|
+
if not isinstance(value, list):
|
|
133
|
+
raise TypeError("Value must be a list")
|
|
134
|
+
return cast(list[object], value)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _number(value: object) -> float:
|
|
138
|
+
if not isinstance(value, int | float):
|
|
139
|
+
raise TypeError("Value must be numeric")
|
|
140
|
+
return float(value)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deltav
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: DeltaV reservation-based budget enforcement for agent and tool runs.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cachetronaut/deltav
|
|
6
|
+
Project-URL: Repository, https://github.com/cachetronaut/deltav
|
|
7
|
+
Project-URL: Issues, https://github.com/cachetronaut/deltav/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: dockbay>=0.0.0
|
|
11
|
+
Requires-Dist: psycopg-pool>=3.2
|
|
12
|
+
Requires-Dist: psycopg[binary]>=3.2
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# deltav
|
|
16
|
+
|
|
17
|
+
Python implementation of DeltaV.
|
|
18
|
+
|
|
19
|
+
For product-level context, shared contracts, and cross-language repository information, see the public repository: https://github.com/cachetronaut/deltav.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pip install deltav
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Import
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import deltav
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
Run from `py/`:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
uv sync --dev
|
|
39
|
+
uv run --with ruff ruff check .
|
|
40
|
+
uv run --with ruff ruff format --check .
|
|
41
|
+
uv run --with ty ty check
|
|
42
|
+
uv run --with pytest --with pytest-asyncio python -m pytest
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
deltav/__init__.py,sha256=zDOKPV-mfK4thYAH3VAtDfzru6vHjyMgttris0WVZTY,1028
|
|
2
|
+
deltav/core.py,sha256=Akj_t7hGV3Jgg4hHLxXJtK2Q_W-9DjLjc5qQpu_nXBM,5286
|
|
3
|
+
deltav/store_convex.py,sha256=ep4yA2kfa0jWT7evBqeg_Ju5nQ0XUmUj1s5eYvJao2s,9395
|
|
4
|
+
deltav/store_local.py,sha256=zqMbW2X0D1mTTzC9-uDDy6QZTUioAXwr3uRa-S3sLtU,2469
|
|
5
|
+
deltav/store_postgres.py,sha256=pBwhqK1SW6pWXJhIj8xCFWy8u8dE_i9UfmL03xfB-3c,6890
|
|
6
|
+
deltav/store_remote.py,sha256=9Vma1BHq7gAhPyB7sUe0R4lQKCmuRsD6oq7azVqa69Q,4957
|
|
7
|
+
deltav-0.0.0.dist-info/METADATA,sha256=24dQI1FGszDbRu_Tl2cOGbuSpwKbaTtajEvXIY1pLBo,1013
|
|
8
|
+
deltav-0.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
deltav-0.0.0.dist-info/RECORD,,
|