@voxgig/sdkgen 4.2.6 → 4.2.8
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.
- package/bin/voxgig-sdkgen +1 -1
- package/dist/helpers/naming.js +5 -4
- package/dist/helpers/naming.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/project/.sdk/model/feature/cost.aon +43 -0
- package/project/.sdk/model/feature/feature-index.aon +1 -0
- package/project/.sdk/src/cmp/js/fragment/EntityCreateOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityListOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityLoadOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityRemoveOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityUpdateOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/py/ReadmeExamplesTest_py.ts +15 -5
- package/project/.sdk/src/cmp/ts/fragment/EntityCreateOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityListOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityLoadOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityRemoveOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityUpdateOp.fragment.ts +2 -1
- package/project/.sdk/tm/c/core/sdk.h +1 -0
- package/project/.sdk/tm/c/feature/cost.c +549 -0
- package/project/.sdk/tm/c/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/clojure/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/cpp/feature/cost.hpp +419 -0
- package/project/.sdk/tm/cpp/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/csharp/feature/CostFeature.cs +518 -0
- package/project/.sdk/tm/csharp/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/dart/lib/feature/cost/CostFeature.dart +446 -0
- package/project/.sdk/tm/dart/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/elixir/lib/projectname/feature/cost.ex +442 -0
- package/project/.sdk/tm/elixir/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/go/feature/cost_feature.go +424 -0
- package/project/.sdk/tm/go/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/go-cli/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/go-mcp/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/java/feature/CostFeature.java +431 -0
- package/project/.sdk/tm/java/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/js/src/feature/cost/CostFeature.js +413 -0
- package/project/.sdk/tm/kotlin/feature/CostFeature.kt +430 -0
- package/project/.sdk/tm/kotlin/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/lean/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/lua/feature/cost_feature.lua +457 -0
- package/project/.sdk/tm/lua/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/ocaml/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/perl/feature/cost_feature.pm +446 -0
- package/project/.sdk/tm/perl/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/php/feature/CostFeature.php +439 -0
- package/project/.sdk/tm/php/feature/TestFeature.php +31 -1
- package/project/.sdk/tm/php/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/py/pkg/feature/cost_feature.py +381 -0
- package/project/.sdk/tm/py/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/py-data/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/rb/feature/cost_feature.rb +386 -0
- package/project/.sdk/tm/rb/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/rust/feature/cost.rs +467 -0
- package/project/.sdk/tm/rust/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/scala/feature/CostFeature.scala +388 -0
- package/project/.sdk/tm/scala/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/swift/Sources/ProjectNameSDK/feature/CostFeature.swift +425 -0
- package/project/.sdk/tm/swift/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/ts/src/feature/cost/CostFeature.ts +415 -0
- package/project/.sdk/tm/zig/feature/cost.zig +457 -0
- package/project/.sdk/tm/zig/src/feature/cost/.gitkeep +0 -0
- package/project/sdkgen-package.json +2 -1
- package/src/helpers/naming.ts +5 -4
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# ProjectName SDK cost feature
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from projectname_sdk.utility.voxgig_struct import voxgig_struct as vs
|
|
6
|
+
|
|
7
|
+
from projectname_sdk.feature.base_feature import ProjectNameBaseFeature
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Cost tracking and spend budget. Uses BOTH seams, which is the point of the
|
|
11
|
+
# feature: money is spent per HTTP ATTEMPT (a retried call is charged again,
|
|
12
|
+
# because the upstream API charges it again), but it is owed by an
|
|
13
|
+
# OPERATION. So the transport wrap prices each attempt, and PreDone
|
|
14
|
+
# attributes the running total to `<entity>.<op>` and to the caller
|
|
15
|
+
# (`ctrl.actor`, the same actor the audit feature records).
|
|
16
|
+
#
|
|
17
|
+
# The price of an attempt comes from the first source that answers: a
|
|
18
|
+
# response header (`header` x `perUnit`), the rate table (`rates`, keyed
|
|
19
|
+
# `<entity>.<op>` / `<op>` / `*`), then the flat `unit`. A body figure
|
|
20
|
+
# (`path` x `perUnit`, e.g. "usage.total_tokens") is read at PreDone
|
|
21
|
+
# instead, from the already-parsed result, and describes the whole call, so
|
|
22
|
+
# it REPLACES the per-attempt estimate rather than adding to it.
|
|
23
|
+
#
|
|
24
|
+
# `budget` caps total spend. With `onBudget: "deny"` a further operation is
|
|
25
|
+
# refused at PrePoint, before an endpoint is resolved and before anything
|
|
26
|
+
# reaches the network.
|
|
27
|
+
#
|
|
28
|
+
# ORDER MATTERS. Cost must sit INSIDE the cache, or a response served from
|
|
29
|
+
# cache is charged for money that was never spent. The default (map) order
|
|
30
|
+
# puts cache innermost and cost outside it, so activate them in list form
|
|
31
|
+
# with cost first.
|
|
32
|
+
class ProjectNameCostFeature(ProjectNameBaseFeature):
|
|
33
|
+
def __init__(self):
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.version = "0.0.1"
|
|
36
|
+
self.name = "cost"
|
|
37
|
+
self.active = True
|
|
38
|
+
self.client = None
|
|
39
|
+
self.options = {}
|
|
40
|
+
self.seq = 0
|
|
41
|
+
|
|
42
|
+
def init(self, ctx, options):
|
|
43
|
+
self.client = ctx.client
|
|
44
|
+
self.options = options if isinstance(options, dict) else {}
|
|
45
|
+
|
|
46
|
+
if self.options.get("active") is True:
|
|
47
|
+
self.active = True
|
|
48
|
+
else:
|
|
49
|
+
self.active = False
|
|
50
|
+
|
|
51
|
+
self.seq = 0
|
|
52
|
+
limit = self._limit()
|
|
53
|
+
|
|
54
|
+
client = self.client
|
|
55
|
+
if getattr(client, "_cost", None) is None:
|
|
56
|
+
client._cost = {
|
|
57
|
+
"currency": self.options.get("currency") or "USD",
|
|
58
|
+
"total": {
|
|
59
|
+
"calls": 0,
|
|
60
|
+
"attempts": 0,
|
|
61
|
+
"amount": 0,
|
|
62
|
+
"reported": 0,
|
|
63
|
+
"estimated": 0,
|
|
64
|
+
},
|
|
65
|
+
"ops": {},
|
|
66
|
+
"actors": {},
|
|
67
|
+
"budget": {
|
|
68
|
+
"limit": limit,
|
|
69
|
+
"spent": 0,
|
|
70
|
+
"remaining": limit,
|
|
71
|
+
"exceeded": False,
|
|
72
|
+
},
|
|
73
|
+
"last": None,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if not self.active:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
utility = ctx.utility
|
|
80
|
+
inner = utility.fetcher
|
|
81
|
+
|
|
82
|
+
def cost_fetcher(fctx, fullurl, fetchdef):
|
|
83
|
+
return self._charge(fctx, fullurl, fetchdef, inner)
|
|
84
|
+
|
|
85
|
+
utility.fetcher = cost_fetcher
|
|
86
|
+
|
|
87
|
+
# The budget gate. Runs before endpoint resolution, so a refused call
|
|
88
|
+
# costs nothing at all.
|
|
89
|
+
def PrePoint(self, ctx):
|
|
90
|
+
if not self.active:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Mark the context as running through the pipeline, so _charge knows a
|
|
94
|
+
# PreDone is coming and does not commit the spend itself.
|
|
95
|
+
pending = getattr(ctx, "_cost_pending", None)
|
|
96
|
+
if pending is None:
|
|
97
|
+
pending = self._new_pending()
|
|
98
|
+
ctx._cost_pending = pending
|
|
99
|
+
pending["piped"] = True
|
|
100
|
+
|
|
101
|
+
limit = self._limit()
|
|
102
|
+
if limit <= 0:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
cost = self.client._cost
|
|
106
|
+
if cost["total"]["amount"] < limit:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
cost["budget"]["exceeded"] = True
|
|
110
|
+
|
|
111
|
+
if self.options.get("onBudget") != "deny":
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
err = ctx.make_error("cost_budget",
|
|
115
|
+
"Cost budget of " + str(limit) + " " + cost["currency"] +
|
|
116
|
+
" is spent (" + str(cost["total"]["amount"]) + " " +
|
|
117
|
+
cost["currency"] + " used)")
|
|
118
|
+
# Short-circuit endpoint resolution; the pipeline surfaces this error.
|
|
119
|
+
ctx.out["point"] = err
|
|
120
|
+
return err
|
|
121
|
+
|
|
122
|
+
def _charge(self, ctx, url, fetchdef, inner):
|
|
123
|
+
# A rejecting transport still costs an attempt. Without this, a run of
|
|
124
|
+
# connection-level failures under `retry` (which catches and tries
|
|
125
|
+
# again) would be charged nothing at all, and an onBudget "deny"
|
|
126
|
+
# ceiling could never stop it.
|
|
127
|
+
threw = None
|
|
128
|
+
try:
|
|
129
|
+
res, err = inner(ctx, url, fetchdef)
|
|
130
|
+
except Exception as ex: # noqa: BLE001 - re-raised below
|
|
131
|
+
threw = ex
|
|
132
|
+
res, err = None, ex
|
|
133
|
+
|
|
134
|
+
amount, source = self._price(ctx, res)
|
|
135
|
+
|
|
136
|
+
pending = getattr(ctx, "_cost_pending", None)
|
|
137
|
+
if pending is None:
|
|
138
|
+
pending = self._new_pending()
|
|
139
|
+
ctx._cost_pending = pending
|
|
140
|
+
|
|
141
|
+
pending["attempts"] += 1
|
|
142
|
+
|
|
143
|
+
# Accumulated here, committed once at PreDone. Adding each attempt to
|
|
144
|
+
# the running total and then subtracting it again when a body figure
|
|
145
|
+
# supersedes it loses precision to catastrophic cancellation.
|
|
146
|
+
#
|
|
147
|
+
# Reported and estimated are kept apart per ATTEMPT: a 503 priced from
|
|
148
|
+
# the rate table followed by a 200 carrying the cost header is part
|
|
149
|
+
# estimate, part reported, and collapsing both into the final
|
|
150
|
+
# attempt's category would corrupt the split.
|
|
151
|
+
pending["amount"] += amount
|
|
152
|
+
key = "reported" if source in ("header", "body") else "estimated"
|
|
153
|
+
pending[key] += amount
|
|
154
|
+
pending["source"] = source
|
|
155
|
+
|
|
156
|
+
self.client._cost["total"]["attempts"] += 1
|
|
157
|
+
|
|
158
|
+
# direct() and graphql() reach the transport without dispatching any
|
|
159
|
+
# pipeline hooks, so there is no PrePoint to gate on and no PreDone to
|
|
160
|
+
# commit. Their spend is committed here, or it would never be counted.
|
|
161
|
+
# `piped` is set by PrePoint, so its absence is the signal.
|
|
162
|
+
if not pending["piped"]:
|
|
163
|
+
self._commit(ctx, pending, "_", "direct")
|
|
164
|
+
del ctx._cost_pending
|
|
165
|
+
|
|
166
|
+
if threw is not None:
|
|
167
|
+
raise threw
|
|
168
|
+
|
|
169
|
+
return res, err
|
|
170
|
+
|
|
171
|
+
def _new_pending(self):
|
|
172
|
+
return {
|
|
173
|
+
"attempts": 0, "amount": 0,
|
|
174
|
+
"reported": 0, "estimated": 0,
|
|
175
|
+
"source": "none", "piped": False,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Attribute the operation's spend once the call is finished.
|
|
179
|
+
def PreDone(self, ctx):
|
|
180
|
+
self._finish(ctx, True)
|
|
181
|
+
|
|
182
|
+
# A failed operation still spent the money. When the pipeline raises,
|
|
183
|
+
# PreDone never runs, so without this the attempts are counted and the
|
|
184
|
+
# spend is not, and a budget could never see the cost of a failed call.
|
|
185
|
+
# Whichever hook fires first consumes the pending entry, so it commits
|
|
186
|
+
# exactly once.
|
|
187
|
+
def PreUnexpected(self, ctx):
|
|
188
|
+
self._finish(ctx, False)
|
|
189
|
+
|
|
190
|
+
def _finish(self, ctx, done):
|
|
191
|
+
if not self.active:
|
|
192
|
+
return
|
|
193
|
+
pending = getattr(ctx, "_cost_pending", None)
|
|
194
|
+
if pending is None:
|
|
195
|
+
return
|
|
196
|
+
del ctx._cost_pending
|
|
197
|
+
|
|
198
|
+
# A FAILED operation that made no attempt never reached the network:
|
|
199
|
+
# PrePoint creates the pending entry to mark the context as piped, and
|
|
200
|
+
# then the budget gate refuses the call (rbac, or an unresolvable
|
|
201
|
+
# endpoint, short-circuits just as early). Committing it would count a
|
|
202
|
+
# call that never happened and file a zero-amount record as `last`.
|
|
203
|
+
#
|
|
204
|
+
# A SUCCEEDED operation that made no attempt is the opposite case: it
|
|
205
|
+
# was served from the cache. That is a real call, and the fact that it
|
|
206
|
+
# cost nothing is the whole point of ordering cost inside the cache.
|
|
207
|
+
if not done and 0 == pending["attempts"]:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
entity = "_"
|
|
211
|
+
opname = "_"
|
|
212
|
+
if ctx.op is not None:
|
|
213
|
+
entity = ctx.op.entity or "_"
|
|
214
|
+
opname = ctx.op.name or "_"
|
|
215
|
+
|
|
216
|
+
self._commit(ctx, pending, entity, opname)
|
|
217
|
+
|
|
218
|
+
# Commit one operation's spend: totals, budget, per-op and per-actor
|
|
219
|
+
# attribution, and the record. Shared by _finish and the raw-request path
|
|
220
|
+
# in _charge, which has no PreDone to reach.
|
|
221
|
+
def _commit(self, ctx, pending, entity, opname):
|
|
222
|
+
cost = self.client._cost
|
|
223
|
+
|
|
224
|
+
amount = pending["amount"]
|
|
225
|
+
reported = pending["reported"]
|
|
226
|
+
estimated = pending["estimated"]
|
|
227
|
+
source = pending["source"]
|
|
228
|
+
|
|
229
|
+
# A body figure prices the whole call, so it replaces the per-attempt
|
|
230
|
+
# estimate rather than adding to it, and being server-stated the whole
|
|
231
|
+
# amount counts as reported.
|
|
232
|
+
body = self._body(ctx)
|
|
233
|
+
if body is not None:
|
|
234
|
+
amount = body
|
|
235
|
+
reported = body
|
|
236
|
+
estimated = 0
|
|
237
|
+
source = "body"
|
|
238
|
+
|
|
239
|
+
self._spend(cost, amount, reported, estimated)
|
|
240
|
+
|
|
241
|
+
actor = getattr(ctx.ctrl, "actor", None) if ctx.ctrl is not None else None
|
|
242
|
+
if actor is None:
|
|
243
|
+
actor = self.options.get("actor")
|
|
244
|
+
if actor is None:
|
|
245
|
+
actor = "anonymous"
|
|
246
|
+
|
|
247
|
+
cost["total"]["calls"] += 1
|
|
248
|
+
self._bump(cost["ops"], entity + "." + opname, amount)
|
|
249
|
+
self._bump(cost["actors"], actor, amount)
|
|
250
|
+
|
|
251
|
+
self.seq += 1
|
|
252
|
+
record = {
|
|
253
|
+
"seq": self.seq,
|
|
254
|
+
"entity": entity,
|
|
255
|
+
"op": opname,
|
|
256
|
+
"actor": actor,
|
|
257
|
+
"amount": amount,
|
|
258
|
+
"currency": cost["currency"],
|
|
259
|
+
"source": source,
|
|
260
|
+
"attempts": pending["attempts"],
|
|
261
|
+
}
|
|
262
|
+
cost["last"] = record
|
|
263
|
+
|
|
264
|
+
sink = self.options.get("sink")
|
|
265
|
+
if callable(sink):
|
|
266
|
+
try:
|
|
267
|
+
sink(record)
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
# Price one attempt: a reported header figure, else the rate table, else
|
|
272
|
+
# the flat unit.
|
|
273
|
+
def _price(self, ctx, res):
|
|
274
|
+
header = self.options.get("header")
|
|
275
|
+
if isinstance(header, str) and header != "":
|
|
276
|
+
val = self._header(res, header)
|
|
277
|
+
if val is not None:
|
|
278
|
+
return val * self._per_unit(), "header"
|
|
279
|
+
|
|
280
|
+
rate = self._rate(ctx)
|
|
281
|
+
if rate is not None:
|
|
282
|
+
return rate, "table"
|
|
283
|
+
|
|
284
|
+
unit = self.options.get("unit")
|
|
285
|
+
if isinstance(unit, (int, float)) and not isinstance(unit, bool) and unit != 0:
|
|
286
|
+
return unit, "unit"
|
|
287
|
+
|
|
288
|
+
return 0, "none"
|
|
289
|
+
|
|
290
|
+
# The rate table uses the same lookup grammar as rbac's rules:
|
|
291
|
+
# `<entity>.<op>`, then `<op>`, then `*`.
|
|
292
|
+
def _rate(self, ctx):
|
|
293
|
+
rates = self.options.get("rates")
|
|
294
|
+
if not isinstance(rates, dict):
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
entity = ""
|
|
298
|
+
if ctx.entity is not None and getattr(ctx.entity, "name", None):
|
|
299
|
+
entity = ctx.entity.name
|
|
300
|
+
elif ctx.op is not None and ctx.op.entity:
|
|
301
|
+
entity = ctx.op.entity
|
|
302
|
+
opname = ctx.op.name if ctx.op is not None and ctx.op.name else ""
|
|
303
|
+
|
|
304
|
+
for key in (entity + "." + opname, opname, "*"):
|
|
305
|
+
val = rates.get(key)
|
|
306
|
+
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
307
|
+
return val
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
# A usage figure from the parsed result body, priced by perUnit. Read
|
|
311
|
+
# here, not at the transport seam, because the body is one-shot.
|
|
312
|
+
def _body(self, ctx):
|
|
313
|
+
path = self.options.get("path")
|
|
314
|
+
if not isinstance(path, str) or path == "":
|
|
315
|
+
return None
|
|
316
|
+
result = ctx.result
|
|
317
|
+
if result is None or result.body is None:
|
|
318
|
+
return None
|
|
319
|
+
val = vs.getpath(result.body, path)
|
|
320
|
+
num = self._num(val)
|
|
321
|
+
if num is None:
|
|
322
|
+
return None
|
|
323
|
+
return num * self._per_unit()
|
|
324
|
+
|
|
325
|
+
def _spend(self, cost, amount, reported, estimated):
|
|
326
|
+
cost["total"]["amount"] += amount
|
|
327
|
+
cost["total"]["reported"] += reported
|
|
328
|
+
cost["total"]["estimated"] += estimated
|
|
329
|
+
|
|
330
|
+
limit = cost["budget"]["limit"]
|
|
331
|
+
cost["budget"]["spent"] = cost["total"]["amount"]
|
|
332
|
+
if limit > 0:
|
|
333
|
+
cost["budget"]["remaining"] = max(0, limit - cost["total"]["amount"])
|
|
334
|
+
if cost["total"]["amount"] >= limit:
|
|
335
|
+
cost["budget"]["exceeded"] = True
|
|
336
|
+
else:
|
|
337
|
+
cost["budget"]["remaining"] = 0
|
|
338
|
+
|
|
339
|
+
def _bump(self, bucket, key, amount):
|
|
340
|
+
entry = bucket.get(key)
|
|
341
|
+
if entry is None:
|
|
342
|
+
entry = {"calls": 0, "amount": 0}
|
|
343
|
+
bucket[key] = entry
|
|
344
|
+
entry["calls"] += 1
|
|
345
|
+
entry["amount"] += amount
|
|
346
|
+
|
|
347
|
+
def _header(self, res, name):
|
|
348
|
+
if not isinstance(res, dict):
|
|
349
|
+
return None
|
|
350
|
+
headers = res.get("headers")
|
|
351
|
+
if not isinstance(headers, dict):
|
|
352
|
+
return None
|
|
353
|
+
lower = name.lower()
|
|
354
|
+
for key in headers:
|
|
355
|
+
if str(key).lower() == lower:
|
|
356
|
+
return self._num(headers[key])
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def _num(self, val):
|
|
360
|
+
if val is None or isinstance(val, bool):
|
|
361
|
+
return None
|
|
362
|
+
if isinstance(val, (int, float)):
|
|
363
|
+
return val
|
|
364
|
+
if isinstance(val, str):
|
|
365
|
+
try:
|
|
366
|
+
return float(val.strip())
|
|
367
|
+
except ValueError:
|
|
368
|
+
return None
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
def _per_unit(self):
|
|
372
|
+
per = self.options.get("perUnit")
|
|
373
|
+
if isinstance(per, (int, float)) and not isinstance(per, bool):
|
|
374
|
+
return per
|
|
375
|
+
return 0
|
|
376
|
+
|
|
377
|
+
def _limit(self):
|
|
378
|
+
budget = self.options.get("budget")
|
|
379
|
+
if isinstance(budget, (int, float)) and not isinstance(budget, bool):
|
|
380
|
+
return budget
|
|
381
|
+
return 0
|
|
File without changes
|
|
File without changes
|