Skip to content

API reference

Every name is imported from ms_env.

ms_env

Environments for MolSuite apps: a recipe says how to tell that a tool works and how to install it, a host says where, and ensure applies one to the other.

The lowest layer of MolSuite: the standard library, no Qt, and nothing of ms_flow.

Modules:

Name Description
app

The environment of an app on a host: the Python that runs here, with the same versions of

host

A host: where commands run. This machine, or another reached with the system's ssh.

recipes

External tools: find one that already works, or install it -- driven by a TOML file.

steps

Steps: named units of work that run one after another, their output read line by line.

Classes:

Name Description
Install
Recipe
RecipeStep
ToolSpec
Step

One named unit of work: either a command line or an in-process call.

StepFailed
Host

A machine, known by what running a command on it gives back: address as ssh knows it (its

HostError

Functions:

Name Description
app_recipe

The recipe that gives a host the environment requirements run in here.

ensure

Make sure the tool is there: start installing it, or join the install already running.

locate

(READY, executable) or (MISSING | UNREACHABLE, ""). Blocks on a subprocess: keep it off UI threads.

recipe_steps

The whole install: clear what ms_env left at the prefix, run the recipe, stamp it.

remove

Delete the prefix when ms_env installed it. False: nothing of ours there, nothing deleted.

status

READY, MISSING or UNREACHABLE. Blocks like locate, which also says where the tool is.

verify

What the environment at the recipe's prefix lacks of what the recipe asks for; [] when it meets it.

find_environment_manager

The program that creates environments here: micromamba, unless a variable names another.

run_step

Run one step, streaming its output. Raises StepFailed on failure.

run_steps

Attributes:

Name Type Description
MISSING
READY
STAMP
UNREACHABLE
LOCAL

MISSING module-attribute

MISSING = 'missing'

READY module-attribute

READY = 'ready'

STAMP module-attribute

STAMP = '.ms_env.json'

UNREACHABLE module-attribute

UNREACHABLE = 'unreachable'

LOCAL module-attribute

LOCAL = Host()

Install dataclass

Install(key: str, future: Future = Future(), lines: list[str] = list())

Attributes:

Name Type Description
key str
future Future
lines list[str]

key instance-attribute

key: str

future class-attribute instance-attribute

future: Future = field(default_factory=Future)

lines class-attribute instance-attribute

lines: list[str] = field(default_factory=list)

Recipe dataclass

Recipe(name: str, prefix: str, candidates: tuple[str, ...], commands: tuple[str, ...], check: tuple[str, ...], steps: tuple[RecipeStep, ...], conda: tuple[str, ...] = (), pip: tuple[str, ...] = (), stamp: str = '', refresh: tuple[RecipeStep, ...] = (), base: str = '')

Attributes:

Name Type Description
name str
prefix str
candidates tuple[str, ...]
commands tuple[str, ...]
check tuple[str, ...]
steps tuple[RecipeStep, ...]
conda tuple[str, ...]
pip tuple[str, ...]
stamp str
refresh tuple[RecipeStep, ...]
base str

name instance-attribute

name: str

prefix instance-attribute

prefix: str

candidates instance-attribute

candidates: tuple[str, ...]

commands instance-attribute

commands: tuple[str, ...]

check instance-attribute

check: tuple[str, ...]

steps instance-attribute

steps: tuple[RecipeStep, ...]

conda class-attribute instance-attribute

conda: tuple[str, ...] = ()

pip class-attribute instance-attribute

pip: tuple[str, ...] = ()

stamp class-attribute instance-attribute

stamp: str = ''

refresh class-attribute instance-attribute

refresh: tuple[RecipeStep, ...] = ()

base class-attribute instance-attribute

base: str = ''

RecipeStep dataclass

RecipeStep(name: str, argv: tuple[str, ...], timeout: float = 1800.0, send: tuple[str, ...] = ())

Attributes:

Name Type Description
name str
argv tuple[str, ...]
timeout float
send tuple[str, ...]

name instance-attribute

name: str

argv instance-attribute

argv: tuple[str, ...]

timeout class-attribute instance-attribute

timeout: float = 1800.0

send class-attribute instance-attribute

send: tuple[str, ...] = field(default=(), repr=False)

ToolSpec dataclass

ToolSpec(name: str, version: str, info: dict[str, Any], prefix: str, paths: tuple[str, ...], commands: tuple[str, ...], check: tuple[str, ...], conda: tuple[str, ...] = (), pip: tuple[str, ...] = (), channels: tuple[str, ...] = ('conda-forge',), steps: tuple[RecipeStep, ...] = ())

Methods:

Name Description
load
recipe

Bind the templates to one host. tools_home/prefix are paths on that host.

Attributes:

Name Type Description
name str
version str
info dict[str, Any]
prefix str
paths tuple[str, ...]
commands tuple[str, ...]
check tuple[str, ...]
conda tuple[str, ...]
pip tuple[str, ...]
channels tuple[str, ...]
steps tuple[RecipeStep, ...]

name instance-attribute

name: str

version instance-attribute

version: str

info instance-attribute

info: dict[str, Any]

prefix instance-attribute

prefix: str

paths instance-attribute

paths: tuple[str, ...]

commands instance-attribute

commands: tuple[str, ...]

check instance-attribute

check: tuple[str, ...]

conda class-attribute instance-attribute

conda: tuple[str, ...] = ()

pip class-attribute instance-attribute

pip: tuple[str, ...] = ()

channels class-attribute instance-attribute

channels: tuple[str, ...] = ('conda-forge',)

steps class-attribute instance-attribute

steps: tuple[RecipeStep, ...] = ()

load classmethod

load(path: Path | str) -> 'ToolSpec'
Source code in src/ms_env/recipes.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
@classmethod
def load(cls, path: Path | str) -> "ToolSpec":
    data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
    tool, locate, env = data["tool"], data["locate"], data.get("env", {})
    return cls(
        name=str(tool["name"]),
        version=str(tool.get("version", "")),
        info=dict(tool),
        prefix=str(tool.get("prefix", "{tools_home}/{name}/{version}")),
        paths=tuple(locate.get("paths", ())),
        commands=tuple(locate.get("commands", ())),
        check=tuple(locate.get("check", ())),
        conda=tuple(env.get("conda", ())),
        pip=tuple(env.get("pip", ())),
        channels=tuple(env.get("channels", ("conda-forge",))),
        steps=tuple(
            RecipeStep(str(step["name"]), tuple(step["argv"]), float(step.get("timeout", 1800)))
            for step in data.get("install", ())
        ),
    )

recipe

recipe(tools_home: str, *, prefix: str = '', extra_paths: Iterable[str] = (), manager: str = '') -> Recipe

Bind the templates to one host. tools_home/prefix are paths on that host.

prefix is a location the user chose instead of the managed one: an environment of their own (checked, never changed) or a place to install into. extra_paths are user-registered executables; manager is a conda-type program to use there instead of micromamba.

Source code in src/ms_env/recipes.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def recipe(
    self,
    tools_home: str,
    *,
    prefix: str = "",
    extra_paths: Iterable[str] = (),
    manager: str = "",
) -> Recipe:
    """Bind the templates to one host. ``tools_home``/``prefix`` are paths *on that host*.

    ``prefix`` is a location the user chose instead of the managed one: an environment of
    their own (checked, never changed) or a place to install into. ``extra_paths`` are
    user-registered executables; ``manager`` is a conda-type program to use there instead
    of micromamba.
    """
    values = {"tools_home": str(tools_home).rstrip("/"), "name": self.name, "version": self.version, "exe": "{exe}"}
    values["prefix"] = str(prefix or self.prefix.format_map(values)).rstrip("/")
    fill = lambda items: tuple(str(item).format_map(values) for item in items)  # noqa: E731
    steps = _env_steps(self.name, fill(self.conda), fill(self.pip), fill(self.channels), values, manager)
    steps += [RecipeStep(step.name, fill(step.argv), step.timeout) for step in self.steps]
    return Recipe(
        name=self.name,
        prefix=values["prefix"],
        candidates=(*map(str, extra_paths), *fill(self.paths)),
        commands=self.commands,
        check=fill(self.check),
        steps=tuple(steps),
        conda=fill(self.conda),
        pip=fill(self.pip),
        stamp=hashlib.sha256(
            repr((self.name, self.version, self.conda, self.pip, self.channels, self.steps)).encode()
        ).hexdigest()[:16],
    )

Step dataclass

Step(name: str, argv: Optional[list[str]] = None, call: Optional[Callable[[], str]] = None, timeout: float = DEFAULT_TIMEOUT)

One named unit of work: either a command line or an in-process call.

Attributes:

Name Type Description
name str
argv Optional[list[str]]
call Optional[Callable[[], str]]
timeout float

name instance-attribute

name: str

argv class-attribute instance-attribute

argv: Optional[list[str]] = None

call class-attribute instance-attribute

call: Optional[Callable[[], str]] = None

timeout class-attribute instance-attribute

timeout: float = DEFAULT_TIMEOUT

StepFailed

Bases: RuntimeError

Host

Host(address: str | None = None, ssh=('ssh', '-o', 'BatchMode=yes'))

A machine, known by what running a command on it gives back: address as ssh knows it (its keys, users and jumps are in ~/.ssh/config), or None for this one.

Methods:

Name Description
__repr__
argv

What to execute here for sh to run command there.

run

What command printed there, given data (bytes, or an open file) to read.

home

The home of the account there: where a path of ours on that machine starts.

Source code in src/ms_env/host.py
19
20
def __init__(self, address: str | None = None, ssh=("ssh", "-o", "BatchMode=yes")):
    self.address, self.ssh = address or None, tuple(ssh)

__repr__

__repr__() -> str
Source code in src/ms_env/host.py
22
23
def __repr__(self) -> str:
    return f"Host({self.address!r})"

argv

argv(command: str) -> list[str]

What to execute here for sh to run command there.

Source code in src/ms_env/host.py
25
26
27
def argv(self, command: str) -> list[str]:
    """What to execute here for `sh` to run `command` there."""
    return ["sh", "-c", command] if self.address is None else [*self.ssh, self.address, command]

run

run(command: str, data=b'', timeout: float = 600) -> bytes

What command printed there, given data (bytes, or an open file) to read.

Source code in src/ms_env/host.py
29
30
31
32
33
34
35
36
37
38
39
40
41
def run(self, command: str, data=b"", timeout: float = 600) -> bytes:
    """What `command` printed there, given `data` (bytes, or an open file) to read."""
    feed = {"input": data} if isinstance(data, bytes) else {"stdin": data}
    done = subprocess.run(self.argv(command), **feed, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                          timeout=timeout)
    if done.returncode:
        said = done.stderr.decode(errors="replace").strip()
        if "Permission denied" in said:  # BatchMode: ssh does not ask, and here nobody could answer
            said += f"\nssh has to log in to {self.address} with a key, without asking: see ssh-copy-id"
        if "Host key verification failed" in said:  # the same: nobody here to say yes to a new key
            said += f"\nssh does not know {self.address} yet: log in to it once by hand (`ssh {self.address}`)"
        raise HostError(f"`{command[:200]}` on {self.address or 'this machine'}: {said or done.returncode}")
    return done.stdout

home

home() -> str

The home of the account there: where a path of ours on that machine starts.

Source code in src/ms_env/host.py
43
44
45
def home(self) -> str:
    """The home of the account there: where a path of ours on that machine starts."""
    return self.run('printf %s "$HOME"', timeout=60).decode()

HostError

Bases: RuntimeError

app_recipe

app_recipe(name: str, requirements: Iterable[str], imports: Iterable[str], *, prefix: str, tools_home: str, cache: Path | str, manager: str = '') -> Recipe

The recipe that gives a host the environment requirements run in here.

requirements are the packages of the app, each name or name[extras]: one imported from a source checkout goes as a wheel of its code as it is now, one that is installed is asked for in the version it has. imports are the modules that have to import there for the environment to count as usable. prefix and tools_home are paths on the host (see Host.home); cache is a directory of this machine for the wheels.

The environment is updated where it stands for as long as the Python is the same. Builds wheels the first time, and after the code changes: keep it off UI threads.

Source code in src/ms_env/app.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def app_recipe(
    name: str,
    requirements: Iterable[str],
    imports: Iterable[str],
    *,
    prefix: str,
    tools_home: str,
    cache: Path | str,
    manager: str = "",
) -> Recipe:
    """The recipe that gives a host the environment `requirements` run in here.

    `requirements` are the packages of the app, each `name` or `name[extras]`: one imported
    from a source checkout goes as a wheel of its code as it is now, one that is installed is
    asked for in the version it has. `imports` are the modules that have to import there for
    the environment to count as usable. `prefix` and `tools_home` are paths on the host (see
    `Host.home`); `cache` is a directory of this machine for the wheels.

    The environment is updated where it stands for as long as the Python is the same. Builds
    wheels the first time, and after the code changes: keep it off UI threads.
    """
    prefix, cache = str(prefix).rstrip("/"), Path(cache)
    code, wheels, wanted, tags = f"{prefix}/{_CODE}", [], [], []
    for requirement in requirements:
        dist, extras = re.fullmatch(r"\s*([\w.-]+)\s*(\[[^]]*])?\s*", requirement).groups()
        module = re.sub(r"[-.]+", "_", dist).lower()
        root = _checkout(module)
        if root is None:
            wanted.append(f"{dist}{extras or ''}=={metadata.version(dist)}")
            continue
        tags.append(_tree_hash(root, module))
        wheels.append(_wheel(root, tags[-1], cache))
        wanted.append(f"{code}/{wheels[-1].name}{extras or ''}")
    cache.mkdir(parents=True, exist_ok=True)
    constraints, loose = cache / "constraints.txt", cache / "constraints-pip.txt"
    constraints.write_text(_constraints(True), encoding="utf-8")
    loose.write_text(_constraints(False), encoding="utf-8")

    pip = (f"{prefix}/bin/python", "-m", "pip", "install", "--progress-bar", "off")
    # ponytail: one version the index lacks drops every pin of what conda installed here, not
    # just that one. Drop them one by one if a host ever ends up too far from this environment.
    pinned = (
        'pip() { "$0" -m pip install --progress-bar off --constraint "$1" "${@:2}"; }; d=$1; shift; '
        'pip "$d/constraints.txt" "$@" || '
        '{ echo "not every version here can be had there: pinning what pip installed only"; '
        'pip "$d/constraints-pip.txt" "$@"; }'
    )
    refresh = [
        RecipeStep(
            f"send the code of {name}",
            ("sh", "-c", 'rm -rf "$0" && mkdir -p "$0" && tar -x -C "$0"', code),
            600.0,
            send=(str(constraints), str(loose), *map(str, wheels)),
        ),
        RecipeStep(
            f"install {name} and what it needs",
            ("bash", "-c", pinned, f"{prefix}/bin/python", code, *wanted),
            3600.0,
        ),
    ]
    if wheels:  # the version of a checkout does not change with its code: pip would keep the old one
        refresh.append(
            RecipeStep(
                f"install the code of {name} as it is here",
                (*pip, "--no-deps", "--force-reinstall", *(f"{code}/{wheel.name}" for wheel in wheels)),
                600.0,
            )
        )
    conda = (f"python={platform.python_version()}", "pip")
    values = {"prefix": prefix, "tools_home": str(tools_home).rstrip("/")}
    short = lambda *parts: hashlib.sha256(repr(parts).encode()).hexdigest()[:16]  # noqa: E731
    return Recipe(
        name=name,
        prefix=prefix,
        candidates=(f"{prefix}/bin/python",),
        commands=(),
        check=("{exe}", "-c", "import " + ", ".join(imports)),
        steps=(*_env_steps(name, conda, (), ("conda-forge",), values, manager), *refresh),
        conda=conda,
        stamp=short(conda, wanted, tags, constraints.read_text(encoding="utf-8")),
        refresh=tuple(refresh),
        base=short(conda),
    )

ensure

ensure(recipe: Recipe, host: Host = LOCAL, busy: Callable[[], str] | None = None) -> Install

Make sure the tool is there: start installing it, or join the install already running.

Returns at once: wait on future and read lines for the log. Nothing is installed where the tool is already usable. An install of ours made from another recipe is made again, or brought up to this one where it stands when the recipe says how (that is the update); an environment of the user is verified and left as it is.

busy is asked only before an install of ours is changed: what it returns is why that cannot be done now (something is running from it), and the install fails saying so.

Source code in src/ms_env/recipes.py
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
def ensure(recipe: Recipe, host: Host = LOCAL, busy: Callable[[], str] | None = None) -> Install:
    """Make sure the tool is there: start installing it, or join the install already running.

    Returns at once: wait on ``future`` and read ``lines`` for the log. Nothing is installed
    where the tool is already usable. An install of ours made from another recipe is made
    again, or brought up to this one where it stands when the recipe says how (that is the
    update); an environment of the user is verified and left as it is.

    ``busy`` is asked only before an install of ours is changed: what it returns is why that
    cannot be done now (something is running from it), and the install fails saying so.
    """
    key = f"{recipe.name}@{host.address or 'local'}"
    with _LOCK:
        current = _INSTALLS.get(key)
        if current is not None and not current.future.done():
            return current
        install = _INSTALLS[key] = Install(key)
    # ponytail: one daemon thread per install; a pool if installs pile up.
    # Daemon on purpose: quitting the app must not wait 30 min for a conda solve.
    threading.Thread(target=_run_install, args=(install, recipe, host, busy), name=f"ms_env-{key}", daemon=True).start()
    return install

locate

locate(recipe: Recipe, host: Host = LOCAL, timeout: float = 60.0) -> tuple[str, str]

(READY, executable) or (MISSING | UNREACHABLE, ""). Blocks on a subprocess: keep it off UI threads.

Source code in src/ms_env/recipes.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
def locate(recipe: Recipe, host: Host = LOCAL, timeout: float = 60.0) -> tuple[str, str]:
    """(READY, executable) or (MISSING | UNREACHABLE, ""). Blocks on a subprocess: keep it off UI threads."""
    try:
        result = subprocess.run(
            host.argv(_locate_script(recipe)),
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return (UNREACHABLE if host.address else MISSING), ""
    found = result.stdout.strip().splitlines()
    if result.returncode == 0 and found:
        return READY, found[-1]
    return (UNREACHABLE if host.address and result.returncode == _SSH_CONNECT_FAILED else MISSING), ""

recipe_steps

recipe_steps(recipe: Recipe, host: Host = LOCAL, refresh: bool = False) -> list[Step]

The whole install: clear what ms_env left at the prefix, run the recipe, stamp it.

The first step fails on a prefix that holds something ms_env did not install. With refresh the prefix stays and only recipe.refresh runs before the stamp.

Source code in src/ms_env/recipes.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
def recipe_steps(recipe: Recipe, host: Host = LOCAL, refresh: bool = False) -> list[Step]:
    """The whole install: clear what ms_env left at the prefix, run the recipe, stamp it.

    The first step fails on a prefix that holds something ms_env did not install. With
    `refresh` the prefix stays and only `recipe.refresh` runs before the stamp.
    """
    label, prefix = (f"{host.address}: " if host.address else ""), _prefix(recipe)
    clear = (
        f'p={prefix}\n'
        f'if [ -n "$(ls -A "$p" 2>/dev/null)" ] && [ ! -f "$p/{STAMP}" ] && [ ! -f "$p{_INSTALLING}" ]; then\n'
        '  echo "$p holds an environment ms_env did not install: it is checked, never changed." >&2; exit 1\n'
        "fi\n"
        f'mkdir -p "$(dirname "$p")" && : > "$p{_INSTALLING}" && rm -rf -- "$p"'
    )
    stamp = (
        f'p={prefix}\nmkdir -p "$p" && printf %s {shlex.quote(_stamp_text(recipe))} > "$p/{STAMP}" '
        f'&& rm -f "$p{_INSTALLING}"'
    )
    steps = (
        *(recipe.refresh if refresh else (RecipeStep(f"prepare {recipe.prefix}", ("sh", "-c", clear), 300.0), *recipe.steps)),
        RecipeStep(f"record the {recipe.name} install", ("sh", "-c", stamp), 60.0),
    )
    return [_step(step, label, host) for step in steps]

remove

remove(recipe: Recipe, host: Host = LOCAL) -> bool

Delete the prefix when ms_env installed it. False: nothing of ours there, nothing deleted.

Source code in src/ms_env/recipes.py
384
385
386
387
388
389
390
391
392
393
394
395
def remove(recipe: Recipe, host: Host = LOCAL) -> bool:
    """Delete the prefix when ms_env installed it. False: nothing of ours there, nothing deleted."""
    key = f"{recipe.name}@{host.address or 'local'}"
    with _LOCK:
        current = _INSTALLS.get(key)
        if current is not None and not current.future.done():
            raise RuntimeError(f"{recipe.name} is being installed on {host.address or 'this machine'}.")
    script = (
        f'p={_prefix(recipe)}\n[ -f "$p/{STAMP}" ] || [ -f "$p{_INSTALLING}" ] || {{ echo no; exit 0; }}\n'
        f'rm -rf -- "$p" "$p{_INSTALLING}"'
    )
    return host.run(script, timeout=600).decode().strip() != "no"

status

status(recipe: Recipe, host: Host = LOCAL) -> str

READY, MISSING or UNREACHABLE. Blocks like locate, which also says where the tool is.

Source code in src/ms_env/recipes.py
315
316
317
def status(recipe: Recipe, host: Host = LOCAL) -> str:
    """READY, MISSING or UNREACHABLE. Blocks like `locate`, which also says where the tool is."""
    return locate(recipe, host)[0]

verify

verify(recipe: Recipe, host: Host = LOCAL) -> list[str]

What the environment at the recipe's prefix lacks of what the recipe asks for; [] when it meets it.

The way to take an environment the user already has instead of installing one: every pinned conda package (looked up in conda-meta), every pinned pip package (asked to the python of the prefix) and the recipe's check on the executables of that prefix. Reads only. ponytail: a pip URL or path is not looked up, and >= counts as "is there".

Source code in src/ms_env/recipes.py
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def verify(recipe: Recipe, host: Host = LOCAL) -> list[str]:
    """What the environment at the recipe's prefix lacks of what the recipe asks for; [] when it meets it.

    The way to take an environment the user already has instead of installing one: every
    pinned conda package (looked up in `conda-meta`), every pinned pip package (asked to the
    python of the prefix) and the recipe's check on the executables of that prefix. Reads
    only. ponytail: a pip URL or path is not looked up, and `>=` counts as "is there".
    """
    lines = [f'p={_prefix(recipe)}', '[ -d "$p" ] || { echo "there is no directory $p"; exit 0; }',
             'has() { ls "$p"/conda-meta/$1 >/dev/null 2>&1; }']
    for name, version in map(_pin, recipe.conda):
        # conda's `=1.2` takes 1.2 and 1.2.x; a file there is name-version-build.json
        globs = [f"{name}-{version}-*.json", f"{name}-{version}.*-*.json"] if version else [f"{name}-[0-9]*-*.json"]
        wanted = shlex.quote(f"conda: {name}{'=' + version if version else ''} is asked for and is not there")
        lines.append(" || ".join([*(f"has {shlex.quote(glob)}" for glob in globs), f"echo {wanted}"]))
    pins = ["==".join(filter(None, _pin(spec))) for spec in recipe.pip if "/" not in spec]
    if pins:
        lines.append(
            f'"$p/bin/python" -c {shlex.quote(_PIP_PINS)} {shlex.join(pins)} 2>/dev/null'
            ' || echo "pip: no working python at $p/bin/python"'
        )
    lacks = host.run("\n".join(lines), timeout=120).decode().splitlines()
    inside = tuple(path for path in recipe.candidates if path.startswith(recipe.prefix + "/"))
    if recipe.check and status(dataclasses.replace(recipe, candidates=inside, commands=()), host) != READY:
        lacks.append(f"check: `{shlex.join(recipe.check)}` passes for none of {', '.join(inside) or 'its executables'}")
    return lacks

find_environment_manager

find_environment_manager(*extra_variables: str) -> Path | None

The program that creates environments here: micromamba, unless a variable names another.

extra_variables are app-specific overrides, read before MOLSUITE_ENV_MANAGER; any of them may name a conda or a mamba. None: the recipe downloads its own micromamba.

Source code in src/ms_env/steps.py
80
81
82
83
84
85
86
87
88
89
90
91
def find_environment_manager(*extra_variables: str) -> Path | None:
    """The program that creates environments here: micromamba, unless a variable names another.

    ``extra_variables`` are app-specific overrides, read before ``MOLSUITE_ENV_MANAGER``; any
    of them may name a conda or a mamba. None: the recipe downloads its own micromamba.
    """
    named = [str(os.environ.get(variable) or "").strip() for variable in (*extra_variables, "MOLSUITE_ENV_MANAGER")]
    for candidate in (*filter(None, named), shutil.which("micromamba")):
        path = Path(candidate or "").expanduser()
        if path.is_file() and os.access(path, os.X_OK):
            return path.resolve()
    return None

run_step

run_step(step: Step, on_line: Callable[[str], None] | None = None) -> None

Run one step, streaming its output. Raises StepFailed on failure.

Source code in src/ms_env/steps.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def run_step(step: Step, on_line: Callable[[str], None] | None = None) -> None:
    """Run one step, streaming its output. Raises StepFailed on failure."""
    emit = on_line or (lambda _line: None)
    if step.call is not None:
        message = step.call()
        if message:
            emit(message)
        return
    if not step.argv:
        return
    emit("$ " + " ".join(step.argv))
    try:
        process = subprocess.Popen(
            step.argv,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1,
        )
    except FileNotFoundError as exc:
        raise StepFailed(f"{step.name}: command not found ({exc.filename}).") from exc
    deadline = time.monotonic() + step.timeout
    assert process.stdout is not None
    for line in process.stdout:
        emit(line.rstrip())
        if time.monotonic() > deadline:
            process.kill()
            raise StepFailed(f"{step.name}: timed out after {step.timeout:.0f}s.")
    try:
        code = process.wait(timeout=max(1.0, deadline - time.monotonic()))
    except subprocess.TimeoutExpired:
        process.kill()
        raise StepFailed(f"{step.name}: timed out after {step.timeout:.0f}s.") from None
    if code != 0:
        raise StepFailed(f"{step.name}: exited with code {code}.")

run_steps

run_steps(steps: Iterable[Step], on_line: Callable[[str], None] | None = None, on_step: Callable[[int, Step], None] | None = None) -> None
Source code in src/ms_env/steps.py
69
70
71
72
73
74
75
76
77
def run_steps(
    steps: Iterable[Step],
    on_line: Callable[[str], None] | None = None,
    on_step: Callable[[int, Step], None] | None = None,
) -> None:
    for index, step in enumerate(steps):
        if on_step is not None:
            on_step(index, step)
        run_step(step, on_line)