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.
- letify/__init__.py +82 -0
- letify/cli.py +281 -0
- letify/config/__init__.py +129 -0
- letify/config/inventory.py +153 -0
- letify/config/login.py +428 -0
- letify/config/schema.py +52 -0
- letify/config/secrets.py +50 -0
- letify/config/writer.py +150 -0
- letify/declare/__init__.py +20 -0
- letify/declare/env.py +94 -0
- letify/declare/function.py +299 -0
- letify/declare/instance.py +151 -0
- letify/declare/sweep.py +87 -0
- letify/errors.py +92 -0
- letify/launcher.py +445 -0
- letify/protocol/__init__.py +64 -0
- letify/protocol/codec.py +152 -0
- letify/protocol/driver.py +98 -0
- letify/protocol/framing.py +50 -0
- letify/protocol/guards.py +44 -0
- letify/protocol/handle.py +71 -0
- letify/protocol/worker.py +288 -0
- letify/providers/__init__.py +56 -0
- letify/providers/base.py +427 -0
- letify/providers/colab.py +227 -0
- letify/providers/elice.py +246 -0
- letify/providers/local.py +142 -0
- letify/providers/modal.py +215 -0
- letify/providers/naming.py +47 -0
- letify/providers/shell.py +190 -0
- letify/providers/tunnel.py +156 -0
- letify/providers/usage.py +131 -0
- letify/remoting/__init__.py +28 -0
- letify/remoting/capability.py +50 -0
- letify/remoting/loader.py +96 -0
- letify/remoting/probe.py +127 -0
- letify/runtime/__init__.py +24 -0
- letify/runtime/bootstrap.py +58 -0
- letify/runtime/channel.py +324 -0
- letify/runtime/lease.py +70 -0
- letify/runtime/pool.py +236 -0
- letify/runtime/session.py +298 -0
- letify/runtime/telemetry.py +220 -0
- letify/store/__init__.py +27 -0
- letify/store/backends/__init__.py +60 -0
- letify/store/backends/filesystem.py +70 -0
- letify/store/backends/layout.py +27 -0
- letify/store/backends/objects.py +209 -0
- letify/store/cas.py +177 -0
- letify/store/volume.py +199 -0
- letify-1.0.0.dist-info/METADATA +505 -0
- letify-1.0.0.dist-info/RECORD +55 -0
- letify-1.0.0.dist-info/WHEEL +4 -0
- letify-1.0.0.dist-info/entry_points.txt +2 -0
- 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"]
|