ms-env 0.3.2__tar.gz → 0.3.3__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 (27) hide show
  1. {ms_env-0.3.2 → ms_env-0.3.3}/PKG-INFO +3 -2
  2. {ms_env-0.3.2 → ms_env-0.3.3}/README.md +2 -1
  3. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env/app.py +19 -12
  4. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env/host.py +11 -1
  5. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env/recipes.py +57 -6
  6. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env.egg-info/PKG-INFO +3 -2
  7. ms_env-0.3.3/src/ms_env.egg-info/scm_version.json +8 -0
  8. {ms_env-0.3.2 → ms_env-0.3.3}/tests/test_ms_env.py +81 -4
  9. ms_env-0.3.2/src/ms_env.egg-info/scm_version.json +0 -8
  10. {ms_env-0.3.2 → ms_env-0.3.3}/.github/workflows/ci.yml +0 -0
  11. {ms_env-0.3.2 → ms_env-0.3.3}/.github/workflows/docs.yml +0 -0
  12. {ms_env-0.3.2 → ms_env-0.3.3}/.github/workflows/publish.yml +0 -0
  13. {ms_env-0.3.2 → ms_env-0.3.3}/.gitignore +0 -0
  14. {ms_env-0.3.2 → ms_env-0.3.3}/LICENSE +0 -0
  15. {ms_env-0.3.2 → ms_env-0.3.3}/docs/api_reference.md +0 -0
  16. {ms_env-0.3.2 → ms_env-0.3.3}/docs/guide.md +0 -0
  17. {ms_env-0.3.2 → ms_env-0.3.3}/docs/index.md +0 -0
  18. {ms_env-0.3.2 → ms_env-0.3.3}/pyproject.toml +0 -0
  19. {ms_env-0.3.2 → ms_env-0.3.3}/setup.cfg +0 -0
  20. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env/__init__.py +0 -0
  21. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env/steps.py +0 -0
  22. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env.egg-info/SOURCES.txt +0 -0
  23. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env.egg-info/dependency_links.txt +0 -0
  24. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env.egg-info/requires.txt +0 -0
  25. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env.egg-info/scm_file_list.json +0 -0
  26. {ms_env-0.3.2 → ms_env-0.3.3}/src/ms_env.egg-info/top_level.txt +0 -0
  27. {ms_env-0.3.2 → ms_env-0.3.3}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ms_env
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Environments for MolSuite apps: find or install external tools, on this machine or over ssh.
5
5
  Author: Mario S. Valdés-Tresanco, Mario E. Valdés-Tresanco
6
6
  License-Expression: MIT
@@ -125,7 +125,8 @@ as it is.
125
125
  `Host()` is this machine. `Host("me@login.hpc")` runs every command through the system's
126
126
  `ssh` with `BatchMode=yes`: users, keys and jumps come from `~/.ssh/config`, the key
127
127
  logs in or the command fails at once, no credential is handled and nothing is left
128
- listening on the other side. A remote host is a POSIX machine: every command there runs through `sh`. This
128
+ listening on the other side. Outside Windows the commands to one host share one connection
129
+ (a socket in `~/.ssh`, closed a minute after the last command). A remote host is a POSIX machine: every command there runs through `sh`. This
129
130
  machine needs no `sh`: what ms_env does here it does in Python, Windows included.
130
131
 
131
132
  ## The environment of an app
@@ -100,7 +100,8 @@ as it is.
100
100
  `Host()` is this machine. `Host("me@login.hpc")` runs every command through the system's
101
101
  `ssh` with `BatchMode=yes`: users, keys and jumps come from `~/.ssh/config`, the key
102
102
  logs in or the command fails at once, no credential is handled and nothing is left
103
- listening on the other side. A remote host is a POSIX machine: every command there runs through `sh`. This
103
+ listening on the other side. Outside Windows the commands to one host share one connection
104
+ (a socket in `~/.ssh`, closed a minute after the last command). A remote host is a POSIX machine: every command there runs through `sh`. This
104
105
  machine needs no `sh`: what ms_env does here it does in Python, Windows included.
105
106
 
106
107
  ## The environment of an app
@@ -71,23 +71,29 @@ def _wheel(root: Path, tag: str, cache: Path) -> Path:
71
71
  return next(out.glob("*.whl"))
72
72
 
73
73
 
74
- def _constraints(everything: bool) -> str:
75
- """`name==version` of what this environment holds: all of it, or what pip took from an index.
74
+ def _constraints() -> tuple[str, str]:
75
+ """`name==version` of what this environment holds: all of it, and what pip took from an index.
76
76
 
77
77
  A local build is never pinned (no index has it), nor a name this environment has two
78
- records of (which one runs is anyone's guess). What conda put here is pinned only when
79
- `everything`: an index may lack that version, which is why the install has a second try.
78
+ records of (which one runs is anyone's guess). What conda put here is pinned only in the
79
+ first: an index may lack that version, which is why the install has a second try.
80
80
  """
81
- seen: dict[str, set[str]] = {}
82
- for dist in metadata.distributions():
81
+ seen: dict[str, set[tuple[str, bool]]] = {}
82
+ for dist in metadata.distributions(): # one pass: reading every record is most of what this costs
83
83
  name, installer = dist.metadata.get("Name"), (dist.read_text("INSTALLER") or "").strip()
84
84
  if not name: # a broken leftover in site-packages
85
85
  continue
86
86
  local = "+" in dist.version or (installer != "conda" and dist.read_text("direct_url.json"))
87
- pin = not local and (everything or installer in ("pip", "uv"))
88
- seen.setdefault(re.sub(r"[-_.]+", "-", name).lower(), set()).add(dist.version if pin else "")
89
- pins = (f"{name}=={next(iter(versions))}" for name, versions in seen.items() if len(versions) == 1)
90
- return "\n".join(sorted(pin for pin in pins if not pin.endswith("=="))) + "\n"
87
+ seen.setdefault(re.sub(r"[-_.]+", "-", name).lower(), set()).add(
88
+ ("" if local else dist.version, installer in ("pip", "uv")))
89
+
90
+ def pins(everything: bool) -> str:
91
+ versions = {name: {version if everything or by_pip else "" for version, by_pip in records}
92
+ for name, records in seen.items()}
93
+ return "\n".join(sorted(f"{name}=={next(iter(found))}" for name, found in versions.items()
94
+ if len(found) == 1 and next(iter(found)))) + "\n"
95
+
96
+ return pins(True), pins(False)
91
97
 
92
98
 
93
99
  def app_recipe(
@@ -125,8 +131,9 @@ def app_recipe(
125
131
  wanted.append(f"{code}/{wheels[-1].name}{extras or ''}")
126
132
  cache.mkdir(parents=True, exist_ok=True)
127
133
  constraints, loose = cache / "constraints.txt", cache / "constraints-pip.txt"
128
- constraints.write_text(_constraints(True), encoding="utf-8")
129
- loose.write_text(_constraints(False), encoding="utf-8")
134
+ everything, by_pip = _constraints()
135
+ constraints.write_text(everything, encoding="utf-8")
136
+ loose.write_text(by_pip, encoding="utf-8")
130
137
 
131
138
  pip = (f"{prefix}/bin/python", "-m", "pip", "install", "--progress-bar", "off")
132
139
  # ponytail: one version the index lacks drops every pin of what conda installed here, not
@@ -2,9 +2,12 @@
2
2
 
3
3
  No credential is handled here. `ssh` runs with `BatchMode=yes`: the keys and the agent of the
4
4
  account log in or the command fails at once, and nothing is left listening on the other side.
5
+ Where `ssh` can (not Windows' OpenSSH), the commands to one host share one connection, kept a
6
+ minute after the last one: a check is then a few milliseconds instead of a new login.
5
7
  """
6
8
  from __future__ import annotations
7
9
 
10
+ import os
8
11
  import platform as _platform
9
12
  import subprocess
10
13
  from pathlib import Path
@@ -23,6 +26,11 @@ def conda_platform(system: str, machine: str) -> str:
23
26
  return _PLATFORMS.get((system, machine), f"{system}-{machine}")
24
27
 
25
28
 
29
+ # The shared connection's socket goes in ~/.ssh; ssh refuses to run when it cannot make it there.
30
+ _SHARED = () if os.name == "nt" else (
31
+ "-o", "ControlMaster=auto", "-o", "ControlPersist=60", "-o", "ControlPath=~/.ssh/ms_env-%C")
32
+
33
+
26
34
  class HostError(RuntimeError):
27
35
  pass
28
36
 
@@ -31,8 +39,10 @@ class Host:
31
39
  """A machine, known by what running a command on it gives back: `address` as `ssh` knows it (its
32
40
  keys, users and jumps are in `~/.ssh/config`), or None for this one."""
33
41
 
34
- def __init__(self, address: str | None = None, ssh=("ssh", "-o", "BatchMode=yes")):
42
+ def __init__(self, address: str | None = None, ssh=("ssh", "-o", "BatchMode=yes", *_SHARED)):
35
43
  self.address, self.ssh = address or None, tuple(ssh)
44
+ if self.address and _SHARED and _SHARED[-1] in self.ssh:
45
+ (Path.home() / ".ssh").mkdir(mode=0o700, exist_ok=True)
36
46
 
37
47
  def __repr__(self) -> str:
38
48
  return f"Host({self.address!r})"
@@ -74,7 +74,7 @@ from pathlib import Path, PurePath
74
74
  from typing import Any, Callable, Iterable
75
75
 
76
76
  from ms_env.steps import Step, StepFailed, find_environment_manager, run_step
77
- from ms_env.host import LOCAL, Host
77
+ from ms_env.host import LOCAL, Host, HostError
78
78
 
79
79
  READY = "ready"
80
80
  MISSING = "missing"
@@ -386,7 +386,8 @@ def _base(owner: str) -> str:
386
386
 
387
387
 
388
388
  def _executable(info: tarfile.TarInfo) -> tarfile.TarInfo:
389
- return info.replace(mode=0o755) # what Windows packs carries no mode; a download has to run there
389
+ # not deep: a deep copy takes the TarFile it points to along, with all it packed so far
390
+ return info.replace(mode=0o755, deep=False) # what Windows packs carries no mode; a download has to run there
390
391
 
391
392
 
392
393
  def _tar(paths: Iterable[str]) -> bytes:
@@ -397,8 +398,43 @@ def _tar(paths: Iterable[str]) -> bytes:
397
398
  return held.getvalue()
398
399
 
399
400
 
401
+ # `_fetch`, run there by the host's own python3: the same check and unpacking, with no ms_env there yet.
402
+ _FETCH_THERE = """import hashlib, os, shutil, sys, tempfile, urllib.request
403
+ url, sha256, to = sys.argv[1:4]
404
+ name = url.rsplit("/", 1)[-1]
405
+ with tempfile.TemporaryDirectory() as held:
406
+ file, digest = os.path.join(held, name), hashlib.sha256()
407
+ with urllib.request.urlopen(url, timeout=60) as got, open(file, "wb") as out:
408
+ for chunk in iter(lambda: got.read(1 << 20), b""):
409
+ digest.update(chunk)
410
+ out.write(chunk)
411
+ if digest.hexdigest() != sha256:
412
+ sys.exit(f"{url} is not the file the recipe names: sha256 {digest.hexdigest()}, not {sha256}")
413
+ if any(name.endswith(ext) for _, extensions, _ in shutil.get_unpack_formats() for ext in extensions):
414
+ try:
415
+ shutil.unpack_archive(file, to, **({} if name.endswith(".zip") else {"filter": "data"}))
416
+ except TypeError: # a python there older than the filter
417
+ shutil.unpack_archive(file, to)
418
+ else:
419
+ os.makedirs(os.path.dirname(to) or ".", exist_ok=True)
420
+ shutil.move(file, to)
421
+ os.chmod(to, 0o755)
422
+ """
423
+
424
+
425
+ def _fetch_there(step: RecipeStep, host: Host, emit: Callable[[str], None]) -> None:
426
+ """A download for a remote host: made there when the host reaches the URL, else made here and sent."""
427
+ emit(f"downloading {step.url} on {host.address}")
428
+ try:
429
+ host.run(shlex.join(["python3", "-", step.url, step.sha256, step.to]), data=_FETCH_THERE.encode(),
430
+ timeout=step.timeout)
431
+ except (HostError, subprocess.TimeoutExpired) as exc:
432
+ emit(f"{host.address} could not download it ({str(exc).splitlines()[-1][:200]}): sending it from here")
433
+ _fetch_and_send(step, host, emit)
434
+
435
+
400
436
  def _fetch_and_send(step: RecipeStep, host: Host, emit: Callable[[str], None]) -> None:
401
- """A download for a remote host: made and checked here, then sent, so the host needs no curl."""
437
+ """A download for a host that has no python3 or no way out: made and checked here, then sent."""
402
438
  with tempfile.TemporaryDirectory() as held:
403
439
  _fetch(step.url, step.sha256, str(Path(held, posixpath.basename(step.to))), emit)
404
440
  parent = shlex.quote(posixpath.dirname(step.to))
@@ -412,7 +448,7 @@ def _step(step: RecipeStep, label: str, host: Host) -> Step:
412
448
  return Step(label + step.name, run=step.local, timeout=step.timeout)
413
449
  if step.url:
414
450
  run = partial(_fetch, step.url, step.sha256, step.to) if host.address is None else \
415
- partial(_fetch_and_send, step, host)
451
+ partial(_fetch_there, step, host)
416
452
  return Step(label + step.name, run=run, timeout=step.timeout)
417
453
  if not step.send:
418
454
  return Step(label + step.name, argv=_argv_on(step.argv, host), timeout=step.timeout)
@@ -635,10 +671,15 @@ class Install:
635
671
  key: str
636
672
  future: Future = field(default_factory=Future)
637
673
  lines: list[str] = field(default_factory=list) # live log, appended from the install thread
674
+ # Steps done of all it takes: the check, and then each step of an install when one is needed.
675
+ done: int = 0
676
+ total: int = 1
677
+ step: str = "" # the name of the step it is running, while it runs one
638
678
 
639
679
 
640
680
  _INSTALLS: dict[str, Install] = {}
641
681
  _LOCK = threading.Lock()
682
+ _CHANGING: dict[str, threading.Lock] = {} # by host: the checks run side by side, the installs one at a time
642
683
 
643
684
 
644
685
  def ensure(recipe: Recipe, host: Host = LOCAL, busy: Callable[[], str] | None = None) -> Install:
@@ -682,8 +723,17 @@ def _run_install(install: Install, recipe: Recipe, host: Host, busy: Callable[[]
682
723
  why = busy() if busy and owner.startswith("{") else "" # a stamp: an install of ours that ended
683
724
  if why:
684
725
  raise StepFailed(f"{recipe.name} on {where} is not as this recipe asks, and is left as it is: {why}")
685
- for step in recipe_steps(recipe, host, refresh=bool(recipe.base) and _base(owner) == recipe.base):
686
- run_step(step, install.lines.append)
726
+ steps = recipe_steps(recipe, host, refresh=bool(recipe.base) and _base(owner) == recipe.base)
727
+ install.done, install.total = 1, 1 + len(steps)
728
+ with _LOCK:
729
+ changing = _CHANGING.setdefault(host.address or "", threading.Lock())
730
+ # ponytail: one install at a time on a host (they share its micromamba and its package cache)
731
+ with changing:
732
+ for step in steps:
733
+ install.step = step.name
734
+ run_step(step, install.lines.append)
735
+ install.done += 1
736
+ install.step = ""
687
737
  state = status(recipe, host)
688
738
  if state != READY:
689
739
  raise StepFailed(f"{recipe.name}: every step ran but the tool is still {state} on {where}.")
@@ -691,6 +741,7 @@ def _run_install(install: Install, recipe: Recipe, host: Host, busy: Callable[[]
691
741
  install.lines.append(str(exc))
692
742
  install.future.set_exception(exc)
693
743
  else:
744
+ install.done = install.total
694
745
  install.future.set_result(None)
695
746
 
696
747
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ms_env
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Environments for MolSuite apps: find or install external tools, on this machine or over ssh.
5
5
  Author: Mario S. Valdés-Tresanco, Mario E. Valdés-Tresanco
6
6
  License-Expression: MIT
@@ -125,7 +125,8 @@ as it is.
125
125
  `Host()` is this machine. `Host("me@login.hpc")` runs every command through the system's
126
126
  `ssh` with `BatchMode=yes`: users, keys and jumps come from `~/.ssh/config`, the key
127
127
  logs in or the command fails at once, no credential is handled and nothing is left
128
- listening on the other side. A remote host is a POSIX machine: every command there runs through `sh`. This
128
+ listening on the other side. Outside Windows the commands to one host share one connection
129
+ (a socket in `~/.ssh`, closed a minute after the last command). A remote host is a POSIX machine: every command there runs through `sh`. This
129
130
  machine needs no `sh`: what ms_env does here it does in Python, Windows included.
130
131
 
131
132
  ## The environment of an app
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.3",
3
+ "distance": 0,
4
+ "node": "ga04372b97f9cb0bb6669d7e33a69accd2ad77471",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-29"
8
+ }
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
 
4
4
  import subprocess
5
5
  import dataclasses
6
+ import hashlib
7
+ import os
6
8
  import json
7
9
  import sys
8
10
  from pathlib import Path
@@ -57,7 +59,7 @@ def test_spec_binds_per_host(tmp_path):
57
59
  assert [step.name.removeprefix("mario@login.hpc: ") for step in steps] == [
58
60
  "prepare /opt/mine", "create the faketool environment", "pip install into faketool", "record the faketool install",
59
61
  ]
60
- assert steps[1].argv[:4] == ["ssh", "-o", "BatchMode=yes", "mario@login.hpc"]
62
+ assert steps[1].argv[:3] + [steps[1].argv[-2]] == ["ssh", "-o", "BatchMode=yes", "mario@login.hpc"]
61
63
  assert "/scratch/tools/micromamba" in steps[1].argv[-1] # bootstrap lands under the remote home
62
64
  assert steps[2].argv[-1].endswith("'extra pkg'") # one quoted remote command line
63
65
  assert remote.stamp == local.stamp # what the TOML asks for, wherever it is applied
@@ -130,6 +132,32 @@ def test_install_needs_no_project(tmp_path, monkeypatch):
130
132
  assert "boom detail" in broken.lines
131
133
 
132
134
 
135
+ def test_installs_on_one_host_take_turns_and_count_their_steps(tmp_path, monkeypatch):
136
+ import time
137
+
138
+ monkeypatch.setenv("PATH", "/usr/bin:/bin")
139
+ recipes = []
140
+ for name in ("a", "b"):
141
+ (tmp_path / name).mkdir()
142
+ recipes.append(dataclasses.replace(_plain_spec(tmp_path / name, slow=True), name=f"faketool-{name}"))
143
+ start = time.monotonic()
144
+ installs = [tools.ensure(recipe) for recipe in recipes]
145
+ running = set()
146
+ while not all(install.future.done() for install in installs):
147
+ running.update(install.step for install in installs)
148
+ time.sleep(0.05)
149
+ for install in installs:
150
+ install.future.result(timeout=30)
151
+ assert "wait" in running, running
152
+ assert all(install.step == "" for install in installs) # named while it runs, not after
153
+ assert time.monotonic() - start >= 2 # one `sleep 1` after the other: they share the host's micromamba
154
+ # the check, "prepare", the two steps of the recipe, the stamp
155
+ assert [(install.done, install.total) for install in installs] == [(5, 5), (5, 5)]
156
+ again = tools.ensure(recipes[0])
157
+ again.future.result(timeout=30)
158
+ assert (again.done, again.total) == (1, 1) # only the check: nothing to install
159
+
160
+
133
161
  def test_ms_env_updates_and_removes_what_it_installed(tmp_path, monkeypatch):
134
162
  monkeypatch.setenv("PATH", "/usr/bin:/bin")
135
163
  recipe = _plain_spec(tmp_path)
@@ -186,7 +214,8 @@ def test_the_environment_of_the_user_is_verified_and_never_changed(tmp_path, mon
186
214
 
187
215
  def test_a_host_runs_commands_and_says_why_one_failed():
188
216
  assert Host().run("cat; echo there", b"here ") == b"here there\n"
189
- assert Host("login.hpc").argv("ls") == ["ssh", "-o", "BatchMode=yes", "login.hpc", "ls"]
217
+ argv = Host("login.hpc").argv("ls")
218
+ assert argv[:3] + argv[-2:] == ["ssh", "-o", "BatchMode=yes", "login.hpc", "ls"]
190
219
  with pytest.raises(tools.HostError, match="this machine: no such thing"):
191
220
  Host().run("echo no such thing >&2; exit 3")
192
221
 
@@ -294,7 +323,7 @@ def test_a_download_is_picked_per_platform_checked_and_installed(tmp_path, monke
294
323
  tools.ToolSpec.load(tmp_path / "bad.toml")
295
324
 
296
325
 
297
- def test_an_archive_is_unpacked_and_a_remote_host_gets_the_download_sent(tmp_path, monkeypatch):
326
+ def test_an_archive_is_unpacked_and_a_remote_host_with_no_way_out_gets_the_download_sent(tmp_path, monkeypatch):
298
327
  import tarfile
299
328
  import zipfile
300
329
 
@@ -307,7 +336,13 @@ def test_an_archive_is_unpacked_and_a_remote_host_gets_the_download_sent(tmp_pat
307
336
  assert Path(recipe.prefix, "index.html").read_text() == "<html/>"
308
337
 
309
338
  sent: list[tuple[str, bytes]] = []
310
- monkeypatch.setattr(Host, "run", lambda self, command, data=b"", timeout=0: sent.append((command, data)) or b"")
339
+
340
+ def run(self, command, data=b"", timeout=0): # a host with no way out: what it cannot download is sent
341
+ if command.startswith("python3"):
342
+ raise tools.HostError("no route to host")
343
+ return sent.append((command, data)) or b""
344
+
345
+ monkeypatch.setattr(Host, "run", run)
311
346
  far = _download_spec(tmp_path, {"": _fake_exe(tmp_path / "release/faketool")}).recipe("/far/tools")
312
347
  run_step(tools.recipe_steps(far, Host("login.hpc"))[1])
313
348
  (command, data), = sent
@@ -343,3 +378,45 @@ def test_a_new_tag_on_the_same_code_builds_a_new_wheel(tmp_path):
343
378
  git("tag", "v0.2.0") # a release of the same code: its wheel has another version
344
379
  assert before.startswith("v0.1.0-1-g") and app._described(tmp_path).startswith("v0.2.0-0-g")
345
380
  assert app._described(tmp_path / "not-a-checkout") == ""
381
+
382
+
383
+ def test_a_folder_of_many_files_is_sent_as_a_tar_of_its_size(tmp_path):
384
+ import resource
385
+ import tarfile
386
+ from io import BytesIO
387
+
388
+ from ms_env.recipes import _tar
389
+
390
+ for i in range(300): # each file once took a deep copy of all packed before it: GBs by 100
391
+ (tmp_path / "pack" / f"sub{i % 10}").mkdir(parents=True, exist_ok=True)
392
+ (tmp_path / "pack" / f"sub{i % 10}" / f"f{i}.js").write_bytes(b"x" * 20_000)
393
+ before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
394
+ data = _tar([str(tmp_path / "pack")])
395
+ assert resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - before < 200 * 1024 # kB
396
+ with tarfile.open(fileobj=BytesIO(data)) as tar:
397
+ members = tar.getmembers()
398
+ assert len([m for m in members if m.isfile()]) == 300 and all(m.mode == 0o755 for m in members)
399
+
400
+
401
+ def test_a_host_downloads_for_itself_and_is_sent_the_file_only_when_it_cannot(tmp_path):
402
+ from ms_env.recipes import RecipeStep, _step
403
+
404
+ payload = tmp_path / "tool.bin"
405
+ payload.write_bytes(b"#!/bin/sh\necho hi\n")
406
+ digest = hashlib.sha256(payload.read_bytes()).hexdigest()
407
+
408
+ class Far(Host): # a "remote" host that is this machine; `blocked` is a host with no way out
409
+ blocked = False
410
+
411
+ def argv(self, command):
412
+ if self.blocked and command.startswith("python3"):
413
+ return ["sh", "-c", "echo 'no route to host' >&2; exit 1"]
414
+ return ["sh", "-c", command]
415
+
416
+ for blocked in (False, True):
417
+ host, said = Far("far"), []
418
+ host.blocked = blocked
419
+ to = tmp_path / f"there-{blocked}" / "tool"
420
+ _step(RecipeStep("get", (), 60.0, url=payload.as_uri(), sha256=digest, to=str(to)), "", host).run(said.append)
421
+ assert to.read_bytes() == payload.read_bytes() and os.access(to, os.X_OK)
422
+ assert any("sending it from here" in line for line in said) == blocked
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.3.2",
3
- "distance": 0,
4
- "node": "g3f644bbfcaf555dc7cedf971907597ad44ef3f75",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-28"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes