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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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