ms-env 0.2.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.
- ms_env/__init__.py +50 -0
- ms_env/app.py +165 -0
- ms_env/host.py +50 -0
- ms_env/recipes.py +481 -0
- ms_env/steps.py +94 -0
- ms_env-0.2.0.dist-info/METADATA +133 -0
- ms_env-0.2.0.dist-info/RECORD +10 -0
- ms_env-0.2.0.dist-info/WHEEL +5 -0
- ms_env-0.2.0.dist-info/licenses/LICENSE +21 -0
- ms_env-0.2.0.dist-info/top_level.txt +1 -0
ms_env/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Environments for MolSuite apps: a recipe says how to tell that a tool works and how to
|
|
2
|
+
install it, a host says where, and `ensure` applies one to the other.
|
|
3
|
+
|
|
4
|
+
The lowest layer of MolSuite: the standard library, no Qt, and nothing of `ms_flow`.
|
|
5
|
+
"""
|
|
6
|
+
from ms_env.app import app_recipe
|
|
7
|
+
from ms_env.recipes import (
|
|
8
|
+
MISSING,
|
|
9
|
+
READY,
|
|
10
|
+
STAMP,
|
|
11
|
+
UNREACHABLE,
|
|
12
|
+
Install,
|
|
13
|
+
Recipe,
|
|
14
|
+
RecipeStep,
|
|
15
|
+
ToolSpec,
|
|
16
|
+
ensure,
|
|
17
|
+
locate,
|
|
18
|
+
recipe_steps,
|
|
19
|
+
remove,
|
|
20
|
+
status,
|
|
21
|
+
verify,
|
|
22
|
+
)
|
|
23
|
+
from ms_env.steps import Step, StepFailed, find_environment_manager, run_step, run_steps
|
|
24
|
+
from ms_env.host import LOCAL, Host, HostError
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Install",
|
|
28
|
+
"LOCAL",
|
|
29
|
+
"MISSING",
|
|
30
|
+
"READY",
|
|
31
|
+
"Recipe",
|
|
32
|
+
"RecipeStep",
|
|
33
|
+
"STAMP",
|
|
34
|
+
"Step",
|
|
35
|
+
"StepFailed",
|
|
36
|
+
"Host",
|
|
37
|
+
"HostError",
|
|
38
|
+
"ToolSpec",
|
|
39
|
+
"UNREACHABLE",
|
|
40
|
+
"app_recipe",
|
|
41
|
+
"ensure",
|
|
42
|
+
"find_environment_manager",
|
|
43
|
+
"locate",
|
|
44
|
+
"recipe_steps",
|
|
45
|
+
"remove",
|
|
46
|
+
"run_step",
|
|
47
|
+
"run_steps",
|
|
48
|
+
"status",
|
|
49
|
+
"verify",
|
|
50
|
+
]
|
ms_env/app.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The environment of an app on a host: the Python that runs here, with the same versions of
|
|
2
|
+
what this one has installed.
|
|
3
|
+
|
|
4
|
+
Nothing is copied but the code that runs here from a source checkout, which travels as wheels
|
|
5
|
+
built from it. Everything else the host downloads, pinned to what this environment holds, so
|
|
6
|
+
the two may be different systems.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import importlib.util
|
|
12
|
+
import platform
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from importlib import metadata
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterable
|
|
20
|
+
|
|
21
|
+
from ms_env.recipes import Recipe, RecipeStep, _env_steps
|
|
22
|
+
|
|
23
|
+
_CODE = ".ms_env.d" # inside the prefix: the wheels and the constraints of the last install
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _checkout(module: str) -> Path | None:
|
|
27
|
+
"""The project `module` is imported from, when that is a source checkout and not an install."""
|
|
28
|
+
spec = importlib.util.find_spec(module)
|
|
29
|
+
if spec is None or not spec.origin:
|
|
30
|
+
raise ModuleNotFoundError(f"{module} does not import here, so no host can be given the same.")
|
|
31
|
+
origin = Path(spec.origin).resolve()
|
|
32
|
+
if {"site-packages", "dist-packages"} & set(origin.parts):
|
|
33
|
+
return None
|
|
34
|
+
return next((parent for parent in origin.parents if (parent / "pyproject.toml").is_file()), None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tree_hash(root: Path, module: str) -> str:
|
|
38
|
+
digest = hashlib.sha256((root / "pyproject.toml").read_bytes())
|
|
39
|
+
package = Path(importlib.util.find_spec(module).origin).resolve().parent
|
|
40
|
+
for path in sorted(package.rglob("*")):
|
|
41
|
+
if path.is_file() and "__pycache__" not in path.parts:
|
|
42
|
+
digest.update(str(path.relative_to(package)).encode() + path.read_bytes())
|
|
43
|
+
return digest.hexdigest()[:16]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _wheel(root: Path, tag: str, cache: Path) -> Path:
|
|
47
|
+
"""The wheel of the checkout at `root` as it is now, built once for each state of its code."""
|
|
48
|
+
out = cache / f"{root.name}-{tag}"
|
|
49
|
+
if not list(out.glob("*.whl")):
|
|
50
|
+
for old in cache.glob(f"{root.name}-*"):
|
|
51
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
52
|
+
done = subprocess.run(
|
|
53
|
+
[sys.executable, "-m", "pip", "wheel", "--no-deps", "--quiet", "--wheel-dir", str(out), str(root)],
|
|
54
|
+
capture_output=True, text=True, check=False,
|
|
55
|
+
)
|
|
56
|
+
if done.returncode or not list(out.glob("*.whl")):
|
|
57
|
+
raise RuntimeError(f"no wheel could be built from {root}:\n{done.stderr.strip()[-2000:]}")
|
|
58
|
+
return next(out.glob("*.whl"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _constraints(everything: bool) -> str:
|
|
62
|
+
"""`name==version` of what this environment holds: all of it, or what pip took from an index.
|
|
63
|
+
|
|
64
|
+
A local build is never pinned (no index has it), nor a name this environment has two
|
|
65
|
+
records of (which one runs is anyone's guess). What conda put here is pinned only when
|
|
66
|
+
`everything`: an index may lack that version, which is why the install has a second try.
|
|
67
|
+
"""
|
|
68
|
+
seen: dict[str, set[str]] = {}
|
|
69
|
+
for dist in metadata.distributions():
|
|
70
|
+
name, installer = dist.metadata.get("Name"), (dist.read_text("INSTALLER") or "").strip()
|
|
71
|
+
if not name: # a broken leftover in site-packages
|
|
72
|
+
continue
|
|
73
|
+
local = "+" in dist.version or (installer != "conda" and dist.read_text("direct_url.json"))
|
|
74
|
+
pin = not local and (everything or installer in ("pip", "uv"))
|
|
75
|
+
seen.setdefault(re.sub(r"[-_.]+", "-", name).lower(), set()).add(dist.version if pin else "")
|
|
76
|
+
pins = (f"{name}=={next(iter(versions))}" for name, versions in seen.items() if len(versions) == 1)
|
|
77
|
+
return "\n".join(sorted(pin for pin in pins if not pin.endswith("=="))) + "\n"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def app_recipe(
|
|
81
|
+
name: str,
|
|
82
|
+
requirements: Iterable[str],
|
|
83
|
+
imports: Iterable[str],
|
|
84
|
+
*,
|
|
85
|
+
prefix: str,
|
|
86
|
+
tools_home: str,
|
|
87
|
+
cache: Path | str,
|
|
88
|
+
manager: str = "",
|
|
89
|
+
) -> Recipe:
|
|
90
|
+
"""The recipe that gives a host the environment `requirements` run in here.
|
|
91
|
+
|
|
92
|
+
`requirements` are the packages of the app, each `name` or `name[extras]`: one imported
|
|
93
|
+
from a source checkout goes as a wheel of its code as it is now, one that is installed is
|
|
94
|
+
asked for in the version it has. `imports` are the modules that have to import there for
|
|
95
|
+
the environment to count as usable. `prefix` and `tools_home` are paths on the host (see
|
|
96
|
+
`Host.home`); `cache` is a directory of this machine for the wheels.
|
|
97
|
+
|
|
98
|
+
The environment is updated where it stands for as long as the Python is the same. Builds
|
|
99
|
+
wheels the first time, and after the code changes: keep it off UI threads.
|
|
100
|
+
"""
|
|
101
|
+
prefix, cache = str(prefix).rstrip("/"), Path(cache)
|
|
102
|
+
code, wheels, wanted, tags = f"{prefix}/{_CODE}", [], [], []
|
|
103
|
+
for requirement in requirements:
|
|
104
|
+
dist, extras = re.fullmatch(r"\s*([\w.-]+)\s*(\[[^]]*])?\s*", requirement).groups()
|
|
105
|
+
module = re.sub(r"[-.]+", "_", dist).lower()
|
|
106
|
+
root = _checkout(module)
|
|
107
|
+
if root is None:
|
|
108
|
+
wanted.append(f"{dist}{extras or ''}=={metadata.version(dist)}")
|
|
109
|
+
continue
|
|
110
|
+
tags.append(_tree_hash(root, module))
|
|
111
|
+
wheels.append(_wheel(root, tags[-1], cache))
|
|
112
|
+
wanted.append(f"{code}/{wheels[-1].name}{extras or ''}")
|
|
113
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
constraints, loose = cache / "constraints.txt", cache / "constraints-pip.txt"
|
|
115
|
+
constraints.write_text(_constraints(True), encoding="utf-8")
|
|
116
|
+
loose.write_text(_constraints(False), encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
pip = (f"{prefix}/bin/python", "-m", "pip", "install", "--progress-bar", "off")
|
|
119
|
+
# ponytail: one version the index lacks drops every pin of what conda installed here, not
|
|
120
|
+
# just that one. Drop them one by one if a host ever ends up too far from this environment.
|
|
121
|
+
pinned = (
|
|
122
|
+
'pip() { "$0" -m pip install --progress-bar off --constraint "$1" "${@:2}"; }; d=$1; shift; '
|
|
123
|
+
'pip "$d/constraints.txt" "$@" || '
|
|
124
|
+
'{ echo "not every version here can be had there: pinning what pip installed only"; '
|
|
125
|
+
'pip "$d/constraints-pip.txt" "$@"; }'
|
|
126
|
+
)
|
|
127
|
+
refresh = [
|
|
128
|
+
RecipeStep(
|
|
129
|
+
f"send the code of {name}",
|
|
130
|
+
("sh", "-c", 'rm -rf "$0" && mkdir -p "$0" && tar -x -C "$0"', code),
|
|
131
|
+
600.0,
|
|
132
|
+
send=(str(constraints), str(loose), *map(str, wheels)),
|
|
133
|
+
),
|
|
134
|
+
RecipeStep(
|
|
135
|
+
f"install {name} and what it needs",
|
|
136
|
+
("bash", "-c", pinned, f"{prefix}/bin/python", code, *wanted),
|
|
137
|
+
3600.0,
|
|
138
|
+
),
|
|
139
|
+
]
|
|
140
|
+
if wheels: # the version of a checkout does not change with its code: pip would keep the old one
|
|
141
|
+
refresh.append(
|
|
142
|
+
RecipeStep(
|
|
143
|
+
f"install the code of {name} as it is here",
|
|
144
|
+
(*pip, "--no-deps", "--force-reinstall", *(f"{code}/{wheel.name}" for wheel in wheels)),
|
|
145
|
+
600.0,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
conda = (f"python={platform.python_version()}", "pip")
|
|
149
|
+
values = {"prefix": prefix, "tools_home": str(tools_home).rstrip("/")}
|
|
150
|
+
short = lambda *parts: hashlib.sha256(repr(parts).encode()).hexdigest()[:16] # noqa: E731
|
|
151
|
+
return Recipe(
|
|
152
|
+
name=name,
|
|
153
|
+
prefix=prefix,
|
|
154
|
+
candidates=(f"{prefix}/bin/python",),
|
|
155
|
+
commands=(),
|
|
156
|
+
check=("{exe}", "-c", "import " + ", ".join(imports)),
|
|
157
|
+
steps=(*_env_steps(name, conda, (), ("conda-forge",), values, manager), *refresh),
|
|
158
|
+
conda=conda,
|
|
159
|
+
stamp=short(conda, wanted, tags, constraints.read_text(encoding="utf-8")),
|
|
160
|
+
refresh=tuple(refresh),
|
|
161
|
+
base=short(conda),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = ["app_recipe"]
|
ms_env/host.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""A host: where commands run. This machine, or another reached with the system's `ssh`.
|
|
2
|
+
|
|
3
|
+
No credential is handled here. `ssh` runs with `BatchMode=yes`: the keys and the agent of the
|
|
4
|
+
account log in or the command fails at once, and nothing is left listening on the other side.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HostError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Host:
|
|
16
|
+
"""A machine, known by what running a command on it gives back: `address` as `ssh` knows it (its
|
|
17
|
+
keys, users and jumps are in `~/.ssh/config`), or None for this one."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, address: str | None = None, ssh=("ssh", "-o", "BatchMode=yes")):
|
|
20
|
+
self.address, self.ssh = address or None, tuple(ssh)
|
|
21
|
+
|
|
22
|
+
def __repr__(self) -> str:
|
|
23
|
+
return f"Host({self.address!r})"
|
|
24
|
+
|
|
25
|
+
def argv(self, command: str) -> list[str]:
|
|
26
|
+
"""What to execute here for `sh` to run `command` there."""
|
|
27
|
+
return ["sh", "-c", command] if self.address is None else [*self.ssh, self.address, command]
|
|
28
|
+
|
|
29
|
+
def run(self, command: str, data=b"", timeout: float = 600) -> bytes:
|
|
30
|
+
"""What `command` printed there, given `data` (bytes, or an open file) to read."""
|
|
31
|
+
feed = {"input": data} if isinstance(data, bytes) else {"stdin": data}
|
|
32
|
+
done = subprocess.run(self.argv(command), **feed, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
33
|
+
timeout=timeout)
|
|
34
|
+
if done.returncode:
|
|
35
|
+
said = done.stderr.decode(errors="replace").strip()
|
|
36
|
+
if "Permission denied" in said: # BatchMode: ssh does not ask, and here nobody could answer
|
|
37
|
+
said += f"\nssh has to log in to {self.address} with a key, without asking: see ssh-copy-id"
|
|
38
|
+
if "Host key verification failed" in said: # the same: nobody here to say yes to a new key
|
|
39
|
+
said += f"\nssh does not know {self.address} yet: log in to it once by hand (`ssh {self.address}`)"
|
|
40
|
+
raise HostError(f"`{command[:200]}` on {self.address or 'this machine'}: {said or done.returncode}")
|
|
41
|
+
return done.stdout
|
|
42
|
+
|
|
43
|
+
def home(self) -> str:
|
|
44
|
+
"""The home of the account there: where a path of ours on that machine starts."""
|
|
45
|
+
return self.run('printf %s "$HOME"', timeout=60).decode()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
LOCAL = Host()
|
|
49
|
+
|
|
50
|
+
__all__ = ["LOCAL", "Host", "HostError"]
|
ms_env/recipes.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""External tools: find one that already works, or install it -- driven by a TOML file.
|
|
2
|
+
|
|
3
|
+
The app ships one TOML per tool: where to look for it, how to tell it works, how to
|
|
4
|
+
install it. A recipe is that TOML bound to one host (see ``ms_env.host``): this
|
|
5
|
+
machine, or a host over ssh. ``status`` says whether the tool works there and ``ensure``
|
|
6
|
+
installs it when it does not, on a daemon thread that belongs to the app, not to a
|
|
7
|
+
project (none has to be open).
|
|
8
|
+
|
|
9
|
+
Lookup on a host, first candidate whose check exits 0 wins: paths the user
|
|
10
|
+
registered, the managed install under ``tools_home``, then ``PATH``.
|
|
11
|
+
|
|
12
|
+
Whose a prefix is decides what happens to it. ``ensure`` stamps what it installs: a
|
|
13
|
+
stamped prefix is created again when the recipe changes, and ``remove`` deletes it. A
|
|
14
|
+
prefix that holds something without a stamp is the user's own environment: ``verify``
|
|
15
|
+
says what it lacks of what the recipe asks for, and nothing ever changes or deletes it.
|
|
16
|
+
|
|
17
|
+
TOML layout (every section but ``[tool]`` and ``[locate]`` is optional)::
|
|
18
|
+
|
|
19
|
+
[tool]
|
|
20
|
+
name = "openbabel"
|
|
21
|
+
label = "OpenBabel"
|
|
22
|
+
version = "3.1.1" # pinned here; never a user option
|
|
23
|
+
prefix = "{tools_home}/{name}/{version}" # the default
|
|
24
|
+
|
|
25
|
+
[locate]
|
|
26
|
+
paths = ["{prefix}/bin/obabel"] # where the install puts it
|
|
27
|
+
commands = ["obabel"] # looked up on PATH
|
|
28
|
+
check = ["{exe}", "-V"] # {exe} = the candidate
|
|
29
|
+
|
|
30
|
+
[env] # a conda prefix at {prefix}
|
|
31
|
+
conda = ["openbabel={version}"]
|
|
32
|
+
pip = []
|
|
33
|
+
|
|
34
|
+
[[install]] # plain steps, after [env]
|
|
35
|
+
name = "download the model"
|
|
36
|
+
argv = ["curl", "-fsSL", "-o", "{prefix}/model.pt", "https://..."]
|
|
37
|
+
timeout = 1800
|
|
38
|
+
|
|
39
|
+
Every command runs through ``sh`` on the host, so a host is a POSIX machine.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import dataclasses
|
|
44
|
+
import hashlib
|
|
45
|
+
import io
|
|
46
|
+
import json
|
|
47
|
+
import re
|
|
48
|
+
import shlex
|
|
49
|
+
import subprocess
|
|
50
|
+
import tarfile
|
|
51
|
+
import threading
|
|
52
|
+
import tomllib
|
|
53
|
+
from concurrent.futures import Future
|
|
54
|
+
from dataclasses import dataclass, field
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
from typing import Any, Iterable
|
|
57
|
+
|
|
58
|
+
from ms_env.steps import Step, StepFailed, run_step
|
|
59
|
+
from ms_env.host import LOCAL, Host
|
|
60
|
+
|
|
61
|
+
READY = "ready"
|
|
62
|
+
MISSING = "missing"
|
|
63
|
+
UNREACHABLE = "unreachable"
|
|
64
|
+
_SSH_CONNECT_FAILED = 255 # ssh's own exit code; anything else came from the remote command
|
|
65
|
+
STAMP = ".ms_env.json" # inside a prefix ms_env installed
|
|
66
|
+
_INSTALLING = ".installing" # beside a prefix while it is installed: what a cut install leaves is ours too
|
|
67
|
+
_USER = "user"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ----------------------------------------------------------------------------
|
|
71
|
+
# Spec (the TOML, still templated) -> Recipe (concrete for one host)
|
|
72
|
+
# ----------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class RecipeStep:
|
|
76
|
+
name: str
|
|
77
|
+
argv: tuple[str, ...]
|
|
78
|
+
timeout: float = 1800.0
|
|
79
|
+
# Files of this machine the command reads as a tar, each under its own name. Out of the
|
|
80
|
+
# repr, which is what the stamp of a tool is made of.
|
|
81
|
+
send: tuple[str, ...] = field(default=(), repr=False)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class Recipe:
|
|
86
|
+
name: str
|
|
87
|
+
prefix: str
|
|
88
|
+
candidates: tuple[str, ...] # executables tried first, in order
|
|
89
|
+
commands: tuple[str, ...] # then these names on PATH
|
|
90
|
+
check: tuple[str, ...] # "{exe}" marks the candidate
|
|
91
|
+
steps: tuple[RecipeStep, ...]
|
|
92
|
+
conda: tuple[str, ...] = () # what the environment at `prefix` has to hold
|
|
93
|
+
pip: tuple[str, ...] = ()
|
|
94
|
+
stamp: str = "" # hash of what the TOML asks for, the same on every host
|
|
95
|
+
# What brings a prefix up to this recipe without making it again, for one of ours whose
|
|
96
|
+
# stamp names the same `base`. Without a base a recipe that changed is installed from nothing.
|
|
97
|
+
refresh: tuple[RecipeStep, ...] = ()
|
|
98
|
+
base: str = ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class ToolSpec:
|
|
103
|
+
name: str
|
|
104
|
+
version: str
|
|
105
|
+
info: dict[str, Any] # the whole [tool] table: label, purpose, footprint...
|
|
106
|
+
prefix: str
|
|
107
|
+
paths: tuple[str, ...]
|
|
108
|
+
commands: tuple[str, ...]
|
|
109
|
+
check: tuple[str, ...]
|
|
110
|
+
conda: tuple[str, ...] = ()
|
|
111
|
+
pip: tuple[str, ...] = ()
|
|
112
|
+
channels: tuple[str, ...] = ("conda-forge",)
|
|
113
|
+
steps: tuple[RecipeStep, ...] = ()
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def load(cls, path: Path | str) -> "ToolSpec":
|
|
117
|
+
data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
|
|
118
|
+
tool, locate, env = data["tool"], data["locate"], data.get("env", {})
|
|
119
|
+
return cls(
|
|
120
|
+
name=str(tool["name"]),
|
|
121
|
+
version=str(tool.get("version", "")),
|
|
122
|
+
info=dict(tool),
|
|
123
|
+
prefix=str(tool.get("prefix", "{tools_home}/{name}/{version}")),
|
|
124
|
+
paths=tuple(locate.get("paths", ())),
|
|
125
|
+
commands=tuple(locate.get("commands", ())),
|
|
126
|
+
check=tuple(locate.get("check", ())),
|
|
127
|
+
conda=tuple(env.get("conda", ())),
|
|
128
|
+
pip=tuple(env.get("pip", ())),
|
|
129
|
+
channels=tuple(env.get("channels", ("conda-forge",))),
|
|
130
|
+
steps=tuple(
|
|
131
|
+
RecipeStep(str(step["name"]), tuple(step["argv"]), float(step.get("timeout", 1800)))
|
|
132
|
+
for step in data.get("install", ())
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def recipe(
|
|
137
|
+
self,
|
|
138
|
+
tools_home: str,
|
|
139
|
+
*,
|
|
140
|
+
prefix: str = "",
|
|
141
|
+
extra_paths: Iterable[str] = (),
|
|
142
|
+
manager: str = "",
|
|
143
|
+
) -> Recipe:
|
|
144
|
+
"""Bind the templates to one host. ``tools_home``/``prefix`` are paths *on that host*.
|
|
145
|
+
|
|
146
|
+
``prefix`` is a location the user chose instead of the managed one: an environment of
|
|
147
|
+
their own (checked, never changed) or a place to install into. ``extra_paths`` are
|
|
148
|
+
user-registered executables; ``manager`` is a conda-type program to use there instead
|
|
149
|
+
of micromamba.
|
|
150
|
+
"""
|
|
151
|
+
values = {"tools_home": str(tools_home).rstrip("/"), "name": self.name, "version": self.version, "exe": "{exe}"}
|
|
152
|
+
values["prefix"] = str(prefix or self.prefix.format_map(values)).rstrip("/")
|
|
153
|
+
fill = lambda items: tuple(str(item).format_map(values) for item in items) # noqa: E731
|
|
154
|
+
steps = _env_steps(self.name, fill(self.conda), fill(self.pip), fill(self.channels), values, manager)
|
|
155
|
+
steps += [RecipeStep(step.name, fill(step.argv), step.timeout) for step in self.steps]
|
|
156
|
+
return Recipe(
|
|
157
|
+
name=self.name,
|
|
158
|
+
prefix=values["prefix"],
|
|
159
|
+
candidates=(*map(str, extra_paths), *fill(self.paths)),
|
|
160
|
+
commands=self.commands,
|
|
161
|
+
check=fill(self.check),
|
|
162
|
+
steps=tuple(steps),
|
|
163
|
+
conda=fill(self.conda),
|
|
164
|
+
pip=fill(self.pip),
|
|
165
|
+
stamp=hashlib.sha256(
|
|
166
|
+
repr((self.name, self.version, self.conda, self.pip, self.channels, self.steps)).encode()
|
|
167
|
+
).hexdigest()[:16],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _env_steps(
|
|
172
|
+
name: str, conda: tuple[str, ...], pip: tuple[str, ...], channels: tuple[str, ...], values: dict, manager: str
|
|
173
|
+
) -> list[RecipeStep]:
|
|
174
|
+
if not conda and not pip:
|
|
175
|
+
return []
|
|
176
|
+
prefix = values["prefix"]
|
|
177
|
+
channel_args = [part for channel in channels for part in ("--channel", channel)]
|
|
178
|
+
create = shlex.join(["create", "--yes", "--quiet", "--prefix", prefix, *channel_args, *conda])
|
|
179
|
+
steps = [
|
|
180
|
+
RecipeStep(
|
|
181
|
+
f"create the {name} environment",
|
|
182
|
+
("sh", "-c", f'{_find_or_bootstrap_manager(manager, values["tools_home"])}\n"$M" {create}'),
|
|
183
|
+
)
|
|
184
|
+
]
|
|
185
|
+
if pip:
|
|
186
|
+
steps.append(
|
|
187
|
+
RecipeStep(
|
|
188
|
+
f"pip install into {name}",
|
|
189
|
+
(f"{prefix}/bin/python", "-m", "pip", "install", "--progress-bar", "off", *pip),
|
|
190
|
+
timeout=3600.0,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return steps
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _find_or_bootstrap_manager(hint: str, tools_home: str) -> str:
|
|
197
|
+
"""sh that leaves the manager in $M: the hint, micromamba on PATH, or one it downloads."""
|
|
198
|
+
root = shlex.quote(f"{tools_home}/micromamba")
|
|
199
|
+
return f"""M={shlex.quote(str(hint or ""))}
|
|
200
|
+
[ -x "$M" ] || M=$(command -v micromamba) || {{
|
|
201
|
+
case "$(uname -s)-$(uname -m)" in
|
|
202
|
+
Linux-x86_64) p=linux-64 ;; Linux-aarch64) p=linux-aarch64 ;;
|
|
203
|
+
Darwin-arm64) p=osx-arm64 ;; Darwin-x86_64) p=osx-64 ;;
|
|
204
|
+
*) echo "no micromamba here and no build of it for $(uname -sm)" >&2; exit 1 ;;
|
|
205
|
+
esac
|
|
206
|
+
M={root}/bin/micromamba
|
|
207
|
+
if [ ! -x "$M" ]; then
|
|
208
|
+
echo "no micromamba found: installing it into {root}"
|
|
209
|
+
mkdir -p {root} && curl -fsSL "https://micro.mamba.pm/api/micromamba/$p/latest" | tar -xj -C {root} bin/micromamba || exit 1
|
|
210
|
+
fi
|
|
211
|
+
export MAMBA_ROOT_PREFIX={root}
|
|
212
|
+
}}"""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ----------------------------------------------------------------------------
|
|
216
|
+
# A recipe on a host
|
|
217
|
+
# ----------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def _argv_on(argv: Iterable[str], host: Host) -> list[str]:
|
|
220
|
+
return list(argv) if host.address is None else host.argv(shlex.join(argv))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _prefix(recipe: Recipe) -> str:
|
|
224
|
+
if not recipe.prefix.strip("/ "):
|
|
225
|
+
raise ValueError(f"{recipe.name}: a recipe needs a prefix of its own, not {recipe.prefix!r}.")
|
|
226
|
+
return shlex.quote(recipe.prefix)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _stamp_text(recipe: Recipe) -> str:
|
|
230
|
+
return json.dumps({"name": recipe.name, "stamp": recipe.stamp, **({"base": recipe.base} if recipe.base else {})})
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _base(owner: str) -> str:
|
|
234
|
+
"""The base a stamp names; "" for what is not a stamp, or the stamp of a recipe without one."""
|
|
235
|
+
try:
|
|
236
|
+
return json.loads(owner).get("base", "")
|
|
237
|
+
except (ValueError, AttributeError):
|
|
238
|
+
return ""
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _tar(paths: Iterable[str]) -> bytes:
|
|
242
|
+
held = io.BytesIO()
|
|
243
|
+
with tarfile.open(fileobj=held, mode="w") as tar:
|
|
244
|
+
for path in paths:
|
|
245
|
+
tar.add(path, arcname=Path(path).name)
|
|
246
|
+
return held.getvalue()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _step(step: RecipeStep, label: str, host: Host) -> Step:
|
|
250
|
+
if not step.send:
|
|
251
|
+
return Step(label + step.name, argv=_argv_on(step.argv, host), timeout=step.timeout)
|
|
252
|
+
|
|
253
|
+
def send() -> str:
|
|
254
|
+
host.run(shlex.join(step.argv), data=_tar(step.send), timeout=step.timeout)
|
|
255
|
+
return "sent " + ", ".join(Path(path).name for path in step.send)
|
|
256
|
+
|
|
257
|
+
return Step(label + step.name, call=send, timeout=step.timeout)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def recipe_steps(recipe: Recipe, host: Host = LOCAL, refresh: bool = False) -> list[Step]:
|
|
261
|
+
"""The whole install: clear what ms_env left at the prefix, run the recipe, stamp it.
|
|
262
|
+
|
|
263
|
+
The first step fails on a prefix that holds something ms_env did not install. With
|
|
264
|
+
`refresh` the prefix stays and only `recipe.refresh` runs before the stamp.
|
|
265
|
+
"""
|
|
266
|
+
label, prefix = (f"{host.address}: " if host.address else ""), _prefix(recipe)
|
|
267
|
+
clear = (
|
|
268
|
+
f'p={prefix}\n'
|
|
269
|
+
f'if [ -n "$(ls -A "$p" 2>/dev/null)" ] && [ ! -f "$p/{STAMP}" ] && [ ! -f "$p{_INSTALLING}" ]; then\n'
|
|
270
|
+
' echo "$p holds an environment ms_env did not install: it is checked, never changed." >&2; exit 1\n'
|
|
271
|
+
"fi\n"
|
|
272
|
+
f'mkdir -p "$(dirname "$p")" && : > "$p{_INSTALLING}" && rm -rf -- "$p"'
|
|
273
|
+
)
|
|
274
|
+
stamp = (
|
|
275
|
+
f'p={prefix}\nmkdir -p "$p" && printf %s {shlex.quote(_stamp_text(recipe))} > "$p/{STAMP}" '
|
|
276
|
+
f'&& rm -f "$p{_INSTALLING}"'
|
|
277
|
+
)
|
|
278
|
+
steps = (
|
|
279
|
+
*(recipe.refresh if refresh else (RecipeStep(f"prepare {recipe.prefix}", ("sh", "-c", clear), 300.0), *recipe.steps)),
|
|
280
|
+
RecipeStep(f"record the {recipe.name} install", ("sh", "-c", stamp), 60.0),
|
|
281
|
+
)
|
|
282
|
+
return [_step(step, label, host) for step in steps]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _locate_script(recipe: Recipe) -> str:
|
|
286
|
+
check = " ".join('"$1"' if part == "{exe}" else shlex.quote(part) for part in recipe.check) or "true"
|
|
287
|
+
return (
|
|
288
|
+
f'ok() {{ [ -x "$1" ] && {check} >/dev/null 2>&1; }}\n'
|
|
289
|
+
f"for p in {' '.join(map(shlex.quote, recipe.candidates))}; do"
|
|
290
|
+
' ok "$p" && { echo "$p"; exit 0; }; done\n'
|
|
291
|
+
f"for c in {' '.join(map(shlex.quote, recipe.commands))}; do"
|
|
292
|
+
' p=$(command -v "$c") && ok "$p" && { echo "$p"; exit 0; }; done\n'
|
|
293
|
+
"exit 1"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def locate(recipe: Recipe, host: Host = LOCAL, timeout: float = 60.0) -> tuple[str, str]:
|
|
298
|
+
"""(READY, executable) or (MISSING | UNREACHABLE, ""). Blocks on a subprocess: keep it off UI threads."""
|
|
299
|
+
try:
|
|
300
|
+
result = subprocess.run(
|
|
301
|
+
host.argv(_locate_script(recipe)),
|
|
302
|
+
capture_output=True,
|
|
303
|
+
text=True,
|
|
304
|
+
timeout=timeout,
|
|
305
|
+
check=False,
|
|
306
|
+
)
|
|
307
|
+
except subprocess.TimeoutExpired:
|
|
308
|
+
return (UNREACHABLE if host.address else MISSING), ""
|
|
309
|
+
found = result.stdout.strip().splitlines()
|
|
310
|
+
if result.returncode == 0 and found:
|
|
311
|
+
return READY, found[-1]
|
|
312
|
+
return (UNREACHABLE if host.address and result.returncode == _SSH_CONNECT_FAILED else MISSING), ""
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def status(recipe: Recipe, host: Host = LOCAL) -> str:
|
|
316
|
+
"""READY, MISSING or UNREACHABLE. Blocks like `locate`, which also says where the tool is."""
|
|
317
|
+
return locate(recipe, host)[0]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ----------------------------------------------------------------------------
|
|
321
|
+
# Whose the prefix is, and whether it holds what the recipe asks for
|
|
322
|
+
# ----------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
def _owner(recipe: Recipe, host: Host) -> str:
|
|
325
|
+
"""The stamp of a prefix ms_env installed, `_USER` for one that holds something else, "" for none."""
|
|
326
|
+
script = (
|
|
327
|
+
f'p={_prefix(recipe)}\n'
|
|
328
|
+
f'[ -f "$p/{STAMP}" ] && {{ cat "$p/{STAMP}"; exit 0; }}\n'
|
|
329
|
+
f'[ -f "$p{_INSTALLING}" ] && {{ echo installing; exit 0; }}\n'
|
|
330
|
+
f'[ -n "$(ls -A "$p" 2>/dev/null)" ] && echo {_USER}\nexit 0'
|
|
331
|
+
)
|
|
332
|
+
return host.run(script, timeout=60).decode().strip()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _pin(spec: str) -> tuple[str, str]:
|
|
336
|
+
"""(name, version) of `name=1.2`, `name==1.2` or `name[extra]==1.2`. A range pins no version."""
|
|
337
|
+
name, version = re.match(r"\s*([\w.-]*)(?:\[[^]]*])?\s*(?:==?\s*([\w.!+-]+))?", spec).groups()
|
|
338
|
+
return name, (version or "").rstrip(".")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Run by the python of the prefix. ponytail: versions compare as text, so 1.0 is not 1.0.0;
|
|
342
|
+
# `packaging` on this side if a recipe ever pins that loosely.
|
|
343
|
+
_PIP_PINS = """import sys
|
|
344
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
345
|
+
for pin in sys.argv[1:]:
|
|
346
|
+
name, _, wanted = pin.partition("==")
|
|
347
|
+
try:
|
|
348
|
+
found = version(name)
|
|
349
|
+
except PackageNotFoundError:
|
|
350
|
+
found = "nothing"
|
|
351
|
+
if found == "nothing" or (wanted and found != wanted):
|
|
352
|
+
print(f"pip: {pin} is asked for, {found} is there")
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def verify(recipe: Recipe, host: Host = LOCAL) -> list[str]:
|
|
357
|
+
"""What the environment at the recipe's prefix lacks of what the recipe asks for; [] when it meets it.
|
|
358
|
+
|
|
359
|
+
The way to take an environment the user already has instead of installing one: every
|
|
360
|
+
pinned conda package (looked up in `conda-meta`), every pinned pip package (asked to the
|
|
361
|
+
python of the prefix) and the recipe's check on the executables of that prefix. Reads
|
|
362
|
+
only. ponytail: a pip URL or path is not looked up, and `>=` counts as "is there".
|
|
363
|
+
"""
|
|
364
|
+
lines = [f'p={_prefix(recipe)}', '[ -d "$p" ] || { echo "there is no directory $p"; exit 0; }',
|
|
365
|
+
'has() { ls "$p"/conda-meta/$1 >/dev/null 2>&1; }']
|
|
366
|
+
for name, version in map(_pin, recipe.conda):
|
|
367
|
+
# conda's `=1.2` takes 1.2 and 1.2.x; a file there is name-version-build.json
|
|
368
|
+
globs = [f"{name}-{version}-*.json", f"{name}-{version}.*-*.json"] if version else [f"{name}-[0-9]*-*.json"]
|
|
369
|
+
wanted = shlex.quote(f"conda: {name}{'=' + version if version else ''} is asked for and is not there")
|
|
370
|
+
lines.append(" || ".join([*(f"has {shlex.quote(glob)}" for glob in globs), f"echo {wanted}"]))
|
|
371
|
+
pins = ["==".join(filter(None, _pin(spec))) for spec in recipe.pip if "/" not in spec]
|
|
372
|
+
if pins:
|
|
373
|
+
lines.append(
|
|
374
|
+
f'"$p/bin/python" -c {shlex.quote(_PIP_PINS)} {shlex.join(pins)} 2>/dev/null'
|
|
375
|
+
' || echo "pip: no working python at $p/bin/python"'
|
|
376
|
+
)
|
|
377
|
+
lacks = host.run("\n".join(lines), timeout=120).decode().splitlines()
|
|
378
|
+
inside = tuple(path for path in recipe.candidates if path.startswith(recipe.prefix + "/"))
|
|
379
|
+
if recipe.check and status(dataclasses.replace(recipe, candidates=inside, commands=()), host) != READY:
|
|
380
|
+
lacks.append(f"check: `{shlex.join(recipe.check)}` passes for none of {', '.join(inside) or 'its executables'}")
|
|
381
|
+
return lacks
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def remove(recipe: Recipe, host: Host = LOCAL) -> bool:
|
|
385
|
+
"""Delete the prefix when ms_env installed it. False: nothing of ours there, nothing deleted."""
|
|
386
|
+
key = f"{recipe.name}@{host.address or 'local'}"
|
|
387
|
+
with _LOCK:
|
|
388
|
+
current = _INSTALLS.get(key)
|
|
389
|
+
if current is not None and not current.future.done():
|
|
390
|
+
raise RuntimeError(f"{recipe.name} is being installed on {host.address or 'this machine'}.")
|
|
391
|
+
script = (
|
|
392
|
+
f'p={_prefix(recipe)}\n[ -f "$p/{STAMP}" ] || [ -f "$p{_INSTALLING}" ] || {{ echo no; exit 0; }}\n'
|
|
393
|
+
f'rm -rf -- "$p" "$p{_INSTALLING}"'
|
|
394
|
+
)
|
|
395
|
+
return host.run(script, timeout=600).decode().strip() != "no"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ----------------------------------------------------------------------------
|
|
399
|
+
# Installs: app-level background threads
|
|
400
|
+
# ----------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
@dataclass
|
|
403
|
+
class Install:
|
|
404
|
+
key: str
|
|
405
|
+
future: Future = field(default_factory=Future)
|
|
406
|
+
lines: list[str] = field(default_factory=list) # live log, appended from the install thread
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
_INSTALLS: dict[str, Install] = {}
|
|
410
|
+
_LOCK = threading.Lock()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def ensure(recipe: Recipe, host: Host = LOCAL, busy: Callable[[], str] | None = None) -> Install:
|
|
414
|
+
"""Make sure the tool is there: start installing it, or join the install already running.
|
|
415
|
+
|
|
416
|
+
Returns at once: wait on ``future`` and read ``lines`` for the log. Nothing is installed
|
|
417
|
+
where the tool is already usable. An install of ours made from another recipe is made
|
|
418
|
+
again, or brought up to this one where it stands when the recipe says how (that is the
|
|
419
|
+
update); an environment of the user is verified and left as it is.
|
|
420
|
+
|
|
421
|
+
``busy`` is asked only before an install of ours is changed: what it returns is why that
|
|
422
|
+
cannot be done now (something is running from it), and the install fails saying so.
|
|
423
|
+
"""
|
|
424
|
+
key = f"{recipe.name}@{host.address or 'local'}"
|
|
425
|
+
with _LOCK:
|
|
426
|
+
current = _INSTALLS.get(key)
|
|
427
|
+
if current is not None and not current.future.done():
|
|
428
|
+
return current
|
|
429
|
+
install = _INSTALLS[key] = Install(key)
|
|
430
|
+
# ponytail: one daemon thread per install; a pool if installs pile up.
|
|
431
|
+
# Daemon on purpose: quitting the app must not wait 30 min for a conda solve.
|
|
432
|
+
threading.Thread(target=_run_install, args=(install, recipe, host, busy), name=f"ms_env-{key}", daemon=True).start()
|
|
433
|
+
return install
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _run_install(install: Install, recipe: Recipe, host: Host, busy: Callable[[], str] | None = None) -> None:
|
|
437
|
+
install.future.set_running_or_notify_cancel()
|
|
438
|
+
try:
|
|
439
|
+
where = host.address or "this machine"
|
|
440
|
+
owner = _owner(recipe, host)
|
|
441
|
+
if owner == _USER:
|
|
442
|
+
lacks = verify(recipe, host)
|
|
443
|
+
if lacks:
|
|
444
|
+
raise StepFailed(
|
|
445
|
+
f"{recipe.prefix} on {where} does not meet the {recipe.name} recipe:\n" + "\n".join(lacks)
|
|
446
|
+
)
|
|
447
|
+
install.lines.append(f"{recipe.prefix} on {where} meets the {recipe.name} recipe: used as it is.")
|
|
448
|
+
elif owner in ("", _stamp_text(recipe)) and status(recipe, host) == READY:
|
|
449
|
+
install.lines.append(f"{recipe.name} is already usable on {where}.")
|
|
450
|
+
else:
|
|
451
|
+
why = busy() if busy and owner.startswith("{") else "" # a stamp: an install of ours that ended
|
|
452
|
+
if why:
|
|
453
|
+
raise StepFailed(f"{recipe.name} on {where} is not as this recipe asks, and is left as it is: {why}")
|
|
454
|
+
for step in recipe_steps(recipe, host, refresh=bool(recipe.base) and _base(owner) == recipe.base):
|
|
455
|
+
run_step(step, install.lines.append)
|
|
456
|
+
state = status(recipe, host)
|
|
457
|
+
if state != READY:
|
|
458
|
+
raise StepFailed(f"{recipe.name}: every step ran but the tool is still {state} on {where}.")
|
|
459
|
+
except Exception as exc: # noqa: BLE001 -- whatever broke, dependents must see a failure
|
|
460
|
+
install.lines.append(str(exc))
|
|
461
|
+
install.future.set_exception(exc)
|
|
462
|
+
else:
|
|
463
|
+
install.future.set_result(None)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
__all__ = [
|
|
467
|
+
"Install",
|
|
468
|
+
"MISSING",
|
|
469
|
+
"READY",
|
|
470
|
+
"Recipe",
|
|
471
|
+
"RecipeStep",
|
|
472
|
+
"STAMP",
|
|
473
|
+
"ToolSpec",
|
|
474
|
+
"UNREACHABLE",
|
|
475
|
+
"ensure",
|
|
476
|
+
"locate",
|
|
477
|
+
"recipe_steps",
|
|
478
|
+
"remove",
|
|
479
|
+
"status",
|
|
480
|
+
"verify",
|
|
481
|
+
]
|
ms_env/steps.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Steps: named units of work that run one after another, their output read line by line.
|
|
2
|
+
|
|
3
|
+
Plain standard library and Qt-free: a UI only renders `Step` lists and the lines they print.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, Iterable, Optional
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT = 30.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StepFailed(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class Step:
|
|
24
|
+
"""One named unit of work: either a command line or an in-process call."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
argv: Optional[list[str]] = None
|
|
28
|
+
call: Optional[Callable[[], str]] = None
|
|
29
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_step(step: Step, on_line: Callable[[str], None] | None = None) -> None:
|
|
33
|
+
"""Run one step, streaming its output. Raises StepFailed on failure."""
|
|
34
|
+
emit = on_line or (lambda _line: None)
|
|
35
|
+
if step.call is not None:
|
|
36
|
+
message = step.call()
|
|
37
|
+
if message:
|
|
38
|
+
emit(message)
|
|
39
|
+
return
|
|
40
|
+
if not step.argv:
|
|
41
|
+
return
|
|
42
|
+
emit("$ " + " ".join(step.argv))
|
|
43
|
+
try:
|
|
44
|
+
process = subprocess.Popen(
|
|
45
|
+
step.argv,
|
|
46
|
+
stdout=subprocess.PIPE,
|
|
47
|
+
stderr=subprocess.STDOUT,
|
|
48
|
+
text=True,
|
|
49
|
+
bufsize=1,
|
|
50
|
+
)
|
|
51
|
+
except FileNotFoundError as exc:
|
|
52
|
+
raise StepFailed(f"{step.name}: command not found ({exc.filename}).") from exc
|
|
53
|
+
deadline = time.monotonic() + step.timeout
|
|
54
|
+
assert process.stdout is not None
|
|
55
|
+
for line in process.stdout:
|
|
56
|
+
emit(line.rstrip())
|
|
57
|
+
if time.monotonic() > deadline:
|
|
58
|
+
process.kill()
|
|
59
|
+
raise StepFailed(f"{step.name}: timed out after {step.timeout:.0f}s.")
|
|
60
|
+
try:
|
|
61
|
+
code = process.wait(timeout=max(1.0, deadline - time.monotonic()))
|
|
62
|
+
except subprocess.TimeoutExpired:
|
|
63
|
+
process.kill()
|
|
64
|
+
raise StepFailed(f"{step.name}: timed out after {step.timeout:.0f}s.") from None
|
|
65
|
+
if code != 0:
|
|
66
|
+
raise StepFailed(f"{step.name}: exited with code {code}.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_steps(
|
|
70
|
+
steps: Iterable[Step],
|
|
71
|
+
on_line: Callable[[str], None] | None = None,
|
|
72
|
+
on_step: Callable[[int, Step], None] | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
for index, step in enumerate(steps):
|
|
75
|
+
if on_step is not None:
|
|
76
|
+
on_step(index, step)
|
|
77
|
+
run_step(step, on_line)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def find_environment_manager(*extra_variables: str) -> Path | None:
|
|
81
|
+
"""The program that creates environments here: micromamba, unless a variable names another.
|
|
82
|
+
|
|
83
|
+
``extra_variables`` are app-specific overrides, read before ``MOLSUITE_ENV_MANAGER``; any
|
|
84
|
+
of them may name a conda or a mamba. None: the recipe downloads its own micromamba.
|
|
85
|
+
"""
|
|
86
|
+
named = [str(os.environ.get(variable) or "").strip() for variable in (*extra_variables, "MOLSUITE_ENV_MANAGER")]
|
|
87
|
+
for candidate in (*filter(None, named), shutil.which("micromamba")):
|
|
88
|
+
path = Path(candidate or "").expanduser()
|
|
89
|
+
if path.is_file() and os.access(path, os.X_OK):
|
|
90
|
+
return path.resolve()
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = ["DEFAULT_TIMEOUT", "Step", "StepFailed", "find_environment_manager", "run_step", "run_steps"]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ms_env
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Environments for MolSuite apps: find or install external tools, on this machine or over ssh.
|
|
5
|
+
Author: Mario S. Valdés-Tresanco, Mario E. Valdés-Tresanco
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/MolSuite/ms_env
|
|
8
|
+
Project-URL: Documentation, https://molsuite.github.io/ms_env/
|
|
9
|
+
Project-URL: Issues, https://github.com/MolSuite/ms_env/issues
|
|
10
|
+
Keywords: environments,conda,micromamba,ssh,molsuite
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# ms_env
|
|
25
|
+
|
|
26
|
+
Environments for MolSuite apps: find an external tool that already works, or install
|
|
27
|
+
it, on this machine or on a host reached over `ssh`. Standard library only.
|
|
28
|
+
|
|
29
|
+
A **recipe** says how to tell that a tool works and how to install it. A **host**
|
|
30
|
+
says where. `ms_env` applies one to the other.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from ms_env import READY, Host, ToolSpec, ensure, locate
|
|
34
|
+
|
|
35
|
+
spec = ToolSpec.load("openbabel.toml")
|
|
36
|
+
|
|
37
|
+
recipe = spec.recipe("/home/me/.myapp/tools") # bound to this machine
|
|
38
|
+
state, executable = locate(recipe) # (READY, ".../bin/obabel") or (MISSING, "")
|
|
39
|
+
if state != READY:
|
|
40
|
+
install = ensure(recipe) # returns at once: a daemon thread installs
|
|
41
|
+
install.future.result() # wait; `install.lines` is the live log
|
|
42
|
+
|
|
43
|
+
cluster = Host("me@login.hpc") # as `ssh` knows it
|
|
44
|
+
ensure(spec.recipe("/home/me/.myapp/tools"), cluster).future.result()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## A recipe
|
|
48
|
+
|
|
49
|
+
One TOML file per tool, shipped by the app:
|
|
50
|
+
|
|
51
|
+
```toml
|
|
52
|
+
[tool]
|
|
53
|
+
name = "openbabel"
|
|
54
|
+
label = "OpenBabel"
|
|
55
|
+
version = "3.1.1" # pinned here; never a user option
|
|
56
|
+
prefix = "{tools_home}/{name}/{version}" # the default
|
|
57
|
+
|
|
58
|
+
[locate]
|
|
59
|
+
paths = ["{prefix}/bin/obabel"] # where the install puts it
|
|
60
|
+
commands = ["obabel"] # looked up on PATH
|
|
61
|
+
check = ["{exe}", "-V"] # {exe} = the candidate
|
|
62
|
+
|
|
63
|
+
[env] # a conda prefix at {prefix}
|
|
64
|
+
conda = ["openbabel={version}"]
|
|
65
|
+
pip = []
|
|
66
|
+
|
|
67
|
+
[[install]] # plain steps, after [env]
|
|
68
|
+
name = "download the model"
|
|
69
|
+
argv = ["curl", "-fsSL", "-o", "{prefix}/model.pt", "https://..."]
|
|
70
|
+
timeout = 1800
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Lookup on a host, first candidate whose check exits 0 wins: the paths the user
|
|
74
|
+
registered, the managed install under `tools_home`, then `PATH`. `[env]` is created
|
|
75
|
+
with micromamba: the one on the host's `PATH`, or one downloaded into `tools_home`.
|
|
76
|
+
Another conda-type program is used only when the app names it (`manager=`).
|
|
77
|
+
|
|
78
|
+
## Whose an environment is
|
|
79
|
+
|
|
80
|
+
`ensure` stamps the prefix it installs. The stamp decides what may happen to it:
|
|
81
|
+
|
|
82
|
+
| At the prefix | `ensure` | `remove` |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| Nothing | Creates it | Nothing to delete |
|
|
85
|
+
| A stamp of this recipe | Nothing | Deletes it |
|
|
86
|
+
| A stamp of another recipe (the TOML changed) | Creates it again: the update | Deletes it |
|
|
87
|
+
| Something without a stamp: the user's own environment | Verifies it, never changes it | Leaves it |
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from ms_env import remove, verify
|
|
91
|
+
|
|
92
|
+
mine = spec.recipe("/home/me/.myapp/tools", prefix="/home/me/envs/babel") # an environment I already have
|
|
93
|
+
verify(mine) # [] when it meets the recipe, else what it lacks:
|
|
94
|
+
# ["conda: openbabel=3.1.1 is asked for and is not there"]
|
|
95
|
+
remove(recipe) # True: ms_env had installed it and it is gone
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`verify` looks up every pinned conda package, every pinned pip package and the
|
|
99
|
+
recipe's check, and only reads. An executable registered on its own (`extra_paths=`)
|
|
100
|
+
is not an environment: it only has to pass the check.
|
|
101
|
+
|
|
102
|
+
`ensure(recipe, host, busy=why)` asks `why()` before it changes an install of its own:
|
|
103
|
+
a reason (`"2 jobs are running from it"`) makes the install fail and leaves the prefix
|
|
104
|
+
as it is.
|
|
105
|
+
|
|
106
|
+
## A host
|
|
107
|
+
|
|
108
|
+
`Host()` is this machine. `Host("me@login.hpc")` runs every command through the system's
|
|
109
|
+
`ssh` with `BatchMode=yes`: users, keys and jumps come from `~/.ssh/config`, the key
|
|
110
|
+
logs in or the command fails at once, no credential is handled and nothing is left
|
|
111
|
+
listening on the other side. A host is a POSIX machine; only Linux is supported.
|
|
112
|
+
|
|
113
|
+
## The environment of an app
|
|
114
|
+
|
|
115
|
+
`app_recipe` makes the recipe that gives a host the environment an app runs in here:
|
|
116
|
+
the same Python, the same versions, and the code of a source checkout as it is now.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from ms_env import app_recipe
|
|
120
|
+
|
|
121
|
+
home = cluster.home()
|
|
122
|
+
recipe = app_recipe("myapp", ["myapp"], ["myapp"], prefix=f"{home}/.myapp/env",
|
|
123
|
+
tools_home=f"{home}/.myapp/tools", cache="/home/me/.myapp/cache/wheels")
|
|
124
|
+
ensure(recipe, cluster).future.result()
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Install
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
pip install ms_env
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Documentation: <https://molsuite.github.io/ms_env/>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ms_env/__init__.py,sha256=RzjgrvC-z3s6rlaiG8A7ilt4hsZpIT66kXuj6Yv-_zA,1019
|
|
2
|
+
ms_env/app.py,sha256=DSD-Z5hPmARL_hi7_VYhczCDb_0RZCJIyEcOlq2y_9U,7457
|
|
3
|
+
ms_env/host.py,sha256=1WA4vibBeWo1J3T9oJzpxOAK1u96uY_N7kua57mcJb4,2316
|
|
4
|
+
ms_env/recipes.py,sha256=0cusgSc4HFKxDhZEZgWZgH090Qdascy9cyd6u7fMlVg,20521
|
|
5
|
+
ms_env/steps.py,sha256=mM-W3bAjrB-0URHT8cwt0vk5TPfmWSUrEojJj40RodY,3143
|
|
6
|
+
ms_env-0.2.0.dist-info/licenses/LICENSE,sha256=BnhLi3mlB9CxUBdj6SKp9izP481uYTrbbWvWii7ZrLE,1065
|
|
7
|
+
ms_env-0.2.0.dist-info/METADATA,sha256=muPayPCnvP8qVbPrbejfYdbk4D9rOhGEIj8cd4o7fi0,5096
|
|
8
|
+
ms_env-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
ms_env-0.2.0.dist-info/top_level.txt,sha256=DzhK1x5Ja_vgzaa0wEhRpY7UrBL8eY6zxfLzbFtANh8,7
|
|
10
|
+
ms_env-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MolSuite
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ms_env
|