letify 1.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.
Files changed (55) hide show
  1. letify/__init__.py +82 -0
  2. letify/cli.py +281 -0
  3. letify/config/__init__.py +129 -0
  4. letify/config/inventory.py +153 -0
  5. letify/config/login.py +428 -0
  6. letify/config/schema.py +52 -0
  7. letify/config/secrets.py +50 -0
  8. letify/config/writer.py +150 -0
  9. letify/declare/__init__.py +20 -0
  10. letify/declare/env.py +94 -0
  11. letify/declare/function.py +299 -0
  12. letify/declare/instance.py +151 -0
  13. letify/declare/sweep.py +87 -0
  14. letify/errors.py +92 -0
  15. letify/launcher.py +445 -0
  16. letify/protocol/__init__.py +64 -0
  17. letify/protocol/codec.py +152 -0
  18. letify/protocol/driver.py +98 -0
  19. letify/protocol/framing.py +50 -0
  20. letify/protocol/guards.py +44 -0
  21. letify/protocol/handle.py +71 -0
  22. letify/protocol/worker.py +288 -0
  23. letify/providers/__init__.py +56 -0
  24. letify/providers/base.py +427 -0
  25. letify/providers/colab.py +227 -0
  26. letify/providers/elice.py +246 -0
  27. letify/providers/local.py +142 -0
  28. letify/providers/modal.py +215 -0
  29. letify/providers/naming.py +47 -0
  30. letify/providers/shell.py +190 -0
  31. letify/providers/tunnel.py +156 -0
  32. letify/providers/usage.py +131 -0
  33. letify/remoting/__init__.py +28 -0
  34. letify/remoting/capability.py +50 -0
  35. letify/remoting/loader.py +96 -0
  36. letify/remoting/probe.py +127 -0
  37. letify/runtime/__init__.py +24 -0
  38. letify/runtime/bootstrap.py +58 -0
  39. letify/runtime/channel.py +324 -0
  40. letify/runtime/lease.py +70 -0
  41. letify/runtime/pool.py +236 -0
  42. letify/runtime/session.py +298 -0
  43. letify/runtime/telemetry.py +220 -0
  44. letify/store/__init__.py +27 -0
  45. letify/store/backends/__init__.py +60 -0
  46. letify/store/backends/filesystem.py +70 -0
  47. letify/store/backends/layout.py +27 -0
  48. letify/store/backends/objects.py +209 -0
  49. letify/store/cas.py +177 -0
  50. letify/store/volume.py +199 -0
  51. letify-1.0.0.dist-info/METADATA +505 -0
  52. letify-1.0.0.dist-info/RECORD +55 -0
  53. letify-1.0.0.dist-info/WHEEL +4 -0
  54. letify-1.0.0.dist-info/entry_points.txt +2 -0
  55. letify-1.0.0.dist-info/licenses/LICENSE +201 -0
letify/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """letify: declarations that become infrastructure.
2
+
3
+ Declare what a function needs and it runs there:
4
+
5
+ import letify
6
+
7
+ let = letify.Launcher()
8
+ colab = let.providers.colab_a
9
+
10
+ @let.function(device=colab.G4, host=letify.Host.remote)
11
+ def train(lr, bs):
12
+ ...
13
+
14
+ train(lr=1e-4, bs=32)
15
+
16
+ A declaration places two things. ``device`` says where the device is, carrying the provider
17
+ and the account with it. ``host`` says where the host code runs: ``local``, the default,
18
+ keeps Python here and forwards only CUDA calls, and ``remote`` ships the function to the
19
+ machine that holds the device.
20
+
21
+ A session ends with the call that needed it. ``lifetime="process"`` keeps it, so a run of
22
+ separate calls does not pay session start each time. Nothing has to be torn down by hand.
23
+
24
+ Nothing here imports a provider's optional dependency, so ``import letify`` works with
25
+ the base install and a provider whose package is missing reports itself unavailable.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from .declare.env import Env
31
+ from .declare.function import Function
32
+ from .declare.instance import AnyInstance, Host, Instance, Lifetime
33
+ from .declare.sweep import Sweep, grid
34
+ from .declare.sweep import zip_ as zip
35
+ from .errors import (
36
+ ConfigError,
37
+ HandleScopeError,
38
+ LetifyError,
39
+ ProtocolError,
40
+ ProviderUnavailable,
41
+ RemoteError,
42
+ RuntimeFailure,
43
+ RuntimeLost,
44
+ UnknownInstance,
45
+ UnknownProvider,
46
+ UnsupportedMode,
47
+ )
48
+ from .launcher import Launcher, Providers
49
+ from .protocol.handle import Blob, Handle, RemoteFile
50
+ from .store.volume import Volume
51
+
52
+ __version__ = "1.0.0"
53
+
54
+ __all__ = [
55
+ "AnyInstance",
56
+ "Blob",
57
+ "ConfigError",
58
+ "Env",
59
+ "Function",
60
+ "Handle",
61
+ "HandleScopeError",
62
+ "Host",
63
+ "Instance",
64
+ "Launcher",
65
+ "LetifyError",
66
+ "Lifetime",
67
+ "ProtocolError",
68
+ "ProviderUnavailable",
69
+ "Providers",
70
+ "RemoteError",
71
+ "RemoteFile",
72
+ "RuntimeFailure",
73
+ "RuntimeLost",
74
+ "Sweep",
75
+ "UnknownInstance",
76
+ "UnknownProvider",
77
+ "UnsupportedMode",
78
+ "Volume",
79
+ "__version__",
80
+ "grid",
81
+ "zip",
82
+ ]
letify/cli.py ADDED
@@ -0,0 +1,281 @@
1
+ """Command line entry points.
2
+
3
+ Enough to answer what comes up before any code is written: which providers are declared,
4
+ what they offer, what is running right now, whether a machine answers, and whether
5
+ forwarding CUDA calls to it is worth doing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+
14
+ from . import __version__
15
+ from .config import login
16
+ from .errors import LetifyError
17
+ from .launcher import Launcher
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="letify", description="Declarations that become infrastructure."
23
+ )
24
+ parser.add_argument("--version", action="version", version=f"letify {__version__}")
25
+ parser.add_argument("--config", help="path to a .letify file")
26
+ sub = parser.add_subparsers(dest="command", required=True)
27
+
28
+ sub.add_parser("providers", help="list declared providers and their storage")
29
+ sub.add_parser("devices", help="list the accelerators each provider offers")
30
+ sub.add_parser("status", help="show live runtimes and what they are costing")
31
+
32
+ usage = sub.add_parser("usage", help="show what each account has left")
33
+ usage.add_argument("alias", nargs="?", help="one provider instead of all of them")
34
+ usage.add_argument("--json", action="store_true", help="print the records unformatted")
35
+
36
+ utilization = sub.add_parser(
37
+ "utilization", help="show how hard each instance's accelerator is working"
38
+ )
39
+ utilization.add_argument("alias", nargs="?", help="one provider instead of all of them")
40
+ utilization.add_argument("--json", action="store_true", help="print the records unformatted")
41
+
42
+ log_in = sub.add_parser("login", help="declare an account and reference it here")
43
+ log_in.add_argument("kind", help="provider kind: shell, tunnel, colab, modal, elice")
44
+ log_in.add_argument("alias", nargs="?", help="name to reach it by; defaults to the kind")
45
+ log_in.add_argument("--address", help="machine address, for shell and tunnel")
46
+ log_in.add_argument("--user", help="SSH user")
47
+ log_in.add_argument("--port", type=int, help="SSH port")
48
+ log_in.add_argument("--key", help="SSH private key path")
49
+ log_in.add_argument(
50
+ "--auth",
51
+ choices=login.AUTH_METHODS,
52
+ help="how to authenticate; key is the default and the only one that works unattended",
53
+ )
54
+ log_in.add_argument(
55
+ "--persistent",
56
+ action="store_true",
57
+ default=None,
58
+ help="the machine keeps its disk between sessions",
59
+ )
60
+ log_in.add_argument("--zone-id", dest="zone_id", help="Elice zone id")
61
+ log_in.add_argument("--machine-id", dest="machine_id", help="Elice machine id")
62
+ log_in.add_argument("--endpoint", help="API endpoint, where it is not the default")
63
+ log_in.add_argument("--account", help="account email, for Colab")
64
+ log_in.add_argument("--workspace", help="workspace name, for Modal")
65
+ log_in.add_argument(
66
+ "--token", help="credential to file in the OS keyring rather than in any file"
67
+ )
68
+ log_in.add_argument(
69
+ "--no-input",
70
+ dest="interactive",
71
+ action="store_false",
72
+ help="fail rather than prompt, for a script",
73
+ )
74
+ log_in.add_argument(
75
+ "--skip-key-install",
76
+ dest="install_key",
77
+ action="store_false",
78
+ help="the key is already on the machine, so only confirm it",
79
+ )
80
+
81
+ log_out = sub.add_parser("logout", help="remove an account from this machine")
82
+ log_out.add_argument("alias", help="provider alias to forget")
83
+
84
+ check = sub.add_parser("check", help="check that a provider answers")
85
+ check.add_argument("alias", help="provider alias from the configuration")
86
+
87
+ probe = sub.add_parser("probe", help="measure whether host='local' is worth using")
88
+ probe.add_argument("host", nargs="?", help="host name to measure the round trip to")
89
+
90
+ efficiency = sub.add_parser(
91
+ "efficiency", help="expected fraction of a direct run, from measured terms"
92
+ )
93
+ efficiency.add_argument("step_seconds", type=float, help="GPU time per step")
94
+ efficiency.add_argument("syncs", type=int, help="host synchronizations per step")
95
+ efficiency.add_argument("round_trip_ms", type=float, help="network round trip")
96
+
97
+ return parser
98
+
99
+
100
+ def _describe_usage(row: dict) -> str:
101
+ """One line for a usage row, saying plainly when there is no number."""
102
+ if row.get("unmetered"):
103
+ return "unmetered"
104
+ unit = row.get("unit") or ""
105
+ parts = []
106
+ if row.get("remaining") is not None:
107
+ left = f"{row['remaining']:g} {unit} left"
108
+ if row.get("limit"):
109
+ left += f" of {row['limit']:g}"
110
+ parts.append(left)
111
+ if row.get("rate_per_hour") is not None:
112
+ parts.append(f"{row['rate_per_hour']:g} {unit}/hour running now")
113
+ return ", ".join(parts) or f"not reported ({row.get('source')})"
114
+
115
+
116
+ def _describe_device(device: dict) -> str:
117
+ """One line for a device reading, leaving out what the card did not report."""
118
+ load = (
119
+ f"{device['utilization_percent']:.0f}% busy"
120
+ if device.get("utilization_percent") is not None
121
+ else "load unknown"
122
+ )
123
+ parts = [f"gpu{device['index']}", str(device["name"]), load]
124
+ if device.get("memory_total_gb"):
125
+ used = device.get("memory_used_gb") or 0.0
126
+ parts.append(f"{used:.1f}/{device['memory_total_gb']:.1f} GiB")
127
+ if device.get("temperature_c") is not None:
128
+ parts.append(f"{device['temperature_c']:.0f}C")
129
+ if device.get("power_w") is not None:
130
+ parts.append(f"{device['power_w']:.0f}W")
131
+ return " ".join(parts)
132
+
133
+
134
+ def main(argv: list[str] | None = None) -> int:
135
+ args = build_parser().parse_args(argv)
136
+
137
+ if args.command == "efficiency":
138
+ from .remoting import efficiency as compute
139
+
140
+ share = compute(args.step_seconds, args.syncs, args.round_trip_ms)
141
+ print(f"{share * 100:.1f}% of a direct run")
142
+ return 0
143
+
144
+ if args.command == "login":
145
+ alias = args.alias or args.kind
146
+ answers = login.Answers(
147
+ alias=alias,
148
+ kind=args.kind,
149
+ values={
150
+ "address": args.address,
151
+ "user": args.user,
152
+ "port": args.port,
153
+ "key": args.key,
154
+ "auth": args.auth,
155
+ "persistent": args.persistent,
156
+ "zone_id": args.zone_id,
157
+ "machine_id": args.machine_id,
158
+ "endpoint": args.endpoint,
159
+ "account": args.account,
160
+ "workspace": args.workspace,
161
+ },
162
+ token=args.token,
163
+ interactive=args.interactive,
164
+ install_key=args.install_key,
165
+ )
166
+ try:
167
+ fresh, home, project = login.log_in(answers, project=args.config)
168
+ except LetifyError as exc:
169
+ print(exc, file=sys.stderr)
170
+ return 1
171
+ if fresh:
172
+ print(f"{alias} declared in {home}")
173
+ else:
174
+ print(f"{alias} was already declared in {home}, so nothing was asked for")
175
+ print(f"{alias} referenced in {project}, which is safe to commit")
176
+ return 0
177
+
178
+ if args.command == "logout":
179
+ removed, forgotten = login.log_out(args.alias)
180
+ if not removed:
181
+ print(
182
+ f"{args.alias} is not declared in {login.home_path()}",
183
+ file=sys.stderr,
184
+ )
185
+ return 1
186
+ detail = " and its keyring entry" if forgotten else ""
187
+ print(f"{args.alias} removed from {login.home_path()}{detail}")
188
+ print("The project reference is left alone, because this repository still needs it")
189
+ return 0
190
+
191
+ let = Launcher(args.config, announce=False)
192
+
193
+ if args.command == "providers":
194
+ for alias in let.config.order:
195
+ try:
196
+ provider = let.provider(alias)
197
+ except Exception as exc:
198
+ print(f"{alias:20} unavailable: {exc}")
199
+ continue
200
+ row = f"{alias:20} {provider.kind:10} {provider.persistence:11}"
201
+ channel = "persistent" if provider.persistent_channel else "one-shot"
202
+ print(f"{row} channel={channel}")
203
+ return 0
204
+
205
+ if args.command == "devices":
206
+ print(json.dumps(let.providers.devices, indent=2, sort_keys=True))
207
+ return 0
208
+
209
+ if args.command == "status":
210
+ print(json.dumps(let.status(), indent=2))
211
+ return 0
212
+
213
+ if args.command == "usage":
214
+ rows = let.usage(args.alias)
215
+ if args.json:
216
+ print(json.dumps(rows, indent=2))
217
+ return 0
218
+ for row in rows:
219
+ alias = str(row["alias"])
220
+ if "unavailable" in row:
221
+ print(f"{alias:20} unavailable: {row['unavailable']}")
222
+ continue
223
+ line = f"{alias:20} {row['kind']!s:10} {_describe_usage(row)}"
224
+ print(line)
225
+ if row.get("note"):
226
+ print(f"{'':20} {row['note']}")
227
+ return 0
228
+
229
+ if args.command == "utilization":
230
+ rows = let.utilization(args.alias)
231
+ if args.json:
232
+ print(json.dumps(rows, indent=2))
233
+ return 0
234
+ for row in rows:
235
+ alias = str(row["alias"])
236
+ if "unavailable" in row:
237
+ print(f"{alias:20} unavailable: {row['unavailable']}")
238
+ continue
239
+ head = f"{alias}.{row['accelerator']}"
240
+ devices = row.get("devices") or []
241
+ if not devices:
242
+ print(f"{head:28} {row.get('reason') or 'nothing reported'}")
243
+ continue
244
+ for device in devices:
245
+ print(f"{head:28} {_describe_device(device)}")
246
+ return 0
247
+
248
+ if args.command == "check":
249
+ provider = let.provider(args.alias)
250
+ checker = getattr(provider, "check", None)
251
+ if checker is None:
252
+ print(f"{args.alias} has no check step", file=sys.stderr)
253
+ return 1
254
+ print(checker())
255
+ return 0
256
+
257
+ if args.command == "probe":
258
+ from .remoting import probe as run_probe
259
+
260
+ capability = run_probe(args.host)
261
+ print(
262
+ json.dumps(
263
+ {
264
+ "platform": capability.platform,
265
+ "core": capability.core,
266
+ "agent": capability.agent,
267
+ "round_trip_ms": capability.round_trip_ms,
268
+ "usable": capability.usable,
269
+ "costly": capability.costly,
270
+ "reason": capability.explain(),
271
+ },
272
+ indent=2,
273
+ )
274
+ )
275
+ return 0
276
+
277
+ return 1
278
+
279
+
280
+ if __name__ == "__main__":
281
+ raise SystemExit(main())
@@ -0,0 +1,129 @@
1
+ """Reading ``.letify``.
2
+
3
+ Two files are merged. ``~/.letify`` holds accounts and connection details, which
4
+ belong to the machine, and the project's ``.letify`` holds defaults that are safe to
5
+ commit. The project file refines what the home file declared, so a repository can be
6
+ cloned by someone else and run under their own accounts.
7
+
8
+ Credentials are never read from the file itself. A field names an environment
9
+ variable or a keyring entry, and the value is resolved when it is used.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import tomllib
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ..errors import ConfigError
19
+ from . import inventory, login, writer
20
+ from .schema import RESERVED_ALIASES, Config, ProviderConfig
21
+ from .secrets import from_keyring, resolve_secret
22
+
23
+ CONFIG_NAME = ".letify"
24
+
25
+ #: A project entry carrying this and nothing else is a reference to an account in the home
26
+ #: file, written by ``letify login``. It is not a connection detail and never reaches a
27
+ #: provider.
28
+ REFERENCE_FIELD = "from_home"
29
+
30
+
31
+ def load(path: str | Path | None = None, *, home: bool = True) -> Config:
32
+ """Read the configuration files and merge them.
33
+
34
+ ``path`` overrides the project file. Set ``home`` to false to ignore ``~/.letify``,
35
+ which is what tests do to stay isolated from the developer's own accounts.
36
+ """
37
+ config = Config()
38
+ files: list[Path] = []
39
+ if home:
40
+ files.append(Path.home() / CONFIG_NAME)
41
+ files.append(Path(path) if path else Path.cwd() / CONFIG_NAME)
42
+
43
+ counter = 0
44
+ # Aliases the project file expects to find in the home file, so a reference to an
45
+ # account this machine does not have can name the command that fixes it.
46
+ referenced: dict[str, tuple[str, Path]] = {}
47
+ declared: set[str] = set()
48
+ home_file = files[0] if home else None
49
+ for file in files:
50
+ if not file.is_file():
51
+ continue
52
+ config.sources.append(file)
53
+ raw = _parse(file)
54
+
55
+ defaults = raw.pop("defaults", None)
56
+ if isinstance(defaults, dict):
57
+ config.defaults.update(defaults)
58
+
59
+ for alias, body in raw.items():
60
+ if not isinstance(body, dict):
61
+ continue
62
+ _check_alias(alias, file)
63
+ kind = body.get("kind")
64
+ if not isinstance(kind, str):
65
+ raise ConfigError(f"{file}: provider {alias!r} has no 'kind' field")
66
+ options = {
67
+ key: value for key, value in body.items() if key not in ("kind", REFERENCE_FIELD)
68
+ }
69
+ if body.get(REFERENCE_FIELD) is True:
70
+ referenced.setdefault(alias, (kind, file))
71
+ if file == home_file:
72
+ declared.add(alias)
73
+ existing = config.providers.get(alias)
74
+ if existing is None:
75
+ config.providers[alias] = ProviderConfig(alias, kind, options, counter)
76
+ counter += 1
77
+ else:
78
+ # The project file refines what the home file declared.
79
+ existing.kind = kind
80
+ existing.options.update(options)
81
+
82
+ for alias, (kind, file) in referenced.items():
83
+ if alias in declared:
84
+ continue
85
+ raise ConfigError(
86
+ f"{file}: {alias!r} refers to an account in ~/.letify that is not there. "
87
+ f"Run 'letify login {kind} {alias}' to declare it on this machine."
88
+ )
89
+
90
+ # The local machine is always available and needs no declaration.
91
+ if "local" not in config.providers:
92
+ config.providers["local"] = ProviderConfig("local", "local", {}, counter)
93
+ return config
94
+
95
+
96
+ def _parse(file: Path) -> dict[str, Any]:
97
+ try:
98
+ return tomllib.loads(file.read_text(encoding="utf-8"))
99
+ except tomllib.TOMLDecodeError as exc:
100
+ raise ConfigError(f"{file} is not valid TOML: {exc}") from exc
101
+
102
+
103
+ def _check_alias(alias: str, file: Path) -> None:
104
+ if alias in RESERVED_ALIASES:
105
+ raise ConfigError(
106
+ f"{file}: {alias!r} is reserved. Pick another alias, because "
107
+ f"let.providers.{alias} already means something else."
108
+ )
109
+ if not alias.isidentifier():
110
+ hint = f" Try {alias.replace('-', '_')!r}." if "-" in alias else ""
111
+ raise ConfigError(
112
+ f"{file}: alias {alias!r} is not a Python identifier, so "
113
+ f"let.providers.{alias} cannot work.{hint}"
114
+ )
115
+
116
+
117
+ __all__ = [
118
+ "CONFIG_NAME",
119
+ "REFERENCE_FIELD",
120
+ "RESERVED_ALIASES",
121
+ "Config",
122
+ "ProviderConfig",
123
+ "from_keyring",
124
+ "inventory",
125
+ "load",
126
+ "login",
127
+ "resolve_secret",
128
+ "writer",
129
+ ]
@@ -0,0 +1,153 @@
1
+ """What accelerators a provider account has, and how many of each.
2
+
3
+ This is the only thing that bounds how much letify runs at once. Three facts force that,
4
+ and no single number on the launcher can express any of them.
5
+
6
+ A Colab account's available accelerators depend on its tier and its compute unit balance,
7
+ so the kinds are per account and they change without letify being told.
8
+
9
+ A shared department machine holds several cards in one box, and which indices are free
10
+ moves with whoever else is logged in. So an entry names the indices it may use and letify
11
+ takes only those that are actually free when a session starts.
12
+
13
+ A run can take more than one card. On a four card machine, a run taking two is two
14
+ concurrent sessions rather than four, which a number bounding sessions cannot say.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ from ..errors import ConfigError
23
+
24
+
25
+ def read_indices(value: Any) -> tuple[int, ...]:
26
+ """Read declared device indices, as ``"0-3"``, ``"0-1,6-7"`` or ``[0, 1, 6]``.
27
+
28
+ A range because that is how a shared box is described by whoever hands it out: cards
29
+ zero through three are yours. Returned sorted and without duplicates, because the order
30
+ a user wrote them in is not a preference letify can honour once cards are busy.
31
+ """
32
+ if isinstance(value, (list, tuple)):
33
+ found = []
34
+ for item in value:
35
+ if isinstance(item, bool) or not isinstance(item, int):
36
+ raise ConfigError(f"device indices must be whole numbers, not {item!r}")
37
+ found.append(item)
38
+ return _checked(found, value)
39
+
40
+ if not isinstance(value, str) or not value.strip():
41
+ raise ConfigError(
42
+ f"device indices must be a range such as '0-3' or a list such as [0, 1], not {value!r}"
43
+ )
44
+
45
+ found = []
46
+ for part in value.split(","):
47
+ piece = part.strip()
48
+ if not piece:
49
+ raise ConfigError(f"device indices {value!r} has an empty entry")
50
+ if "-" in piece:
51
+ low, _, high = piece.partition("-")
52
+ if not low.strip().isdigit() or not high.strip().isdigit():
53
+ raise ConfigError(f"device indices {value!r} is not a range of numbers")
54
+ first, last = int(low), int(high)
55
+ if last < first:
56
+ raise ConfigError(f"device indices {value!r} counts backwards")
57
+ found.extend(range(first, last + 1))
58
+ elif piece.isdigit():
59
+ found.append(int(piece))
60
+ else:
61
+ raise ConfigError(f"device indices {value!r} is not a number or a range")
62
+ return _checked(found, value)
63
+
64
+
65
+ def _checked(found: list[int], original: Any) -> tuple[int, ...]:
66
+ if not found:
67
+ raise ConfigError(f"device indices {original!r} names none")
68
+ if any(index < 0 for index in found):
69
+ raise ConfigError(f"device indices {original!r} has a negative index")
70
+ return tuple(sorted(set(found)))
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class Devices:
75
+ """How many of one accelerator an account has, and which indices if it chooses them."""
76
+
77
+ accelerator: str
78
+ count: int = 1
79
+ indices: tuple[int, ...] = ()
80
+
81
+ @property
82
+ def chooses_indices(self) -> bool:
83
+ """Whether letify picks the physical device, rather than the provider assigning it."""
84
+ return bool(self.indices)
85
+
86
+ @classmethod
87
+ def read(cls, accelerator: str, body: Any) -> Devices:
88
+ """Read one entry of a ``devices`` table.
89
+
90
+ ``indices`` alone gives the count. ``count`` alone is a provider that assigns the
91
+ device itself. Neither is one of that accelerator. Both are accepted only when they
92
+ agree, because two statements of one fact leave no way to tell which was meant.
93
+ """
94
+ if body is None or body is True:
95
+ return cls(accelerator)
96
+ if isinstance(body, int) and not isinstance(body, bool):
97
+ return cls(accelerator, count=_positive(accelerator, body))
98
+ if not isinstance(body, dict):
99
+ raise ConfigError(
100
+ f"devices.{accelerator} must be a table such as "
101
+ f'{{ indices = "0-3" }} or {{ count = 2 }}, not {body!r}'
102
+ )
103
+
104
+ unknown = set(body) - {"count", "indices"}
105
+ if unknown:
106
+ named = ", ".join(sorted(unknown))
107
+ raise ConfigError(
108
+ f"devices.{accelerator} has no field {named}. It takes 'count' and 'indices'."
109
+ )
110
+
111
+ indices = read_indices(body["indices"]) if "indices" in body else ()
112
+ declared = body.get("count")
113
+ if declared is None:
114
+ return cls(accelerator, count=len(indices) or 1, indices=indices)
115
+
116
+ count = _positive(accelerator, declared)
117
+ if indices and count != len(indices):
118
+ raise ConfigError(
119
+ f"devices.{accelerator} declares count {count} and {len(indices)} indices. "
120
+ f"Drop the count, because the indices already say how many there are."
121
+ )
122
+ return cls(accelerator, count=count, indices=indices)
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ return {"count": self.count, "indices": list(self.indices)}
126
+
127
+
128
+ def _positive(accelerator: str, value: Any) -> int:
129
+ if isinstance(value, bool) or not isinstance(value, int):
130
+ raise ConfigError(f"devices.{accelerator} count must be a whole number, not {value!r}")
131
+ if value < 1:
132
+ raise ConfigError(f"devices.{accelerator} count must be at least one, not {value}")
133
+ return value
134
+
135
+
136
+ def read_table(options: dict[str, Any]) -> dict[str, Devices]:
137
+ """Read a provider entry's inventory.
138
+
139
+ ``devices`` is the full form. ``gpus`` is the older list, which means one of each with
140
+ no indices chosen, and stays because an entry that only needs to name its accelerators
141
+ should not have to write a table.
142
+ """
143
+ table = options.get("devices")
144
+ if isinstance(table, dict) and table:
145
+ return {str(name): Devices.read(str(name), body) for name, body in table.items()}
146
+
147
+ declared = options.get("gpus")
148
+ if isinstance(declared, (list, tuple)) and declared:
149
+ return {str(name): Devices(str(name)) for name in declared}
150
+ return {}
151
+
152
+
153
+ __all__ = ["Devices", "read_indices", "read_table"]