devicectl-core 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.
Files changed (47) hide show
  1. devicectl/__init__.py +18 -0
  2. devicectl/cli/__init__.py +1 -0
  3. devicectl/cli/command.py +95 -0
  4. devicectl/cli/exits.py +32 -0
  5. devicectl/cli/fanout.py +142 -0
  6. devicectl/cli/main.py +69 -0
  7. devicectl/cli/output.py +299 -0
  8. devicectl/cli/parser.py +80 -0
  9. devicectl/cli/report.py +86 -0
  10. devicectl/cli/target.py +26 -0
  11. devicectl/clock.py +57 -0
  12. devicectl/devtools/__init__.py +6 -0
  13. devicectl/devtools/frontlint.py +935 -0
  14. devicectl/devtools/htmcheck.py +396 -0
  15. devicectl/devtools/rendercheck.py +384 -0
  16. devicectl/doctor.py +112 -0
  17. devicectl/errors.py +68 -0
  18. devicectl/fields.py +564 -0
  19. devicectl/meta.py +64 -0
  20. devicectl/paths.py +40 -0
  21. devicectl/progress.py +77 -0
  22. devicectl/report.py +67 -0
  23. devicectl/testing.py +199 -0
  24. devicectl/trace.py +333 -0
  25. devicectl/web/__init__.py +1 -0
  26. devicectl/web/agents.py +94 -0
  27. devicectl/web/events.py +171 -0
  28. devicectl/web/http.py +243 -0
  29. devicectl/web/progress.py +101 -0
  30. devicectl/web/server.py +1013 -0
  31. devicectl/web/static/core.css +3034 -0
  32. devicectl/web/static/js/api.js +198 -0
  33. devicectl/web/static/js/band.js +640 -0
  34. devicectl/web/static/js/chart.js +400 -0
  35. devicectl/web/static/js/drafts.js +312 -0
  36. devicectl/web/static/js/notify.js +272 -0
  37. devicectl/web/static/js/panels.js +432 -0
  38. devicectl/web/static/js/shell.js +672 -0
  39. devicectl/web/static/js/trace.js +133 -0
  40. devicectl/web/static/js/ui.js +1139 -0
  41. devicectl/web/static/vendor/preact-htm.module.js +27 -0
  42. devicectl/web/worker.py +697 -0
  43. devicectl_core-0.1.0.dist-info/METADATA +131 -0
  44. devicectl_core-0.1.0.dist-info/RECORD +47 -0
  45. devicectl_core-0.1.0.dist-info/WHEEL +4 -0
  46. devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
  47. devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
devicectl/fields.py ADDED
@@ -0,0 +1,564 @@
1
+ """One description per setting, for every place a setting has to be described.
2
+
3
+ A configuration group -- a charger's load balancing, a battery's protection
4
+ thresholds -- is a list of fields, and a program built the obvious way ends up
5
+ writing that list five times: once to decode what the device answered, once to
6
+ print it, once to serialise it for a browser, once as ``add_argument`` calls,
7
+ and once inside the function that writes it back. The five copies drift, and
8
+ they drift quietly: a field gains a bound in the validator that the slider in
9
+ the browser does not know about, or a JSON key is renamed and the terminal
10
+ keeps the old label.
11
+
12
+ A :class:`FieldSpec` is that description written once. It carries what the
13
+ field *is* (a number, a flag, one of an enumeration), where it lives on the
14
+ device, what it may be set to, and what each of the three audiences calls it:
15
+ a label for a terminal, a key for a document, a flag for a command line. The
16
+ functions below then do each of the five jobs by walking the same tuple.
17
+
18
+ The device-facing halves -- :attr:`FieldSpec.address` and
19
+ :attr:`FieldSpec.wire` -- are deliberately untyped. This module never
20
+ dereferences either: it hands them back to the caller, who knows whether an
21
+ address is an object-dictionary ``(index, sub)`` pair or a Modbus register,
22
+ and whether a wire type is an EDS code or a struct format. What is shared is
23
+ the bookkeeping around them, not the protocol.
24
+
25
+ Nothing here is required: a group with an irregularity -- a bit field written
26
+ as two booleans, a value that is really two registers, a row that names its
27
+ neighbour -- keeps that part by hand and describes the rest. A table that has
28
+ to grow a flag for every exception is not paying for itself.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import math
34
+ from collections.abc import Callable, Iterable, Mapping, Sequence
35
+ from dataclasses import dataclass
36
+ from typing import Any
37
+
38
+ from devicectl.errors import DeviceError
39
+
40
+ # What a field's value *is*, which decides how it is decoded, checked and
41
+ # shown. A device's own type codes are finer than this on purpose: whether a
42
+ # count arrives as a byte or a word changes how it is encoded and nothing
43
+ # else, and the encoding is the caller's business.
44
+ NUMBER = "number" # a real quantity: amps, volts, degrees
45
+ INTEGER = "integer" # a whole number: seconds, a percentage, a count
46
+ TEXT = "text" # a string
47
+ FLAG = "flag" # on or off
48
+ ENUM = "enum" # one of a named set of codes
49
+
50
+ READ_ONLY = "r"
51
+ READ_WRITE = "rw"
52
+
53
+ ON_OFF = ("on", "off")
54
+
55
+ # For a field that is an enumeration on the wire and a switch to a person.
56
+ ON_OFF_CODES = {"on": 1, "off": 0}
57
+
58
+
59
+ class FieldError(DeviceError, ValueError):
60
+ """A value a field could not sensibly be given.
61
+
62
+ A :class:`~devicectl.errors.DeviceError`, so the command line prints it as
63
+ one line and the web server answers 400, with nothing to add at either end;
64
+ and a :class:`ValueError`, because that is what it is. A program whose
65
+ group already has an error class of its own derives it from both -- ``class
66
+ LoadBalancingError(AlfenError, FieldError)`` -- so that code catching
67
+ either keeps catching it.
68
+ """
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class FieldSpec:
73
+ """One setting, described once for all five of its audiences.
74
+
75
+ Only :attr:`name` is required. A field with no :attr:`label` is not
76
+ printed, one with no :attr:`json` is not serialised, one with no
77
+ :attr:`flag` is not on the command line, and one whose :attr:`access` is
78
+ ``r`` is never written -- which is how a table holds a group's read-only
79
+ neighbours (a negotiated heartbeat, an address the charger was handed)
80
+ without pretending they can be set.
81
+ """
82
+
83
+ name: str
84
+ """The attribute on the state object, and the keyword :func:`values` uses."""
85
+
86
+ kind: str = TEXT
87
+ """One of :data:`NUMBER`, :data:`INTEGER`, :data:`TEXT`, :data:`FLAG`, :data:`ENUM`."""
88
+
89
+ address: Any = None
90
+ """Where the field lives on the device. Opaque here; the caller reads it."""
91
+
92
+ wire: Any = None
93
+ """How a write is encoded. Opaque here too."""
94
+
95
+ label: str | None = None
96
+ """What a terminal calls it, or None to leave it out of the rows."""
97
+
98
+ json: str | None = None
99
+ """What a document calls it, or None to leave it out."""
100
+
101
+ flag: str | None = None
102
+ """Its command-line flag (``--safe-current``), or None for none."""
103
+
104
+ unit: str = ""
105
+ """The unit a printed value carries: ``A``, ``s``, ``%``, ``W``."""
106
+
107
+ minimum: float | None = None
108
+ maximum: float | None = None
109
+ """The range a value has to be inside. For :data:`TEXT`, the length."""
110
+
111
+ decimals: int | None = None
112
+ """How many decimal places a reading is shown to, or None for as many as it has.
113
+
114
+ A device that measures to the millivolt and one that measures to the volt
115
+ both answer with a number; only the field knows which of them this is, and
116
+ a row that prints 3.2999999 has lost that.
117
+ """
118
+
119
+ default: Any = None
120
+ """What the device leaves the factory with, when the field says so.
121
+
122
+ Shown beside the value rather than written: a settings table that can say
123
+ "you have 2.8 V here, the default is 3.0 V" answers the question somebody
124
+ is actually asking before they change anything.
125
+ """
126
+
127
+ options: Mapping[Any, str] | Sequence[Any] | None = None
128
+ """The values allowed: a code-to-label mapping, or a plain sequence.
129
+
130
+ A code is whatever the device's own description calls it -- an integer in
131
+ a catalog written by hand, a decimal string in one read out of a vendor's
132
+ datasource -- so the key type is the caller's, like :attr:`address`.
133
+ """
134
+
135
+ words: tuple[str, str] = ("enabled", "disabled")
136
+ """What a :data:`FLAG` reads as when it is on and when it is off."""
137
+
138
+ help: str | None = None
139
+ """The command-line help, without the enumeration: that is added."""
140
+
141
+ metavar: str | None = None
142
+ """The command line's placeholder (``AMPS``, ``S``, ``URL``)."""
143
+
144
+ what: str | None = None
145
+ """How a complaint names the field: "the safe current" rather than "Safe current"."""
146
+
147
+ access: str = READ_WRITE
148
+
149
+ aliases: Mapping[str, Any] | None = None
150
+ """Words the command line takes instead of raw values, and what each means.
151
+
152
+ For a setting whose codes nobody should have to remember -- ``--mode rfid``
153
+ rather than ``--mode 2`` -- and for one that is a code on the wire but a
154
+ switch to a person, which is :data:`ON_OFF_CODES`. A :data:`FLAG` gets
155
+ ``on``/``off`` without asking.
156
+ """
157
+
158
+ render: Callable[[Any, Any], str | None] | None = None
159
+ """Print this one by hand, given ``(value, state)``. For composite rows."""
160
+
161
+ @property
162
+ def writable(self) -> bool:
163
+ """Whether this field may be written at all."""
164
+ return self.access == READ_WRITE
165
+
166
+ @property
167
+ def subject(self) -> str:
168
+ """How a complaint should name this field."""
169
+ return self.what or self.label or self.name
170
+
171
+ @property
172
+ def labels(self) -> Mapping[Any, str] | None:
173
+ """The code-to-label table, when the options carry one."""
174
+ return self.options if isinstance(self.options, Mapping) else None
175
+
176
+ @property
177
+ def domain(self) -> tuple[Any, ...] | None:
178
+ """Every value this field allows, or None when it is unrestricted."""
179
+ return None if self.options is None else tuple(self.options)
180
+
181
+
182
+ Specs = Sequence[FieldSpec]
183
+
184
+
185
+ def by_name(specs: Specs) -> dict[str, FieldSpec]:
186
+ """Index a table by field name."""
187
+ return {spec.name: spec for spec in specs}
188
+
189
+
190
+ def by_flag(specs: Specs) -> dict[str, FieldSpec]:
191
+ """Index the settable fields by the command-line flag that names them."""
192
+ return {spec.flag: spec for spec in specs if spec.flag and spec.writable}
193
+
194
+
195
+ # --- reading ----------------------------------------------------------------
196
+
197
+
198
+ def decode(spec: FieldSpec, raw: Any) -> Any:
199
+ """Turn what the device answered into the value the state object holds.
200
+
201
+ None for anything the device did not say, or said in a shape the field
202
+ cannot be: a station that answers an empty string for a current has not
203
+ reported 0 A, it has declined to answer, and the difference is the whole
204
+ point of the em dash a dashboard draws.
205
+ """
206
+ if raw is None or raw == "":
207
+ return None
208
+ if spec.kind == TEXT:
209
+ return str(raw)
210
+ try:
211
+ number = float(raw)
212
+ except (TypeError, ValueError):
213
+ return None
214
+ if spec.kind == NUMBER:
215
+ return number
216
+ if spec.kind == FLAG:
217
+ return bool(int(number))
218
+ return int(number)
219
+
220
+
221
+ def harvest(specs: Specs, read: Callable[[Any], Any]) -> dict[str, Any]:
222
+ """Decode every addressed field, given a way to look one up by address."""
223
+ return {
224
+ spec.name: decode(spec, read(spec.address))
225
+ for spec in specs
226
+ if spec.address is not None
227
+ }
228
+
229
+
230
+ # --- checking ---------------------------------------------------------------
231
+
232
+
233
+ def _enumerated(spec: FieldSpec) -> str:
234
+ """Name every value a field allows, the way a complaint should list them."""
235
+ labels = spec.labels
236
+ if labels is None:
237
+ return "one of " + ", ".join(str(value) for value in spec.domain or ())
238
+ return ", ".join(f"{value} ({label})" for value, label in sorted(labels.items()))
239
+
240
+
241
+ def _ranged(spec: FieldSpec, value: float) -> None:
242
+ """Raise unless ``value`` is inside the field's range."""
243
+ low, high = spec.minimum, spec.maximum
244
+ if low is not None and high is None and value < low:
245
+ if low == 0:
246
+ raise FieldError(f"{spec.subject} cannot be negative")
247
+ raise FieldError(f"{spec.subject} cannot be below {low:g}{_suffix(spec)}")
248
+ if high is not None and low is None and value > high:
249
+ raise FieldError(f"{spec.subject} cannot be above {high:g}{_suffix(spec)}")
250
+ if low is not None and high is not None and not low <= value <= high:
251
+ raise FieldError(
252
+ f"{spec.subject} must be between {low:g} and {high:g}{_suffix(spec)}"
253
+ )
254
+
255
+
256
+ def _suffix(spec: FieldSpec) -> str:
257
+ """Return the unit as it trails a number in a sentence."""
258
+ if not spec.unit:
259
+ return ""
260
+ return spec.unit if spec.unit == "%" else f" {spec.unit}"
261
+
262
+
263
+ def _coerce_text(spec: FieldSpec, value: Any) -> str:
264
+ """Check a string against the field's enumeration and its two lengths."""
265
+ text = str(value)
266
+ if spec.domain is not None and text not in spec.domain:
267
+ raise FieldError(f"{spec.subject} is {_enumerated(spec)}")
268
+ if spec.maximum is not None and len(text) > int(spec.maximum):
269
+ raise FieldError(f"{spec.subject} is at most {int(spec.maximum)} characters")
270
+ if spec.minimum is not None and len(text) < int(spec.minimum):
271
+ raise FieldError(f"{spec.subject} is at least {int(spec.minimum)} characters")
272
+ return text
273
+
274
+
275
+ def _coerce_number(spec: FieldSpec, value: Any) -> float:
276
+ """Read a number, refusing the two ways one can fail to be one."""
277
+ try:
278
+ number = float(value)
279
+ except (TypeError, ValueError):
280
+ raise FieldError(f"{spec.subject} must be a number") from None
281
+ if math.isnan(number):
282
+ raise FieldError(f"{spec.subject} must be a number")
283
+ return number
284
+
285
+
286
+ def _in_domain(spec: FieldSpec, code: int) -> int:
287
+ """Check a whole number against the field's enumeration."""
288
+ if spec.domain is not None and code not in spec.domain:
289
+ raise FieldError(f"{spec.subject} is {_enumerated(spec)}")
290
+ return code
291
+
292
+
293
+ def coerce(spec: FieldSpec, value: Any) -> Any:
294
+ """Return ``value`` as the device should be given it, or raise.
295
+
296
+ Every refusal a device would make silently is made here instead, in the
297
+ field's own words: out of range, outside the enumeration, too long.
298
+ """
299
+ if spec.kind == FLAG:
300
+ return int(bool(value))
301
+ if spec.kind == TEXT:
302
+ return _coerce_text(spec, value)
303
+ number = _coerce_number(spec, value)
304
+ if spec.kind == ENUM:
305
+ return _in_domain(spec, int(number))
306
+ if spec.kind == INTEGER:
307
+ whole = _in_domain(spec, int(number))
308
+ _ranged(spec, whole)
309
+ return whole
310
+ _ranged(spec, number)
311
+ return number
312
+
313
+
314
+ def values(specs: Specs, given: Mapping[str, Any]) -> dict[str, Any]:
315
+ """Check every named setting, dropping the ones that were left out.
316
+
317
+ ``given`` is whatever the caller was handed -- a namespace turned into a
318
+ dict, a JSON body already mapped onto field names -- with None meaning
319
+ "not named", which is how one flag out of twenty gets set without the
320
+ other nineteen being rewritten to what they already were.
321
+ """
322
+ table = by_name(specs)
323
+ out: dict[str, Any] = {}
324
+ for name, value in given.items():
325
+ if value is None:
326
+ continue
327
+ spec = table.get(name)
328
+ if spec is None:
329
+ raise FieldError(f"there is no {name!r} setting here")
330
+ if not spec.writable:
331
+ raise FieldError(f"{spec.subject} is read-only")
332
+ out[name] = coerce(spec, value)
333
+ return out
334
+
335
+
336
+ def writes(
337
+ specs: Specs,
338
+ checked: Mapping[str, Any],
339
+ *,
340
+ wire: Callable[[FieldSpec], Any] | None = None,
341
+ ) -> dict[Any, tuple[Any, Any]]:
342
+ """Turn checked values into ``address -> (value, wire type)`` pairs.
343
+
344
+ ``wire`` overrides where the encoding comes from, for a device that
345
+ reports each field's type itself rather than declaring it in a catalog.
346
+ """
347
+ table = by_name(specs)
348
+ out: dict[Any, tuple[Any, Any]] = {}
349
+ for name, value in checked.items():
350
+ spec = table[name]
351
+ if spec.address is None:
352
+ raise FieldError(f"{spec.subject} has no address to write to")
353
+ out[spec.address] = (value, spec.wire if wire is None else wire(spec))
354
+ return out
355
+
356
+
357
+ # --- showing ----------------------------------------------------------------
358
+
359
+
360
+ def amount(spec: FieldSpec, value: float) -> str:
361
+ """Render a number with its unit, the way a row carries it."""
362
+ text = f"{value:g}" if isinstance(value, float) else f"{value}"
363
+ return f"{text}{_suffix(spec)}"
364
+
365
+
366
+ def display(spec: FieldSpec, value: Any, state: Any = None) -> str | None:
367
+ """Render one value for a terminal, or None when there is nothing to show."""
368
+ if spec.render is not None:
369
+ return spec.render(value, state)
370
+ if value is None or value == "":
371
+ return None
372
+ if spec.kind == FLAG:
373
+ return spec.words[0] if value else spec.words[1]
374
+ if spec.kind == TEXT:
375
+ return str(value)
376
+ labels = spec.labels
377
+ if labels is not None:
378
+ code = int(value)
379
+ return labels.get(code, f"unknown ({code})")
380
+ return amount(spec, value)
381
+
382
+
383
+ def rows(specs: Specs, state: Any) -> list[tuple[str, str]]:
384
+ """Render the labelled fields as label/value pairs, skipping what is absent."""
385
+ out: list[tuple[str, str]] = []
386
+ for spec in specs:
387
+ if spec.label is None:
388
+ continue
389
+ shown = display(spec, getattr(state, spec.name, None), state)
390
+ if shown is not None:
391
+ out.append((spec.label, shown))
392
+ return out
393
+
394
+
395
+ def document(specs: Specs, state: Any) -> dict[str, Any]:
396
+ """Render the serialised fields as a document, absences included.
397
+
398
+ A field the device did not answer stays in as ``null`` rather than
399
+ dropping out, so a page can draw a steady row of fields with an em dash
400
+ where the answer is missing instead of changing shape between polls.
401
+ """
402
+ return {
403
+ spec.json: getattr(state, spec.name, None)
404
+ for spec in specs
405
+ if spec.json is not None
406
+ }
407
+
408
+
409
+ def option_tables(specs: Specs) -> dict[str, Any]:
410
+ """Render every field's allowed values, keyed by its JSON name.
411
+
412
+ A mapping becomes an object keyed by the code as a string, because JSON
413
+ has no integer keys; a plain sequence stays a list.
414
+ """
415
+ out: dict[str, Any] = {}
416
+ for spec in specs:
417
+ if spec.json is None or spec.options is None:
418
+ continue
419
+ labels = spec.labels
420
+ out[spec.json] = (
421
+ {str(code): label for code, label in labels.items()}
422
+ if labels is not None
423
+ else list(spec.options) # type: ignore[arg-type]
424
+ )
425
+ return out
426
+
427
+
428
+ def bounds(specs: Specs) -> dict[str, dict[str, float]]:
429
+ """Render every field's range, keyed by its JSON name."""
430
+ out: dict[str, dict[str, float]] = {}
431
+ for spec in specs:
432
+ if spec.json is None or spec.kind not in (NUMBER, INTEGER):
433
+ continue
434
+ limits = {
435
+ side: value
436
+ for side, value in (("min", spec.minimum), ("max", spec.maximum))
437
+ if value is not None
438
+ }
439
+ if limits:
440
+ out[spec.json] = limits
441
+ return out
442
+
443
+
444
+ # --- the command line -------------------------------------------------------
445
+
446
+
447
+ def help_text(spec: FieldSpec) -> str | None:
448
+ """Return a flag's help, with the enumeration appended when it takes a code."""
449
+ said = spec.help
450
+ if spec.labels is None or spec.aliases is not None:
451
+ return said
452
+ listed = _enumerated(spec)
453
+ return f"{said}: {listed}" if said else listed
454
+
455
+
456
+ def argument(spec: FieldSpec) -> dict[str, Any]:
457
+ """Return the ``add_argument`` keywords one field asks for."""
458
+ kwargs: dict[str, Any] = {"dest": spec.name, "help": help_text(spec)}
459
+ if spec.aliases is not None:
460
+ kwargs["choices"] = tuple(spec.aliases)
461
+ return kwargs
462
+ if spec.kind == FLAG:
463
+ kwargs["choices"] = ON_OFF
464
+ return kwargs
465
+ if spec.kind == TEXT:
466
+ if spec.domain is not None:
467
+ kwargs["choices"] = tuple(spec.domain)
468
+ else:
469
+ kwargs["metavar"] = spec.metavar
470
+ return kwargs
471
+ kwargs["type"] = float if spec.kind == NUMBER else int
472
+ if spec.kind != ENUM and spec.domain is not None:
473
+ kwargs["choices"] = tuple(spec.domain)
474
+ else:
475
+ kwargs["metavar"] = spec.metavar or "N"
476
+ return kwargs
477
+
478
+
479
+ def add_arguments(specs: Specs, parser: Any) -> None:
480
+ """Add a flag for every settable field that has one."""
481
+ for spec in specs:
482
+ if spec.flag is None or not spec.writable:
483
+ continue
484
+ parser.add_argument(spec.flag, **argument(spec))
485
+
486
+
487
+ def from_namespace(specs: Specs, args: Any) -> dict[str, Any]:
488
+ """Collect the settings a parsed command line named, and check them.
489
+
490
+ A word the flag takes comes back as the value it stands for, so a field
491
+ that is a code on the wire and a switch -- or a name -- on the command line
492
+ is spelled once.
493
+ """
494
+ given: dict[str, Any] = {}
495
+ for spec in specs:
496
+ if spec.flag is None or not spec.writable:
497
+ continue
498
+ value = getattr(args, spec.name, None)
499
+ if value is None:
500
+ continue
501
+ if spec.aliases is not None:
502
+ given[spec.name] = spec.aliases[value]
503
+ elif spec.kind == FLAG:
504
+ given[spec.name] = value == ON_OFF[0]
505
+ else:
506
+ given[spec.name] = value
507
+ return values(specs, given)
508
+
509
+
510
+ def from_document(specs: Specs, doc: Mapping[str, Any]) -> dict[str, Any]:
511
+ """Collect the settings a JSON body named, by their JSON keys, and check them.
512
+
513
+ A key the group does not define is refused rather than ignored: a page
514
+ sending ``safeCurrent`` where the field is ``safeCurrentA`` should be told
515
+ so, not left wondering why nothing changed.
516
+ """
517
+ known = {spec.json: spec for spec in specs if spec.json is not None}
518
+ given: dict[str, Any] = {}
519
+ for key, value in doc.items():
520
+ spec = known.get(key)
521
+ if spec is None:
522
+ raise FieldError(f"there is no {key!r} setting here")
523
+ if value is not None:
524
+ given[spec.name] = value
525
+ return values(specs, given)
526
+
527
+
528
+ def settable(specs: Specs) -> Iterable[FieldSpec]:
529
+ """Every field a caller may write."""
530
+ return (spec for spec in specs if spec.writable)
531
+
532
+
533
+ __all__ = [
534
+ "ENUM",
535
+ "FLAG",
536
+ "INTEGER",
537
+ "NUMBER",
538
+ "ON_OFF",
539
+ "ON_OFF_CODES",
540
+ "READ_ONLY",
541
+ "READ_WRITE",
542
+ "TEXT",
543
+ "FieldError",
544
+ "FieldSpec",
545
+ "add_arguments",
546
+ "amount",
547
+ "argument",
548
+ "bounds",
549
+ "by_flag",
550
+ "by_name",
551
+ "coerce",
552
+ "decode",
553
+ "display",
554
+ "document",
555
+ "from_document",
556
+ "from_namespace",
557
+ "harvest",
558
+ "help_text",
559
+ "option_tables",
560
+ "rows",
561
+ "settable",
562
+ "values",
563
+ "writes",
564
+ ]
devicectl/meta.py ADDED
@@ -0,0 +1,64 @@
1
+ """Where a program's own URLs come from: the packaging metadata it ships with.
2
+
3
+ A page that links to the project's website, to the release notes beside its
4
+ version number and to the licence it is under needs three URLs. Written into
5
+ the JavaScript they are three string constants per program, repeated in the
6
+ README, in the server's start-up banner and in whatever else grows a link --
7
+ and every one of them is a rename away from pointing at nothing.
8
+
9
+ They are already written down once, in ``[project.urls]``:
10
+
11
+ .. code-block:: toml
12
+
13
+ [project.urls]
14
+ Homepage = "https://github.com/pbasista/jkctl"
15
+ Releases = "https://github.com/pbasista/jkctl/releases"
16
+ License = "https://github.com/pbasista/jkctl/blob/main/LICENSE"
17
+
18
+ which the build back end writes into the installed distribution as
19
+ ``Project-URL`` headers. This reads them back. Labels are matched loosely --
20
+ case, spaces and punctuation are ignored -- because the ones PyPI recognises
21
+ are spelled a dozen ways ("Bug Tracker", "bug-tracker") and nothing enforces
22
+ any of them.
23
+
24
+ A checkout that was never installed has no metadata at all, and a lookup that
25
+ finds nothing returns nothing rather than raising: a missing link is a
26
+ wordmark that is not clickable, which is not a reason to refuse to serve the
27
+ page.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from importlib.metadata import PackageNotFoundError, metadata
34
+
35
+ # Everything but letters and digits: "Bug Tracker", "bug-tracker" and
36
+ # "bugtracker" are one label.
37
+ _NOT_ALNUM = re.compile(r"[^a-z0-9]+")
38
+
39
+
40
+ def _key(label: str) -> str:
41
+ """Return the label a URL is filed under, ignoring case and punctuation."""
42
+ return _NOT_ALNUM.sub("", label.strip().lower())
43
+
44
+
45
+ def project_links(distribution: str) -> dict[str, str]:
46
+ """Return ``[project.urls]`` for an installed distribution, by loose label.
47
+
48
+ ``project_links("jkctl")["homepage"]`` is the ``Homepage`` line of its
49
+ ``pyproject.toml``. An uninstalled or unknown distribution gives ``{}``.
50
+ """
51
+ try:
52
+ info = metadata(distribution)
53
+ except PackageNotFoundError:
54
+ return {}
55
+ links: dict[str, str] = {}
56
+ for entry in info.get_all("Project-URL") or ():
57
+ label, _, url = str(entry).partition(",")
58
+ url = url.strip()
59
+ if url:
60
+ links.setdefault(_key(label), url)
61
+ return links
62
+
63
+
64
+ __all__ = ["project_links"]
devicectl/paths.py ADDED
@@ -0,0 +1,40 @@
1
+ """Where a program keeps its per-user files, chosen the same way everywhere.
2
+
3
+ A device tool has a configuration file, and often a cached token or a small
4
+ state file beside it. Where those live is not the program's own decision to
5
+ make twice: it is the platform's convention, and the two programs here had
6
+ each written the same resolution out under their own directory name -- close
7
+ enough to drift (one docstring named macOS, the other did not) and identical
8
+ enough to share.
9
+
10
+ The directory name is the one thing that is the program's: :func:`config_dir`
11
+ takes it and applies the convention.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+
20
+
21
+ def config_dir(name: str) -> Path:
22
+ """Return the directory a program named ``name`` keeps its config in.
23
+
24
+ ``XDG_CONFIG_HOME`` wins wherever it is set, so a dotfiles setup that
25
+ exports it keeps working. Otherwise Windows uses ``%APPDATA%``, where
26
+ Windows programs keep per-user settings; everywhere else uses
27
+ ``~/.config``, which is both the XDG default and where command-line
28
+ tools put themselves on macOS.
29
+ """
30
+ xdg = os.environ.get("XDG_CONFIG_HOME")
31
+ if xdg:
32
+ return Path(xdg).expanduser() / name
33
+ if sys.platform == "win32":
34
+ appdata = os.environ.get("APPDATA")
35
+ if appdata:
36
+ return Path(appdata) / name
37
+ return Path.home() / ".config" / name
38
+
39
+
40
+ __all__ = ["config_dir"]