onebudgetspec-sdk 0.1.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.
@@ -0,0 +1,45 @@
1
+ """The onebudgetspec Python SDK: check, validate and list budgets through the binary.
2
+
3
+ Each call runs the ``onebudgetspec`` binary once and returns its JSON report as a model
4
+ generated from ``onebudgetspec schema``.
5
+ """
6
+
7
+ from ._client import (
8
+ BINARY_ENV,
9
+ CLI_DISTRIBUTION,
10
+ OnebudgetspecError,
11
+ StrPath,
12
+ check,
13
+ list_budgets,
14
+ resolve_binary,
15
+ schema,
16
+ validate,
17
+ )
18
+ from ._generated import SCHEMA_BUNDLE_VERSION
19
+ from ._generated.check_report import CheckReport, CheckResult, Direction, Host, Verdict
20
+ from ._generated.list_report import ListedBudget, ListReport, Measure
21
+
22
+ __all__ = [
23
+ "BINARY_ENV",
24
+ "CLI_DISTRIBUTION",
25
+ "SCHEMA_BUNDLE_VERSION",
26
+ "CheckReport",
27
+ "CheckResult",
28
+ "Direction",
29
+ "Host",
30
+ "ListReport",
31
+ "ListedBudget",
32
+ "Measure",
33
+ "OnebudgetspecError",
34
+ "StrPath",
35
+ "Verdict",
36
+ "__version__",
37
+ "check",
38
+ "list_budgets",
39
+ "resolve_binary",
40
+ "schema",
41
+ "validate",
42
+ ]
43
+
44
+ #: The version of this package, which releases in lock step with the ``onebudgetspec`` binary.
45
+ __version__ = "0.1.0"
@@ -0,0 +1,394 @@
1
+ """Run the ``onebudgetspec`` binary once per call and read the JSON report it prints.
2
+
3
+ Nothing here measures, loads or selects a budget: the binary does all of it, and this
4
+ module turns a call into its argv and its stdout into the generated report type, after
5
+ validating it against the schema that type was generated from.
6
+ """
7
+
8
+ import importlib.metadata
9
+ import json
10
+ import math
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ from collections.abc import Callable, Sequence
16
+ from pathlib import Path
17
+ from typing import TypeVar
18
+
19
+ from jsonschema import Draft202012Validator, FormatChecker
20
+ from pydantic import BaseModel, JsonValue, ValidationError
21
+
22
+ from ._generated import BUNDLE_ROOTS
23
+ from ._generated.check_report import CheckReport
24
+ from ._generated.list_report import ListReport
25
+ from ._generated.schemas import REPORT_SCHEMAS
26
+
27
+ #: The environment variable naming the binary when no explicit one is passed.
28
+ BINARY_ENV = "ONEBUDGETSPEC_BIN"
29
+ #: The distribution whose wheel carries the binary.
30
+ CLI_DISTRIBUTION = "onebudgetspec-cli"
31
+ _EXECUTABLE = "onebudgetspec"
32
+ #: The statuses whose stdout is a report: within, over and error. A verdict is in the
33
+ #: report, so none of them is an exception.
34
+ _REPORTED = frozenset({0, 1, 3})
35
+
36
+ #: A filesystem path, as a string or a path object.
37
+ StrPath = str | os.PathLike[str]
38
+ _Report = TypeVar("_Report", bound=BaseModel)
39
+
40
+
41
+ class OnebudgetspecError(RuntimeError):
42
+ """The binary refused the call, could not run, or printed no report.
43
+
44
+ ``str(error)`` is the binary's own message when it gave one. ``exit_code`` is its exit
45
+ status, ``2`` for an invalid invocation or budgets file, and ``None`` when no binary ran.
46
+ """
47
+
48
+ def __init__(self, message: str, *, exit_code: int | None) -> None:
49
+ """Keep the message and the exit status, ``None`` when nothing ran."""
50
+ super().__init__(message)
51
+ self.exit_code = exit_code
52
+
53
+
54
+ def _bundled() -> Path | None:
55
+ """The binary the ``onebudgetspec-cli`` wheel installed, when it is installed."""
56
+ try:
57
+ distribution = importlib.metadata.distribution(CLI_DISTRIBUTION)
58
+ except importlib.metadata.PackageNotFoundError:
59
+ return None
60
+ for file in distribution.files or ():
61
+ if file.name == _EXECUTABLE and file.parent.name == "bin":
62
+ path = Path(os.path.normpath(Path(str(distribution.locate_file(file)))))
63
+ if path.is_file():
64
+ return path
65
+ return None
66
+
67
+
68
+ def resolve_binary(binary: StrPath | None = None) -> Path:
69
+ """The binary a call runs.
70
+
71
+ In order: ``binary`` when given, then ``ONEBUDGETSPEC_BIN`` when set and non-empty, then
72
+ the binary the ``onebudgetspec-cli`` wheel installed, then ``onebudgetspec`` on ``PATH``.
73
+
74
+ Raises:
75
+ OnebudgetspecError: none of them names a binary.
76
+ """
77
+ if binary is not None:
78
+ return Path(binary)
79
+ from_environment = os.environ.get(BINARY_ENV)
80
+ if from_environment:
81
+ return Path(from_environment)
82
+ bundled = _bundled()
83
+ if bundled is not None:
84
+ return bundled
85
+ on_path = shutil.which(_EXECUTABLE)
86
+ if on_path is not None:
87
+ return Path(on_path)
88
+ raise OnebudgetspecError(
89
+ f"onebudgetspec: no binary found; install {CLI_DISTRIBUTION}, put {_EXECUTABLE} "
90
+ f"on PATH, set {BINARY_ENV}, or pass binary=",
91
+ exit_code=None,
92
+ )
93
+
94
+
95
+ def _values(name: str, values: Sequence[str] | None) -> list[str]:
96
+ """``values`` as a list, refusing a lone string, which would split into characters."""
97
+ if values is None:
98
+ return []
99
+ if isinstance(values, str) or not all(isinstance(value, str) for value in values):
100
+ raise TypeError(f"{name} must be a sequence of strings, not {values!r}")
101
+ return list(values)
102
+
103
+
104
+ def _paths(paths: Sequence[StrPath] | None) -> list[str]:
105
+ if paths is None:
106
+ return []
107
+ if isinstance(paths, (str, os.PathLike)):
108
+ raise TypeError(f"paths must be a sequence of paths, not {paths!r}")
109
+ return [os.fspath(path) for path in paths]
110
+
111
+
112
+ def _files(paths: Sequence[StrPath] | None, recursive: bool) -> list[str]:
113
+ """The flags and operands naming the budgets files, after ``--`` so none reads as a flag."""
114
+ if not isinstance(recursive, bool):
115
+ raise TypeError(f"recursive must be True or False, not {recursive!r}")
116
+ return [*(["--recursive"] if recursive else []), "--", *_paths(paths)]
117
+
118
+
119
+ def _selection(
120
+ ids: Sequence[str] | None,
121
+ labels: Sequence[str] | None,
122
+ exclude_labels: Sequence[str] | None,
123
+ ) -> list[str]:
124
+ """The selection flags, each value joined to its flag so none can read as a flag."""
125
+ return [
126
+ f"{flag}={value}"
127
+ for flag, name, values in (
128
+ ("--id", "ids", ids),
129
+ ("--label", "labels", labels),
130
+ ("--exclude-label", "exclude_labels", exclude_labels),
131
+ )
132
+ for value in _values(name, values)
133
+ ]
134
+
135
+
136
+ def _run(binary: StrPath | None, args: list[str], cwd: StrPath | None) -> bytes:
137
+ """Run the binary with ``args`` and return its stdout, or raise with its message."""
138
+ program = resolve_binary(binary)
139
+ try:
140
+ completed = subprocess.run(
141
+ [os.fspath(program), *args],
142
+ cwd=cwd,
143
+ stdin=subprocess.DEVNULL,
144
+ capture_output=True,
145
+ check=False,
146
+ )
147
+ except OSError as error:
148
+ raise OnebudgetspecError(
149
+ f"onebudgetspec: cannot run {program}: {error}", exit_code=None
150
+ ) from error
151
+ if completed.returncode in _REPORTED:
152
+ return completed.stdout
153
+ status = completed.returncode
154
+ message = completed.stderr.decode(errors="replace").strip()
155
+ if not message:
156
+ ended = f"was terminated by signal {-status}" if status < 0 else f"exited {status}"
157
+ message = f"onebudgetspec: {program} {ended} with no message"
158
+ raise OnebudgetspecError(message, exit_code=status)
159
+
160
+
161
+ #: An RFC 3339 timestamp, as the reports' ``date-time`` fields carry.
162
+ _RFC3339 = re.compile(
163
+ r"(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))",
164
+ re.IGNORECASE,
165
+ )
166
+ _DAYS_IN_MONTH = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
167
+ #: Every format the report schemas name, checked: the timestamps, and the numeric widths
168
+ #: the binary's Rust types carry. A format applies only to its own JSON type; the schema's
169
+ #: ``type`` refuses any other.
170
+ _FORMATS = FormatChecker(formats=())
171
+
172
+
173
+ @_FORMATS.checks("date-time")
174
+ def _is_date_time(value: object) -> bool:
175
+ """An RFC 3339 instant: a day its month has, and every field in range.
176
+
177
+ A second of 60 is refused: a leap second cannot be told from a nonexistent one here.
178
+ """
179
+ if not isinstance(value, str):
180
+ return True
181
+ match = _RFC3339.fullmatch(value)
182
+ if match is None:
183
+ return False
184
+ year, month, day, hour, minute, second = (int(part) for part in match.groups()[:6])
185
+ offset_hours, offset_minutes = (int(part or 0) for part in match.groups()[6:])
186
+ if not 1 <= month <= 12:
187
+ return False
188
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
189
+ days = _DAYS_IN_MONTH[month - 1] + (1 if month == 2 and leap else 0)
190
+ return (
191
+ 1 <= day <= days
192
+ and hour <= 23
193
+ and minute <= 59
194
+ and second <= 59
195
+ and offset_hours <= 23
196
+ and offset_minutes <= 59
197
+ )
198
+
199
+
200
+ def _is_unsigned(bits: int) -> Callable[[object], bool]:
201
+ """A check that an integer fits in ``bits`` unsigned bits."""
202
+
203
+ def check(value: object) -> bool:
204
+ if isinstance(value, bool) or not isinstance(value, int):
205
+ return True
206
+ return 0 <= value < 2**bits
207
+
208
+ return check
209
+
210
+
211
+ _FORMATS.checks("uint32")(_is_unsigned(32))
212
+ _FORMATS.checks("uint64")(_is_unsigned(64))
213
+
214
+
215
+ @_FORMATS.checks("double")
216
+ def _is_double(value: object) -> bool:
217
+ """A finite number; JSON holds no infinity, but a literal too large reads as one."""
218
+ return not isinstance(value, float) or math.isfinite(value)
219
+
220
+
221
+ def _formats_named(schema: object) -> set[str]:
222
+ """Every ``format`` a JSON Schema names, at any depth."""
223
+ match schema:
224
+ case {"format": str(name), **rest}:
225
+ return {name}.union(*(_formats_named(value) for value in rest.values()))
226
+ case dict():
227
+ return set().union(*(_formats_named(value) for value in schema.values()))
228
+ case list():
229
+ return set().union(*(_formats_named(item) for item in schema))
230
+ case _:
231
+ return set()
232
+
233
+
234
+ def _require_validators(schemas: object) -> None:
235
+ """Refuse report schemas naming a format no validator here checks.
236
+
237
+ ``FormatChecker`` passes a format it has no check for, so a report schema naming one would
238
+ let its values through unchecked; this makes that a failure at import instead.
239
+
240
+ Raises:
241
+ OnebudgetspecError: a format the schemas name has no validator in this module.
242
+ """
243
+ unchecked = sorted(_formats_named(schemas) - set(_FORMATS.checkers))
244
+ if unchecked:
245
+ raise OnebudgetspecError(
246
+ f"onebudgetspec: the report schemas name the format(s) {unchecked}, which this SDK "
247
+ "has no validator for, so it cannot check the binary's reports; install the "
248
+ "onebudgetspec-sdk release that matches the binary, or, developing this SDK, add a "
249
+ "validator for each to onebudgetspec_sdk/_client.py",
250
+ exit_code=None,
251
+ )
252
+
253
+
254
+ _require_validators(REPORT_SCHEMAS)
255
+ _VALIDATORS = {
256
+ root: Draft202012Validator(schema, format_checker=_FORMATS)
257
+ for root, schema in REPORT_SCHEMAS.items()
258
+ }
259
+
260
+
261
+ def _report(model: type[_Report], root: str, stdout: bytes) -> _Report:
262
+ """``stdout`` as ``model``, once it is valid against the schema root ``root``."""
263
+ try:
264
+ document = json.loads(stdout)
265
+ except ValueError as error:
266
+ raise OnebudgetspecError(
267
+ f"onebudgetspec: the binary printed no JSON: {error}", exit_code=None
268
+ ) from error
269
+ problems = [
270
+ f"{'/'.join(str(part) for part in error.absolute_path) or '(report)'}: {error.message}"
271
+ for error in _VALIDATORS[root].iter_errors(document)
272
+ ]
273
+ if problems:
274
+ raise OnebudgetspecError(
275
+ f"onebudgetspec: the binary printed no valid {root}: {'; '.join(sorted(problems))}",
276
+ exit_code=None,
277
+ )
278
+ try:
279
+ # Strict: a report field of the wrong JSON type is refused, never coerced.
280
+ return model.model_validate_json(stdout, strict=True)
281
+ except ValidationError as error:
282
+ raise OnebudgetspecError(
283
+ f"onebudgetspec: the binary printed no valid {root}: {error}", exit_code=None
284
+ ) from error
285
+
286
+
287
+ def check(
288
+ paths: Sequence[StrPath] | None = None,
289
+ ids: Sequence[str] | None = None,
290
+ labels: Sequence[str] | None = None,
291
+ exclude_labels: Sequence[str] | None = None,
292
+ recursive: bool = False,
293
+ cwd: StrPath | None = None,
294
+ *,
295
+ binary: StrPath | None = None,
296
+ ) -> CheckReport:
297
+ """Measure every selected budget once: ``onebudgetspec check``.
298
+
299
+ Args:
300
+ paths: budgets files, or directories with ``recursive``; ``./budgets.yaml`` when
301
+ omitted. Relative paths are read from ``cwd``.
302
+ ids: keep only these budgets; an id no file registers is refused.
303
+ labels: keep budgets carrying at least one of these labels.
304
+ exclude_labels: drop budgets carrying any of these labels.
305
+ recursive: search directories for files named ``budgets.yaml``.
306
+ cwd: the directory to run from; the current one when omitted.
307
+ binary: the binary to run; see :func:`resolve_binary`.
308
+
309
+ Returns:
310
+ The report, whatever its verdicts: within, over and error are all in it.
311
+
312
+ Raises:
313
+ OnebudgetspecError: the invocation or a budgets file is invalid (exit status 2),
314
+ with the binary's message, or the binary could not run.
315
+ """
316
+ args = ["check", "--json", *_selection(ids, labels, exclude_labels), *_files(paths, recursive)]
317
+ return _report(CheckReport, "check-report", _run(binary, args, cwd))
318
+
319
+
320
+ def validate(
321
+ paths: Sequence[StrPath] | None = None,
322
+ recursive: bool = False,
323
+ cwd: StrPath | None = None,
324
+ *,
325
+ binary: StrPath | None = None,
326
+ ) -> ListReport:
327
+ """Check the files' shape and id uniqueness, running no command: ``onebudgetspec validate``.
328
+
329
+ Args:
330
+ paths: as for :func:`check`.
331
+ recursive: as for :func:`check`.
332
+ cwd: as for :func:`check`.
333
+ binary: as for :func:`check`.
334
+
335
+ Returns:
336
+ Every budget the valid files register, as ``list`` reports them.
337
+
338
+ Raises:
339
+ OnebudgetspecError: a file is invalid (exit status 2), with the binary's message.
340
+ """
341
+ args = ["validate", "--json", *_files(paths, recursive)]
342
+ return _report(ListReport, "list-report", _run(binary, args, cwd))
343
+
344
+
345
+ def list_budgets(
346
+ paths: Sequence[StrPath] | None = None,
347
+ ids: Sequence[str] | None = None,
348
+ labels: Sequence[str] | None = None,
349
+ exclude_labels: Sequence[str] | None = None,
350
+ recursive: bool = False,
351
+ cwd: StrPath | None = None,
352
+ *,
353
+ binary: StrPath | None = None,
354
+ ) -> ListReport:
355
+ """Report the selected budgets, running no command: ``onebudgetspec list``.
356
+
357
+ Takes the arguments of :func:`check`.
358
+
359
+ Raises:
360
+ OnebudgetspecError: the invocation or a budgets file is invalid (exit status 2).
361
+ """
362
+ args = ["list", "--json", *_selection(ids, labels, exclude_labels), *_files(paths, recursive)]
363
+ return _report(ListReport, "list-report", _run(binary, args, cwd))
364
+
365
+
366
+ def schema(*, binary: StrPath | None = None) -> dict[str, JsonValue]:
367
+ """The JSON Schema bundle the binary prints: ``onebudgetspec schema``.
368
+
369
+ Raises:
370
+ OnebudgetspecError: the binary could not run, or printed no schema bundle.
371
+ """
372
+ stdout = _run(binary, ["schema"], None)
373
+ try:
374
+ bundle = json.loads(stdout)
375
+ except ValueError as error:
376
+ raise OnebudgetspecError(
377
+ f"onebudgetspec: the schema is not JSON: {error}", exit_code=None
378
+ ) from error
379
+ if not isinstance(bundle, dict):
380
+ raise OnebudgetspecError("onebudgetspec: the schema is not a JSON object", exit_code=None)
381
+ version, roots = bundle.get("version"), bundle.get("roots")
382
+ if (
383
+ isinstance(version, bool)
384
+ or not isinstance(version, int)
385
+ or not isinstance(roots, dict)
386
+ or not all(isinstance(roots.get(root), dict) for root in BUNDLE_ROOTS)
387
+ ):
388
+ raise OnebudgetspecError(
389
+ "onebudgetspec: the binary's output is not a schema bundle: it needs an integer "
390
+ f"`version` and the `roots` {list(BUNDLE_ROOTS)}, each an object; point "
391
+ f"{BINARY_ENV} at an onebudgetspec binary, or reinstall {CLI_DISTRIBUTION}",
392
+ exit_code=None,
393
+ )
394
+ return bundle
@@ -0,0 +1,7 @@
1
+ """Models generated from `onebudgetspec schema`; see sdks/python/generate.py."""
2
+
3
+ #: The version of the schema bundle these models were generated from.
4
+ SCHEMA_BUNDLE_VERSION = 1
5
+
6
+ #: Every root the schema bundle carries, which the client requires of one.
7
+ BUNDLE_ROOTS = ("budgets-file", "check-report", "list-report")
@@ -0,0 +1,137 @@
1
+ # Generated by sdks/python/generate.py from the 'check-report' root of `onebudgetspec schema`.
2
+ # Do not edit: re-run the generator. Each docstring is the schema's description verbatim.
3
+ # ruff: noqa: D200, D205, D212, D400, D415, E501
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Literal
8
+
9
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
10
+ from typing_extensions import TypeAliasType
11
+
12
+ Direction = TypeAliasType("Direction", Literal["max", "min"])
13
+ """
14
+ Which side of the threshold is within.
15
+ """
16
+
17
+
18
+ class Host(BaseModel):
19
+ """
20
+ The host conditions recorded beside a result.
21
+ """
22
+
23
+ model_config = ConfigDict(
24
+ extra="forbid",
25
+ )
26
+ conditions: dict[str, str]
27
+ """
28
+ Each condition the file declares, and for a `reported` result each condition its
29
+ command returned, by name.
30
+ """
31
+ cpus: int = Field(..., ge=0)
32
+ """
33
+ The CPUs available to this process.
34
+ """
35
+ load1: float | None
36
+ """
37
+ The 1-minute load average; null where unreadable.
38
+ """
39
+ mem_available_mib: int | None = Field(..., ge=0)
40
+ """
41
+ Available memory in MiB; null where unreadable.
42
+ """
43
+
44
+
45
+ Verdict = TypeAliasType("Verdict", Literal["within", "over", "error"])
46
+ """
47
+ A result's verdict.
48
+ """
49
+
50
+
51
+ class CheckResult(BaseModel):
52
+ """
53
+ One budget's measurement.
54
+ """
55
+
56
+ model_config = ConfigDict(
57
+ extra="forbid",
58
+ )
59
+ actual: float | None
60
+ """
61
+ The measured value; null on an error.
62
+ """
63
+ detail: str | None
64
+ """
65
+ The `detail` a `reported` command wrote; null when it wrote none.
66
+ """
67
+ direction: Direction
68
+ """
69
+ The budget's direction.
70
+ """
71
+ ended_at: AwareDatetime
72
+ """
73
+ When the measurement ended (RFC 3339), never before `started_at`.
74
+ """
75
+ error: str | None
76
+ """
77
+ Why the measurement failed; null unless `verdict` is `error`.
78
+ """
79
+ file: str
80
+ """
81
+ The budgets file's path, as discovered or given.
82
+ """
83
+ headroom: float | None
84
+ """
85
+ `threshold - actual` under `max`, `actual - threshold` under `min`; negative when
86
+ over, null on an error.
87
+ """
88
+ headroom_percent: float | None
89
+ """
90
+ `headroom / threshold * 100`; null on an error or when the threshold is 0.
91
+ """
92
+ host: Host
93
+ """
94
+ The host conditions recorded beside this result.
95
+ """
96
+ id: str
97
+ """
98
+ The budget's id.
99
+ """
100
+ labels: list[str]
101
+ """
102
+ The budget's labels, empty when it has none.
103
+ """
104
+ started_at: AwareDatetime
105
+ """
106
+ When the measurement started (RFC 3339).
107
+ """
108
+ threshold: float
109
+ """
110
+ The budget's threshold.
111
+ """
112
+ unit: str
113
+ """
114
+ The budget's unit.
115
+ """
116
+ verdict: Verdict
117
+ """
118
+ `within`, `over` or `error`.
119
+ """
120
+
121
+
122
+ class CheckReport(BaseModel):
123
+ """
124
+ What `check` answers with: one result per selected budget, in measurement order.
125
+ """
126
+
127
+ model_config = ConfigDict(
128
+ extra="forbid",
129
+ )
130
+ results: list[CheckResult]
131
+ """
132
+ One result per selected budget.
133
+ """
134
+ schema_version: Literal[1]
135
+ """
136
+ Always `1`.
137
+ """
@@ -0,0 +1,89 @@
1
+ # Generated by sdks/python/generate.py from the 'list-report' root of `onebudgetspec schema`.
2
+ # Do not edit: re-run the generator. Each docstring is the schema's description verbatim.
3
+ # ruff: noqa: D200, D205, D212, D400, D415, E501
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+ from typing_extensions import TypeAliasType
11
+
12
+ Direction = TypeAliasType("Direction", Literal["max", "min"])
13
+ """
14
+ Which side of the threshold is within.
15
+ """
16
+
17
+
18
+ Measure = TypeAliasType("Measure", Literal["elapsed", "reported"])
19
+ """
20
+ How a budget's value is measured.
21
+ """
22
+
23
+
24
+ class ListedBudget(BaseModel):
25
+ """
26
+ One budget as `list` reports it: every field the file gives, plus the file.
27
+ """
28
+
29
+ model_config = ConfigDict(
30
+ extra="forbid",
31
+ )
32
+ command: list[str]
33
+ """
34
+ The argv that measures it.
35
+ """
36
+ description: str | None
37
+ """
38
+ The budget's description; null when it has none.
39
+ """
40
+ direction: Direction
41
+ """
42
+ The budget's direction.
43
+ """
44
+ file: str
45
+ """
46
+ The budgets file's path, as discovered or given.
47
+ """
48
+ id: str
49
+ """
50
+ The budget's id.
51
+ """
52
+ labels: list[str]
53
+ """
54
+ The budget's labels, empty when it has none.
55
+ """
56
+ measure: Measure
57
+ """
58
+ How the value is measured.
59
+ """
60
+ threshold: float
61
+ """
62
+ The budget's threshold.
63
+ """
64
+ timeout_seconds: int | None = Field(..., ge=0)
65
+ """
66
+ The budget's timeout; null when it has none.
67
+ """
68
+ unit: str
69
+ """
70
+ The budget's unit.
71
+ """
72
+
73
+
74
+ class ListReport(BaseModel):
75
+ """
76
+ What `list` answers with: the selected budgets, in file order.
77
+ """
78
+
79
+ model_config = ConfigDict(
80
+ extra="forbid",
81
+ )
82
+ budgets: list[ListedBudget]
83
+ """
84
+ The selected budgets.
85
+ """
86
+ schema_version: Literal[1]
87
+ """
88
+ Always `1`.
89
+ """
@@ -0,0 +1,283 @@
1
+ # ruff: noqa: E501 # The schema's descriptions are kept verbatim.
2
+ """The report roots of `onebudgetspec schema`, generated by sdks/python/generate.py.
3
+
4
+ The client validates every report against them. Do not edit.
5
+ """
6
+
7
+ #: Each report root, by name, as the binary emits it.
8
+ REPORT_SCHEMAS: dict[str, dict[str, object]] = {
9
+ "check-report": {
10
+ "$defs": {
11
+ "CheckResult": {
12
+ "additionalProperties": False,
13
+ "description": "One budget's measurement.",
14
+ "properties": {
15
+ "actual": {
16
+ "description": "The measured value; null on an error.",
17
+ "format": "double",
18
+ "type": ["number", "null"],
19
+ },
20
+ "detail": {
21
+ "description": "The `detail` a `reported` command wrote; null when it wrote none.",
22
+ "type": ["string", "null"],
23
+ },
24
+ "direction": {
25
+ "$ref": "#/$defs/Direction",
26
+ "description": "The budget's direction.",
27
+ },
28
+ "ended_at": {
29
+ "description": "When the measurement ended (RFC 3339), never before `started_at`.",
30
+ "format": "date-time",
31
+ "type": "string",
32
+ },
33
+ "error": {
34
+ "description": "Why the measurement failed; null unless `verdict` is `error`.",
35
+ "type": ["string", "null"],
36
+ },
37
+ "file": {
38
+ "description": "The budgets file's path, as discovered or given.",
39
+ "type": "string",
40
+ },
41
+ "headroom": {
42
+ "description": "`threshold - actual` under `max`, `actual - threshold` under `min`; negative when\nover, null on an error.",
43
+ "format": "double",
44
+ "type": ["number", "null"],
45
+ },
46
+ "headroom_percent": {
47
+ "description": "`headroom / threshold * 100`; null on an error or when the threshold is 0.",
48
+ "format": "double",
49
+ "type": ["number", "null"],
50
+ },
51
+ "host": {
52
+ "$ref": "#/$defs/Host",
53
+ "description": "The host conditions recorded beside this result.",
54
+ },
55
+ "id": {"description": "The budget's id.", "type": "string"},
56
+ "labels": {
57
+ "description": "The budget's labels, empty when it has none.",
58
+ "items": {"type": "string"},
59
+ "type": "array",
60
+ },
61
+ "started_at": {
62
+ "description": "When the measurement started (RFC 3339).",
63
+ "format": "date-time",
64
+ "type": "string",
65
+ },
66
+ "threshold": {
67
+ "description": "The budget's threshold.",
68
+ "format": "double",
69
+ "type": "number",
70
+ },
71
+ "unit": {"description": "The budget's unit.", "type": "string"},
72
+ "verdict": {
73
+ "$ref": "#/$defs/Verdict",
74
+ "description": "`within`, `over` or `error`.",
75
+ },
76
+ },
77
+ "required": [
78
+ "actual",
79
+ "detail",
80
+ "direction",
81
+ "ended_at",
82
+ "error",
83
+ "file",
84
+ "headroom",
85
+ "headroom_percent",
86
+ "host",
87
+ "id",
88
+ "labels",
89
+ "started_at",
90
+ "threshold",
91
+ "unit",
92
+ "verdict",
93
+ ],
94
+ "type": "object",
95
+ },
96
+ "Direction": {
97
+ "description": "Which side of the threshold is within.",
98
+ "oneOf": [
99
+ {
100
+ "const": "max",
101
+ "description": "Within when `actual <= threshold`.",
102
+ "type": "string",
103
+ },
104
+ {
105
+ "const": "min",
106
+ "description": "Within when `actual >= threshold`.",
107
+ "type": "string",
108
+ },
109
+ ],
110
+ },
111
+ "Host": {
112
+ "additionalProperties": False,
113
+ "description": "The host conditions recorded beside a result.",
114
+ "properties": {
115
+ "conditions": {
116
+ "additionalProperties": {"type": "string"},
117
+ "description": "Each condition the file declares, and for a `reported` result each condition its\ncommand returned, by name.",
118
+ "type": "object",
119
+ },
120
+ "cpus": {
121
+ "description": "The CPUs available to this process.",
122
+ "format": "uint32",
123
+ "minimum": 0,
124
+ "type": "integer",
125
+ },
126
+ "load1": {
127
+ "description": "The 1-minute load average; null where unreadable.",
128
+ "format": "double",
129
+ "type": ["number", "null"],
130
+ },
131
+ "mem_available_mib": {
132
+ "description": "Available memory in MiB; null where unreadable.",
133
+ "format": "uint64",
134
+ "minimum": 0,
135
+ "type": ["integer", "null"],
136
+ },
137
+ },
138
+ "required": ["conditions", "cpus", "load1", "mem_available_mib"],
139
+ "type": "object",
140
+ },
141
+ "Verdict": {
142
+ "description": "A result's verdict.",
143
+ "oneOf": [
144
+ {
145
+ "const": "within",
146
+ "description": "The actual value is on the budget's side of the threshold.",
147
+ "type": "string",
148
+ },
149
+ {
150
+ "const": "over",
151
+ "description": "The actual value is past the threshold.",
152
+ "type": "string",
153
+ },
154
+ {
155
+ "const": "error",
156
+ "description": "No value was measured; `error` says why.",
157
+ "type": "string",
158
+ },
159
+ ],
160
+ },
161
+ },
162
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
163
+ "additionalProperties": False,
164
+ "description": "What `check` answers with: one result per selected budget, in measurement order.",
165
+ "properties": {
166
+ "results": {
167
+ "description": "One result per selected budget.",
168
+ "items": {"$ref": "#/$defs/CheckResult"},
169
+ "type": "array",
170
+ },
171
+ "schema_version": {"const": 1, "description": "Always `1`.", "type": "integer"},
172
+ },
173
+ "required": ["schema_version", "results"],
174
+ "title": "check-report",
175
+ "type": "object",
176
+ },
177
+ "list-report": {
178
+ "$defs": {
179
+ "Direction": {
180
+ "description": "Which side of the threshold is within.",
181
+ "oneOf": [
182
+ {
183
+ "const": "max",
184
+ "description": "Within when `actual <= threshold`.",
185
+ "type": "string",
186
+ },
187
+ {
188
+ "const": "min",
189
+ "description": "Within when `actual >= threshold`.",
190
+ "type": "string",
191
+ },
192
+ ],
193
+ },
194
+ "ListedBudget": {
195
+ "additionalProperties": False,
196
+ "description": "One budget as `list` reports it: every field the file gives, plus the file.",
197
+ "properties": {
198
+ "command": {
199
+ "description": "The argv that measures it.",
200
+ "items": {"type": "string"},
201
+ "type": "array",
202
+ },
203
+ "description": {
204
+ "description": "The budget's description; null when it has none.",
205
+ "type": ["string", "null"],
206
+ },
207
+ "direction": {
208
+ "$ref": "#/$defs/Direction",
209
+ "description": "The budget's direction.",
210
+ },
211
+ "file": {
212
+ "description": "The budgets file's path, as discovered or given.",
213
+ "type": "string",
214
+ },
215
+ "id": {"description": "The budget's id.", "type": "string"},
216
+ "labels": {
217
+ "description": "The budget's labels, empty when it has none.",
218
+ "items": {"type": "string"},
219
+ "type": "array",
220
+ },
221
+ "measure": {
222
+ "$ref": "#/$defs/Measure",
223
+ "description": "How the value is measured.",
224
+ },
225
+ "threshold": {
226
+ "description": "The budget's threshold.",
227
+ "format": "double",
228
+ "type": "number",
229
+ },
230
+ "timeout_seconds": {
231
+ "description": "The budget's timeout; null when it has none.",
232
+ "format": "uint64",
233
+ "minimum": 0,
234
+ "type": ["integer", "null"],
235
+ },
236
+ "unit": {"description": "The budget's unit.", "type": "string"},
237
+ },
238
+ "required": [
239
+ "command",
240
+ "description",
241
+ "direction",
242
+ "file",
243
+ "id",
244
+ "labels",
245
+ "measure",
246
+ "threshold",
247
+ "timeout_seconds",
248
+ "unit",
249
+ ],
250
+ "type": "object",
251
+ },
252
+ "Measure": {
253
+ "description": "How a budget's value is measured.",
254
+ "oneOf": [
255
+ {
256
+ "const": "elapsed",
257
+ "description": "The command's wall clock, in seconds; a non-zero exit is an error.",
258
+ "type": "string",
259
+ },
260
+ {
261
+ "const": "reported",
262
+ "description": "The value the command writes to the file `ONEBUDGETSPEC_RESULT` names.",
263
+ "type": "string",
264
+ },
265
+ ],
266
+ },
267
+ },
268
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
269
+ "additionalProperties": False,
270
+ "description": "What `list` answers with: the selected budgets, in file order.",
271
+ "properties": {
272
+ "budgets": {
273
+ "description": "The selected budgets.",
274
+ "items": {"$ref": "#/$defs/ListedBudget"},
275
+ "type": "array",
276
+ },
277
+ "schema_version": {"const": 1, "description": "Always `1`.", "type": "integer"},
278
+ },
279
+ "required": ["schema_version", "budgets"],
280
+ "title": "list-report",
281
+ "type": "object",
282
+ },
283
+ }
File without changes
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.5
2
+ Name: onebudgetspec-sdk
3
+ Version: 0.1.0
4
+ Summary: The onebudgetspec Python SDK: check, validate and list budgets through the onebudgetspec binary, with report models generated from its schema.
5
+ Project-URL: Repository, https://github.com/nickderobertis/onebudgetspec
6
+ Author: Nick DeRobertis
7
+ License-Expression: MIT
8
+ Classifier: Typing :: Typed
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: jsonschema>=4.23
11
+ Requires-Dist: onebudgetspec-cli==0.1.0
12
+ Requires-Dist: pydantic>=2.12
13
+ Requires-Dist: typing-extensions>=4.12
14
+ Description-Content-Type: text/markdown
15
+
16
+ # onebudgetspec-sdk
17
+
18
+ The Python SDK for [onebudgetspec](https://github.com/nickderobertis/onebudgetspec), imported
19
+ as `onebudgetspec_sdk`. It releases at the same version as the `onebudgetspec` command line
20
+ and requires `onebudgetspec-cli` at that version, whose wheel installs the binary.
21
+
22
+ ```sh
23
+ pip install onebudgetspec-sdk
24
+ ```
25
+
26
+ Each call runs the `onebudgetspec` binary once, validates the JSON report it prints against
27
+ `onebudgetspec schema`, and returns it as a pydantic model generated from that schema:
28
+
29
+ ```python
30
+ from onebudgetspec_sdk import check, list_budgets, schema, validate
31
+
32
+ report = check(
33
+ paths=None, ids=None, labels=["api"], exclude_labels=["slow"], recursive=False, cwd="."
34
+ )
35
+ for result in report.results:
36
+ print(result.id, result.verdict, result.actual, result.threshold, result.headroom)
37
+ print(result.host.conditions) # declared conditions, and any the command returned
38
+
39
+ listed = list_budgets(labels=["api"]) # ListReport; runs no command
40
+ validated = validate(recursive=True, paths=["."]) # ListReport of every budget; runs no command
41
+ bundle = schema() # the JSON Schema bundle, as a dict
42
+ ```
43
+
44
+ - `check(paths=None, ids=None, labels=None, exclude_labels=None, recursive=False, cwd=None) -> CheckReport`
45
+ - `validate(paths=None, recursive=False, cwd=None) -> ListReport`
46
+ - `list_budgets(paths=None, ids=None, labels=None, exclude_labels=None, recursive=False, cwd=None) -> ListReport`
47
+ - `schema() -> dict`
48
+
49
+ Exit statuses `0` (within), `1` (over) and `3` (a measurement errored) all return the
50
+ report, since the verdicts are in it. Status `2`, an invalid invocation or budgets file,
51
+ raises `OnebudgetspecError` with the CLI's own message and `exit_code == 2`.
52
+
53
+ The binary is, in order: the `binary=` keyword every call takes, then `ONEBUDGETSPEC_BIN`,
54
+ then the `onebudgetspec` the `onebudgetspec-cli` wheel installed, then `onebudgetspec` on
55
+ `PATH`. `resolve_binary()` says which.
56
+
57
+ The models are generated by `generate.py` from the binary's own schema. In the
58
+ repository, `just generate` regenerates them and `just lint` fails while they and the
59
+ schema part.
@@ -0,0 +1,10 @@
1
+ onebudgetspec_sdk/__init__.py,sha256=bLF60eZfj8el1XgwZhkAUlNowltqH_QiGfrzBIohKMY,1084
2
+ onebudgetspec_sdk/_client.py,sha256=jPSFTA0QenflMrNqhrIQloBvr-3ONRG8xzEi43yIppM,14748
3
+ onebudgetspec_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ onebudgetspec_sdk/_generated/__init__.py,sha256=AhDOYr_x7dAphRo16masPrNZKVVhE3wgW1vD5IheAT0,317
5
+ onebudgetspec_sdk/_generated/check_report.py,sha256=Etfev6a5pC9dKNWm43zoGSFrH7JImFIxMkhuMxfrYX4,3068
6
+ onebudgetspec_sdk/_generated/list_report.py,sha256=i50nhyWcpdyM_UdhHZRsIX63ArTwRfHqDiFIy1essJQ,1874
7
+ onebudgetspec_sdk/_generated/schemas.py,sha256=PGM6vBjCJmYmz-pxno4bo8X5fG7e6P0fPzqvaa4h_9U,12043
8
+ onebudgetspec_sdk-0.1.0.dist-info/METADATA,sha256=Na-QLNL7Y8Kp4wpl4zNunb7RFDy2TSsWtphAZdcxpHM,2662
9
+ onebudgetspec_sdk-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ onebudgetspec_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any