hyperstudy-perception 0.1.0__tar.gz → 0.1.1__tar.gz

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 (82) hide show
  1. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/PKG-INFO +1 -1
  2. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/pyproject.toml +1 -1
  3. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/RELEASE.json +1 -1
  4. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/backends/remote.py +2 -2
  5. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/cli.py +44 -8
  6. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/export.py +11 -1
  7. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/hosts.py +74 -15
  8. hyperstudy_perception-0.1.1/src/perception/keycheck.py +64 -0
  9. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/LICENSE +0 -0
  10. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/README.md +0 -0
  11. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/__init__.py +0 -0
  12. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/runpod/guard.sh +0 -0
  13. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/runpod/image/Dockerfile +0 -0
  14. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/runpod/image/start.sh +0 -0
  15. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/runpod/image/tag.py +0 -0
  16. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/skypilot/gpu-faces.yaml +0 -0
  17. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/skypilot/gpu-tune.yaml +0 -0
  18. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/skypilot/guard.sh +0 -0
  19. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/skypilot/iam-perception-node.json +0 -0
  20. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/skypilot/launch.sh +0 -0
  21. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/skypilot/node-gpu.yaml +0 -0
  22. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/skypilot/node-setup.sh +0 -0
  23. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/skypilot/sky.yaml +0 -0
  24. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/cloud/skypilot/smoke.yaml +0 -0
  25. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/envs/api/pyproject.toml +0 -0
  26. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/envs/api/uv.lock +0 -0
  27. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/envs/audio/pyproject.toml +0 -0
  28. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/envs/audio/uv.lock +0 -0
  29. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/envs/core/pyproject.toml +0 -0
  30. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/envs/core/uv.lock +0 -0
  31. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/envs/faces/pyproject.toml +0 -0
  32. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/envs/faces/uv.lock +0 -0
  33. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/graphs/decode.yaml +0 -0
  34. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/graphs/faces.yaml +0 -0
  35. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/graphs/full.yaml +0 -0
  36. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/graphs/mvp-captions.yaml +0 -0
  37. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/graphs/mvp.yaml +0 -0
  38. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/audio/asr/manifest.yaml +0 -0
  39. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/audio/asr/node.py +0 -0
  40. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/audio/asr/prepare.py +0 -0
  41. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/audio/asr/test_wav.py +0 -0
  42. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/audio/asr/wav.py +0 -0
  43. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/conftest.py +0 -0
  44. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/core/decode/manifest.yaml +0 -0
  45. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/core/decode/media.py +0 -0
  46. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/core/decode/node.py +0 -0
  47. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/core/decode/test_media.py +0 -0
  48. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/testdata/README.md +0 -0
  49. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
  50. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vision/face-track/frames.py +0 -0
  51. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vision/face-track/loader.py +0 -0
  52. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vision/face-track/manifest.yaml +0 -0
  53. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vision/face-track/node.py +0 -0
  54. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vision/face-track/prepare.py +0 -0
  55. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vision/face-track/test_frames.py +0 -0
  56. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vision/face-track/test_loader.py +0 -0
  57. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +0 -0
  58. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vision/face-track/thumbnails.py +0 -0
  59. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vision/face-track/tracking.py +0 -0
  60. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +0 -0
  61. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/nodes/vlm/shot-captions/node.py +0 -0
  62. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/schema/README.md +0 -0
  63. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/schema/bundle-0.1.schema.json +0 -0
  64. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/sdk/pyproject.toml +0 -0
  65. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/_pipeline/sdk/src/perception_node/__init__.py +0 -0
  66. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/artifacts.py +0 -0
  67. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/backends/__init__.py +0 -0
  68. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/backends/base.py +0 -0
  69. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/backends/cloud.py +0 -0
  70. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/backends/native.py +0 -0
  71. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/backends/probe.py +0 -0
  72. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/estimates.py +0 -0
  73. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/ir.py +0 -0
  74. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/keys.py +0 -0
  75. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/manifests.py +0 -0
  76. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/plan.py +0 -0
  77. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/release.py +0 -0
  78. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/remote_driver.py +0 -0
  79. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/runpod.py +0 -0
  80. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/scheduler.py +0 -0
  81. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/setup.py +0 -0
  82. {hyperstudy_perception-0.1.0 → hyperstudy_perception-0.1.1}/src/perception/store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hyperstudy-perception
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Video perception pipeline: shots, speech, faces and captions from a video, as a versioned track bundle, run locally, on cloud GPUs or lab servers
5
5
  Project-URL: Homepage, https://github.com/hyperstudyio/hyperstudy-perception
6
6
  Project-URL: Changelog, https://github.com/hyperstudyio/hyperstudy-perception/releases
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperstudy-perception"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "Video perception pipeline: shots, speech, faces and captions from a video, as a versioned track bundle, run locally, on cloud GPUs or lab servers"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,5 +1,5 @@
1
1
  {
2
- "commit": "9beebed7f711b6c85e2a735810db16841a741f39",
2
+ "commit": "4df6083954bc875054f55459a28001f26287a2d1",
3
3
  "files": [
4
4
  "cloud/runpod/guard.sh",
5
5
  "cloud/runpod/image/Dockerfile",
@@ -22,7 +22,7 @@ import subprocess
22
22
  import sys
23
23
  from pathlib import Path
24
24
 
25
- from ..hosts import Host, ssh_command
25
+ from ..hosts import Host
26
26
  from ..manifests import Manifest, Runner
27
27
  from ..release import files_in, release
28
28
  from .base import Capabilities
@@ -127,7 +127,7 @@ class SshBackend(NativeBackend):
127
127
  files = code_files(manifest, runner)
128
128
  caps = self.host.caps
129
129
  spec = {
130
- "dest": self.host.dest, "root": self.host.root, "host": self.host.name, "ssh": ssh_command(),
130
+ "dest": self.host.dest, "root": self.host.root, "host": self.host.name, "ssh": self.host.ssh(),
131
131
  "code": code_hash(files), "files": files,
132
132
  "node_dir": manifest.directory.resolve().relative_to(REPO).as_posix(), "entry": runner.entry, "env": runner.env,
133
133
  "runner_kind": self.runner_kind, "python": self.python, "uv": caps.tools.get("uv"), "prepare": manifest.prepare,
@@ -41,7 +41,7 @@ from .backends.native import NativeBackend, SelfBackend
41
41
  from .backends.probe import probe
42
42
  from .backends.cloud import Cloud, CloudBackend, RunPodBackend
43
43
  from .backends.remote import SshBackend
44
- from .hosts import HostError, Hosts, ssh_command
44
+ from .hosts import HostError, Hosts, probe_dest
45
45
  from .keys import load_secrets
46
46
  from .ir import GraphError, compile_graph
47
47
  from .manifests import Registry
@@ -258,8 +258,13 @@ def cmd_nodes(args):
258
258
  def cmd_models(args):
259
259
  reg = registry(args)
260
260
  if args.json:
261
+ # each node too: what it is called, the API it calls (service) and the keys it needs, and its fixed choices
262
+ # (a model, an effort) at their defaults, with prices: what the app's settings show
263
+ from .plan import settings
261
264
  emit("models", models=[{"node": m.ref, **x.model_dump(include={"id", "source", "size_gb", "gated", "licence_mode"})}
262
- for m in reg for x in m.models])
265
+ for m in reg for x in m.models],
266
+ nodes=[{"node": m.ref, "title": m.title, "service": m.service, "secrets": list(m.secrets),
267
+ "settings": settings(m, {k: s.get("default") for k, s in m.config.items()})} for m in reg])
263
268
  return 0
264
269
  for m in reg:
265
270
  for x in m.models:
@@ -481,7 +486,18 @@ def cmd_hosts(args):
481
486
  if args.action == "add":
482
487
  if not args.dest:
483
488
  raise HostError("hosts add NAME USER@HOST")
484
- hosts.add(args.name, args.dest, args.root)
489
+ hosts.add(args.name, args.dest, args.root, args.user, args.port, args.identity)
490
+ elif args.action == "probe": # a connection test: nothing is added, nothing changes on the host
491
+ dest = args.dest or args.name
492
+ if not dest:
493
+ raise HostError("hosts probe USER@HOST")
494
+ caps, root = probe_dest(dest, args.root, args.user, args.port, args.identity)
495
+ found = {"dest": dest, "root": root, "caps": {**caps.__dict__, "accelerators": sorted(caps.accelerators)}}
496
+ if args.json:
497
+ emit("host_probe", **found)
498
+ else:
499
+ print(f"{dest}: {caps.platform}, {caps.cpu_cores} cores, {caps.memory_gb} GB, {'/'.join(sorted(caps.accelerators))}")
500
+ return 0
485
501
  elif args.action in ("check", "remove", "clean") and not args.name:
486
502
  raise HostError(f"hosts {args.action} NAME")
487
503
  elif args.action == "check":
@@ -510,7 +526,7 @@ def cmd_hosts(args):
510
526
  f'if ps -ww -p "$p" -o command= 2>/dev/null | grep -qF -- "${{f%/pid}}/request.json"; then '
511
527
  f'echo "a node is running there" >&2; exit 4; fi; done\n'
512
528
  f"chmod -R u+w {root}/objects 2>/dev/null; rm -rf {root}/objects {root}/code {root}/work")
513
- out = subprocess.run([*ssh_command(), host.dest, f"sh -c {q(script)}"], capture_output=True, text=True, timeout=600)
529
+ out = subprocess.run([*host.ssh(), host.dest, f"sh -c {q(script)}"], capture_output=True, text=True, timeout=600)
514
530
  if out.returncode:
515
531
  raise HostError(f"could not clean {host.name}: {out.stderr.strip()[-300:]}")
516
532
  except HostError as e:
@@ -536,6 +552,13 @@ def cmd_cloud(args):
536
552
  (SkyPilot, your AWS credentials) or RunPod (an API key). `on` checks that the provider can be used first."""
537
553
  cloud = Cloud(home())
538
554
  problem = None
555
+ if args.action == "check": # whether it could be used, changing nothing (a test)
556
+ problem = runpod_problem() if args.provider == "runpod" else aws_problem()
557
+ if args.json:
558
+ emit("cloud_check", provider=args.provider, ok=problem is None, detail=problem or f"{args.provider} can be used")
559
+ else:
560
+ print(problem or f"{args.provider} can be used")
561
+ return 0
539
562
  if args.action == "on":
540
563
  problem = runpod_problem() if args.provider == "runpod" else aws_problem()
541
564
  if problem is None:
@@ -609,6 +632,16 @@ def cmd_keys(args):
609
632
  if not args.name:
610
633
  raise ValueError("keys remove NAME: which key?")
611
634
  result = {"name": args.name, "removed": keys.remove(args.name)}
635
+ elif args.action == "check": # whether its provider accepts it: a refused key is an answer, not a failure
636
+ if not args.name:
637
+ raise ValueError("keys check NAME: which key?")
638
+ from .keycheck import check
639
+ ok, detail = check(args.name, keys.load().values.get(args.name))
640
+ if args.json:
641
+ emit("key_check", name=args.name, ok=ok, detail=detail)
642
+ else:
643
+ print(detail)
644
+ return 0
612
645
  else:
613
646
  found = keys.load()
614
647
  result = {"keys": [{"name": k, "source": found.sources[k]} for k in sorted(found.values)], "problems": found.problems}
@@ -743,17 +776,20 @@ def main(argv: list[str] | None = None) -> int:
743
776
  rr.add_argument("--allow-uploads", action="store_true", help="let the run's nodes send data off this machine from now on")
744
777
  rr.set_defaults(fn=cmd_rerun)
745
778
  h = sub.add_parser("hosts", help="lab servers nodes can run on, over SSH")
746
- h.add_argument("action", choices=["add", "list", "check", "remove", "clean"])
747
- h.add_argument("name", nargs="?")
779
+ h.add_argument("action", choices=["add", "list", "check", "remove", "clean", "probe"])
780
+ h.add_argument("name", nargs="?", help="probe: the ssh destination")
748
781
  h.add_argument("dest", nargs="?", help="add: an ssh destination (user@host, or a ~/.ssh/config alias)")
749
782
  h.add_argument("--root", default=".cache/hyperstudy-perception", help="add: where on the host its files go (under its home if relative)")
783
+ h.add_argument("--user", help="add, probe: the user to log in as, over what ~/.ssh/config says")
784
+ h.add_argument("--port", type=int, help="add, probe: the ssh port, over what ~/.ssh/config says")
785
+ h.add_argument("--identity", help="add, probe: a private key file on this machine, over what ~/.ssh/config says")
750
786
  h.set_defaults(fn=cmd_hosts)
751
787
  c = sub.add_parser("cloud", help="offer a cloud GPU machine to nodes that can use one")
752
- c.add_argument("action", choices=["on", "off", "status"])
788
+ c.add_argument("action", choices=["on", "off", "status", "check"])
753
789
  c.add_argument("provider", nargs="?", choices=Cloud.providers, default="aws")
754
790
  c.set_defaults(fn=cmd_cloud)
755
791
  k = sub.add_parser("keys", help="your API keys and tokens: store one in the keychain, list where each comes from")
756
- k.add_argument("action", choices=["set", "list", "remove"])
792
+ k.add_argument("action", choices=["set", "list", "remove", "check"])
757
793
  k.add_argument("name", nargs="?")
758
794
  k.set_defaults(fn=cmd_keys)
759
795
  st = sub.add_parser("setup", help="install what nodes need here before a run: environments and weights")
@@ -20,6 +20,16 @@ from .scheduler import Coordinator, LICENCE_ORDER
20
20
  SCHEMA_VERSION = "0.1.0"
21
21
 
22
22
 
23
+ def _version() -> str:
24
+ """The installed package's version: what made the bundle ("unknown" in a checkout not installed; its git_commit
25
+ says which)."""
26
+ from importlib.metadata import PackageNotFoundError, version
27
+ try:
28
+ return version("hyperstudy-perception")
29
+ except PackageNotFoundError:
30
+ return "unknown"
31
+
32
+
23
33
  def _git_commit() -> str | None:
24
34
  if r := release():
25
35
  return r["commit"][:7]
@@ -92,7 +102,7 @@ def bundle(coord: Coordinator, run_id: str, out: Path) -> Path:
92
102
  "format": "hyperstudy-perception-bundle", "schema_version": SCHEMA_VERSION,
93
103
  "created_at": time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime()),
94
104
  "video": {k: media[k] for k in ("sha256", "duration", "fps", "width", "height")} | ({"filename": media["filename"]} if media.get("filename") else {}),
95
- "pipeline": {"name": "hyperstudy-perception", "version": "0.1.0", "git_commit": _git_commit(), "source": f"perception run {run_id}"},
105
+ "pipeline": {"name": "hyperstudy-perception", "version": _version(), "git_commit": _git_commit(), "source": f"perception run {run_id}"},
96
106
  "layers": entries,
97
107
  "assets": assets,
98
108
  }
@@ -1,5 +1,9 @@
1
1
  """Other machines nodes can run on: lab servers reached over SSH (`perception hosts add NAME DEST`).
2
2
 
3
+ DEST is what you would give ssh (user@host, or a Host from ~/.ssh/config). --user, --port and --identity override what
4
+ ~/.ssh/config says for it; every command sent to the host uses them (Host.ssh). `perception hosts probe DEST` reaches a
5
+ destination and says what it has without adding it or changing anything there (a connection test).
6
+
3
7
  A host is probed when it is added (and by `hosts check`): its platform, cores, memory, GPU and tools are recorded in
4
8
  $PERCEPTION_HOME/hosts.json, so planning never waits on the network. SSH runs in batch mode with the user's own keys
5
9
  and ~/.ssh/config: nothing here asks for or stores a password. Connections are shared (ControlMaster), so the many short
@@ -36,6 +40,7 @@ DEST = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.@%+:\[\]-]*$")
36
40
  ROOT = re.compile(r"^[A-Za-z0-9_./~-]+$")
37
41
  DEFAULT_ROOT = ".cache/hyperstudy-perception"
38
42
  RESERVED = {"native", "self", "cloud", "runpod", "local"}
43
+ USER = re.compile(r"^[A-Za-z0-9._][A-Za-z0-9._-]{0,63}$")
39
44
 
40
45
  # What the host has, as key=value lines. POSIX sh: Linux servers and Macs, no Python needed. Tools are looked for on the
41
46
  # PATH a login shell would add too (~/.local/bin is where uv installs itself), since ssh commands get a minimal PATH.
@@ -65,6 +70,10 @@ class Host:
65
70
  root: str # absolute, on the host
66
71
  caps: Capabilities
67
72
  probed_at: float
73
+ # overrides of what ~/.ssh/config says for dest (None: ssh's own)
74
+ user: str | None = None
75
+ port: int | None = None
76
+ identity: str | None = None # a private key on this machine, absolute
68
77
 
69
78
  def to_json(self) -> dict:
70
79
  d = asdict(self)
@@ -74,20 +83,59 @@ class Host:
74
83
  @classmethod
75
84
  def from_json(cls, d: dict) -> "Host":
76
85
  caps = Capabilities(**{**d["caps"], "accelerators": set(d["caps"]["accelerators"])})
77
- return cls(d["name"], d["dest"], d["root"], caps, d["probed_at"])
86
+ return cls(d["name"], d["dest"], d["root"], caps, d["probed_at"], d.get("user"), d.get("port"), d.get("identity"))
87
+
88
+ def ssh(self, fresh: bool = False) -> list[str]:
89
+ """ssh to this host: every command sent to it (probe, nodes, clean) goes through this, with its overrides.
90
+ `fresh`: its own connection, not a shared one (a check: see ssh_command)."""
91
+ return [*ssh_command(shared=not fresh, identity=self.identity), *ssh_options(self.user, self.port, self.identity)]
92
+
93
+
94
+ def ssh_options(user: str | None, port: int | None, identity: str | None) -> list[str]:
95
+ return [*(["-l", user] if user else []), *(["-p", str(port)] if port else []),
96
+ *(["-i", identity, "-o", "IdentitiesOnly=yes"] if identity else [])]
78
97
 
79
98
 
80
- def ssh_command() -> list[str]:
99
+ def check_overrides(dest: str, user: str | None, port: int | None, identity: str | None) -> str | None:
100
+ """Refuse overrides ssh would misread; the identity file as an absolute path on this machine."""
101
+ if user is not None:
102
+ if not USER.match(user):
103
+ raise HostError(f"not a user name: {user!r}")
104
+ if "@" in dest:
105
+ raise HostError(f"{dest} already names its user: give the user once (in the destination, or --user)")
106
+ if port is not None and not 1 <= port <= 65535:
107
+ raise HostError(f"not a port: {port}")
108
+ if identity is None:
109
+ return None
110
+ path = Path(identity).expanduser()
111
+ if not path.is_file():
112
+ raise HostError(f"no key file at {identity}")
113
+ path = path.resolve()
114
+ if any(ord(c) < 32 for c in str(path)):
115
+ raise HostError(f"not a plain path: {identity!r}")
116
+ if "%" in str(path) or "${" in str(path): # ssh expands %u, ${HOME}… in -i: it would read another file than this
117
+ raise HostError(f"ssh would expand % or ${{ in a key file's path: rename {identity}")
118
+ return str(path)
119
+
120
+
121
+ def ssh_command(shared: bool = True, identity: str | None = None) -> list[str]:
81
122
  """ssh with the options every remote command uses. PERCEPTION_SSH replaces the ssh program (the tests use a
82
- stand-in that runs commands on this machine)."""
123
+ stand-in that runs commands on this machine). A connection is shared by host, port and user (%C), and by key file
124
+ too (`identity`): ssh's own sharing ignores the key, so a connection opened with one key would carry commands meant
125
+ for another. Not `shared`: a connection of its own, for a check (add, probe, check), whose yes must be this
126
+ connection's, never one another key opened."""
83
127
  control = Path(f"/tmp/perception-ssh-{os.getuid()}") # short: a socket path must fit in 104 bytes on macOS
84
128
  control.mkdir(mode=0o700, exist_ok=True)
85
129
  st = control.stat()
86
130
  if st.st_uid != os.getuid() or st.st_mode & 0o077:
87
131
  raise HostError(f"{control} must be a directory only you can use")
88
- return [os.environ.get("PERCEPTION_SSH", "ssh"), "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
89
- "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=4",
90
- "-o", "ControlMaster=auto", "-o", f"ControlPath={control}/%C", "-o", "ControlPersist=120"]
132
+ base = [os.environ.get("PERCEPTION_SSH", "ssh"), "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
133
+ "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=4"]
134
+ if not shared:
135
+ return [*base, "-o", "ControlMaster=no", "-o", "ControlPath=none"]
136
+ import hashlib
137
+ keyed = f"-{hashlib.sha256(identity.encode()).hexdigest()[:12]}" if identity else "" # short: the socket path's limit
138
+ return [*base, "-o", "ControlMaster=auto", "-o", f"ControlPath={control}/%C{keyed}", "-o", "ControlPersist=120"]
91
139
 
92
140
 
93
141
  def check_names(name: str, dest: str, root: str):
@@ -126,11 +174,12 @@ def private_root_script(root: str) -> str:
126
174
  return f"root={path}\n" + PRIVATE_ROOT
127
175
 
128
176
 
129
- def probe(dest: str, root: str = DEFAULT_ROOT, ssh: list[str] | None = None) -> tuple[Capabilities, str]:
177
+ def probe(dest: str, root: str = DEFAULT_ROOT, ssh: list[str] | None = None, make_root: bool = True) -> tuple[Capabilities, str]:
130
178
  """What the host has, and its root as an absolute path (made private there, or refused: PRIVATE_ROOT). `ssh`: the
131
- command to reach it, when it needs options of its own (a RunPod pod's key and port)."""
132
- out = subprocess.run([*(ssh or ssh_command()), dest, "sh -s"], input=private_root_script(root) + PROBE, capture_output=True,
133
- text=True, timeout=120)
179
+ command to reach it, when it needs options of its own (overrides, a RunPod pod's key and port). Without `make_root`
180
+ nothing on the host is changed: the root is only where it would be (a connection test)."""
181
+ script = (private_root_script(root) if make_root else "") + PROBE
182
+ out = subprocess.run([*(ssh or ssh_command()), dest, "sh -s"], input=script, capture_output=True, text=True, timeout=120)
134
183
  if out.returncode:
135
184
  raise HostError(f"could not reach {dest} (ssh exit {out.returncode}): {out.stderr.strip()[-500:]}")
136
185
  facts = dict(line.split("=", 1) for line in out.stdout.splitlines() if "=" in line)
@@ -175,10 +224,12 @@ class Hosts:
175
224
  tmp.write_text(json.dumps({n: h.to_json() for n, h in sorted(hosts.items())}, indent=1))
176
225
  os.replace(tmp, self.path)
177
226
 
178
- def add(self, name: str, dest: str, root: str = DEFAULT_ROOT) -> Host:
227
+ def add(self, name: str, dest: str, root: str = DEFAULT_ROOT, user: str | None = None, port: int | None = None,
228
+ identity: str | None = None) -> Host:
179
229
  check_names(name, dest, root)
180
- caps, absolute = probe(dest, root)
181
- host = Host(name, dest, absolute, caps, time.time())
230
+ identity = check_overrides(dest, user, port, identity)
231
+ caps, absolute = probe(dest, root, [*ssh_command(shared=False), *ssh_options(user, port, identity)])
232
+ host = Host(name, dest, absolute, caps, time.time(), user, port, identity)
182
233
  hosts = self.load()
183
234
  hosts[name] = host
184
235
  self.save(hosts)
@@ -189,8 +240,8 @@ class Hosts:
189
240
  if name not in hosts:
190
241
  raise HostError(f"no host {name!r} (perception hosts list)")
191
242
  old = hosts[name]
192
- caps, _ = probe(old.dest, old.root)
193
- hosts[name] = Host(name, old.dest, old.root, caps, time.time())
243
+ caps, _ = probe(old.dest, old.root, old.ssh(fresh=True))
244
+ hosts[name] = Host(name, old.dest, old.root, caps, time.time(), old.user, old.port, old.identity)
194
245
  self.save(hosts)
195
246
  return hosts[name]
196
247
 
@@ -199,3 +250,11 @@ class Hosts:
199
250
  if hosts.pop(name, None) is None:
200
251
  raise HostError(f"no host {name!r}")
201
252
  self.save(hosts)
253
+
254
+
255
+ def probe_dest(dest: str, root: str = DEFAULT_ROOT, user: str | None = None, port: int | None = None,
256
+ identity: str | None = None) -> tuple[Capabilities, str]:
257
+ """Reach a destination as `hosts add` would and say what it has, changing nothing there and adding nothing here."""
258
+ check_names("probe", dest, root)
259
+ identity = check_overrides(dest, user, port, identity)
260
+ return probe(dest, root, [*ssh_command(shared=False), *ssh_options(user, port, identity)], make_root=False)
@@ -0,0 +1,64 @@
1
+ """Whether a provider accepts a key (`perception keys check NAME`): one request that reads something small and costs
2
+ nothing (Anthropic's model list, your Hugging Face account, your RunPod pods). The key is sent only to its own
3
+ provider, never through a redirect (runpod.OPENER)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import http.client
8
+ import json
9
+ import urllib.error
10
+ import urllib.request
11
+
12
+ from .runpod import OPENER, RunPod, RunPodError
13
+
14
+ ANTHROPIC = "https://api.anthropic.com/v1/models?limit=1"
15
+ HUGGING_FACE = "https://huggingface.co/api/whoami-v2"
16
+ CHECKED = ("ANTHROPIC_API_KEY", "HF_TOKEN", "RUNPOD_API_KEY")
17
+
18
+
19
+ def get(url: str, headers: dict[str, str]) -> tuple[int, bytes]:
20
+ """The status and body of a GET (an HTTP error's too); a provider that cannot be reached raises OSError."""
21
+ try:
22
+ with OPENER.open(urllib.request.Request(url, headers=headers), timeout=20) as r:
23
+ return r.status, r.read()
24
+ except urllib.error.HTTPError as e:
25
+ return e.code, e.read()
26
+
27
+
28
+ def account(body: bytes) -> str | None:
29
+ """The account name in Hugging Face's answer, if it says one (the key was accepted either way)."""
30
+ try:
31
+ d = json.loads(body or b"{}")
32
+ except ValueError:
33
+ return None
34
+ return d.get("name") if isinstance(d, dict) else None
35
+
36
+
37
+ def check(name: str, value: str | None) -> tuple[bool, str]:
38
+ """(accepted, what to tell the user)."""
39
+ if name not in CHECKED:
40
+ raise ValueError(f"no check for {name}: perception can check {', '.join(CHECKED)}")
41
+ if not value:
42
+ return False, f"{name} is not set"
43
+ try:
44
+ if name == "RUNPOD_API_KEY":
45
+ try:
46
+ RunPod(value).pods()
47
+ except RunPodError as e:
48
+ return False, ("RunPod refused the key" if e.status in (401, 403) else f"RunPod could not be checked: {e}")
49
+ return True, "RunPod accepted the key"
50
+ if name == "ANTHROPIC_API_KEY":
51
+ status, _ = get(ANTHROPIC, {"x-api-key": value, "anthropic-version": "2023-06-01"})
52
+ provider = "Anthropic"
53
+ else:
54
+ status, body = get(HUGGING_FACE, {"Authorization": f"Bearer {value}"})
55
+ provider = "Hugging Face"
56
+ if status == 200:
57
+ return True, f"Hugging Face accepted the key{f' (as {who})' if (who := account(body)) else ''}"
58
+ except (OSError, http.client.HTTPException) as e: # no whole answer (a reply cut short is not an OSError)
59
+ return False, f"could not reach the provider to check: {e}"
60
+ if status == 200:
61
+ return True, f"{provider} accepted the key"
62
+ if status in (401, 403):
63
+ return False, f"{provider} refused the key"
64
+ return False, f"{provider} could not be checked (HTTP {status})"