rootstock 0.5.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.
- rootstock/__init__.py +34 -0
- rootstock/calculator.py +194 -0
- rootstock/cli.py +426 -0
- rootstock/clusters.py +41 -0
- rootstock/environment.py +238 -0
- rootstock/pep723.py +172 -0
- rootstock/protocol.py +309 -0
- rootstock/server.py +287 -0
- rootstock/worker.py +273 -0
- rootstock-0.5.0.dist-info/METADATA +210 -0
- rootstock-0.5.0.dist-info/RECORD +14 -0
- rootstock-0.5.0.dist-info/WHEEL +4 -0
- rootstock-0.5.0.dist-info/entry_points.txt +2 -0
- rootstock-0.5.0.dist-info/licenses/LICENSE.md +7 -0
rootstock/environment.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Environment management for Rootstock.
|
|
3
|
+
|
|
4
|
+
This module handles:
|
|
5
|
+
- Managing pre-built virtual environments
|
|
6
|
+
- Generating wrapper scripts for worker processes
|
|
7
|
+
- Providing spawn commands for worker processes
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_model_cache_env(root: Path) -> dict[str, str]:
|
|
19
|
+
"""
|
|
20
|
+
Get environment variables to redirect model downloads to shared cache.
|
|
21
|
+
|
|
22
|
+
We set HOME to redirect libraries that use ~/ for caching (FAIRChem, MatGL).
|
|
23
|
+
We also set XDG_CACHE_HOME for libraries that respect it (MACE).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
root: Rootstock root directory.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Dict of environment variables for model caching.
|
|
30
|
+
"""
|
|
31
|
+
cache_dir = root / "cache"
|
|
32
|
+
home_dir = root / "home"
|
|
33
|
+
return {
|
|
34
|
+
# Redirect HOME so libraries using ~/ find the shared cache
|
|
35
|
+
"HOME": str(home_dir),
|
|
36
|
+
# XDG base directory - catches MACE and other well-behaved libraries
|
|
37
|
+
"XDG_CACHE_HOME": str(cache_dir),
|
|
38
|
+
# HuggingFace explicit (some tools check these before XDG)
|
|
39
|
+
"HF_HOME": str(cache_dir / "huggingface"),
|
|
40
|
+
"HF_HUB_CACHE": str(cache_dir / "huggingface" / "hub"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Simplified wrapper template for pre-built environments
|
|
45
|
+
# No PEP 723 metadata needed since dependencies are already installed
|
|
46
|
+
WRAPPER_TEMPLATE = """
|
|
47
|
+
import sys
|
|
48
|
+
sys.path.insert(0, "{env_dir}")
|
|
49
|
+
from env_source import setup
|
|
50
|
+
from rootstock.worker import run_worker
|
|
51
|
+
|
|
52
|
+
run_worker(
|
|
53
|
+
setup_fn=setup,
|
|
54
|
+
model="{model}",
|
|
55
|
+
device="{device}",
|
|
56
|
+
socket_path="{socket_path}",
|
|
57
|
+
)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EnvironmentManager:
|
|
62
|
+
"""
|
|
63
|
+
Manages pre-built rootstock environments and worker spawning.
|
|
64
|
+
|
|
65
|
+
In v0.4+, environments are pre-built virtual environments located in
|
|
66
|
+
{root}/envs/{env_name}/. The environment source file is copied into
|
|
67
|
+
the venv as env_source.py during build.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, root: Path | str):
|
|
71
|
+
"""
|
|
72
|
+
Initialize the environment manager.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
root: Root directory for environments and cache.
|
|
76
|
+
"""
|
|
77
|
+
self.root = Path(root)
|
|
78
|
+
self._temp_files: list[Path] = []
|
|
79
|
+
|
|
80
|
+
def get_env_python(self, env_name: str) -> Path:
|
|
81
|
+
"""
|
|
82
|
+
Get path to Python executable for a pre-built environment.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
env_name: Name of the environment (e.g., "mace_env").
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Path to the environment's Python executable.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
RuntimeError: If the environment is not built.
|
|
92
|
+
"""
|
|
93
|
+
env_python = self.root / "envs" / env_name / "bin" / "python"
|
|
94
|
+
|
|
95
|
+
if not env_python.exists():
|
|
96
|
+
envs_dir = self.root / "envs"
|
|
97
|
+
if envs_dir.exists():
|
|
98
|
+
available = [p.name for p in envs_dir.iterdir() if p.is_dir()]
|
|
99
|
+
else:
|
|
100
|
+
available = []
|
|
101
|
+
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
f"Environment '{env_name}' not built. "
|
|
104
|
+
f"Run: rootstock build {env_name} --root {self.root}\n"
|
|
105
|
+
f"Available environments: {available}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return env_python
|
|
109
|
+
|
|
110
|
+
def generate_wrapper(
|
|
111
|
+
self,
|
|
112
|
+
env_name: str,
|
|
113
|
+
model: str,
|
|
114
|
+
device: str,
|
|
115
|
+
socket_path: str,
|
|
116
|
+
) -> Path:
|
|
117
|
+
"""
|
|
118
|
+
Generate a wrapper script for the given environment.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
env_name: Name of the pre-built environment
|
|
122
|
+
model: Model identifier to pass to setup()
|
|
123
|
+
device: Device string to pass to setup()
|
|
124
|
+
socket_path: Unix socket path for IPC
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Path to the generated wrapper script (temp file).
|
|
128
|
+
"""
|
|
129
|
+
env_dir = self.root / "envs" / env_name
|
|
130
|
+
|
|
131
|
+
# Generate wrapper content
|
|
132
|
+
wrapper_content = WRAPPER_TEMPLATE.format(
|
|
133
|
+
env_dir=str(env_dir),
|
|
134
|
+
model=model,
|
|
135
|
+
device=device,
|
|
136
|
+
socket_path=socket_path,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Write to temp file
|
|
140
|
+
fd, tmp_path = tempfile.mkstemp(suffix=".py", prefix="rootstock_wrapper_")
|
|
141
|
+
with open(fd, "w") as f:
|
|
142
|
+
f.write(wrapper_content)
|
|
143
|
+
|
|
144
|
+
tmp_path = Path(tmp_path)
|
|
145
|
+
self._temp_files.append(tmp_path)
|
|
146
|
+
|
|
147
|
+
return tmp_path
|
|
148
|
+
|
|
149
|
+
def get_spawn_command(self, env_name: str, wrapper_path: Path) -> list[str]:
|
|
150
|
+
"""
|
|
151
|
+
Get the command to spawn a worker using pre-built environment.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
env_name: Name of the pre-built environment.
|
|
155
|
+
wrapper_path: Path to the generated wrapper script.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Command list for subprocess.Popen, e.g.:
|
|
159
|
+
["/vol/rootstock/envs/mace_env/bin/python", "/tmp/wrapper.py"]
|
|
160
|
+
"""
|
|
161
|
+
env_python = self.get_env_python(env_name)
|
|
162
|
+
return [str(env_python), str(wrapper_path)]
|
|
163
|
+
|
|
164
|
+
def get_environment_variables(self) -> dict[str, str]:
|
|
165
|
+
"""
|
|
166
|
+
Get environment variables to set for the worker process.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Dict of environment variables for model caching.
|
|
170
|
+
"""
|
|
171
|
+
env = os.environ.copy()
|
|
172
|
+
env.update(get_model_cache_env(self.root))
|
|
173
|
+
return env
|
|
174
|
+
|
|
175
|
+
def cleanup(self):
|
|
176
|
+
"""Clean up temporary wrapper files."""
|
|
177
|
+
for path in self._temp_files:
|
|
178
|
+
try:
|
|
179
|
+
path.unlink(missing_ok=True)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
self._temp_files.clear()
|
|
183
|
+
|
|
184
|
+
def __del__(self):
|
|
185
|
+
self.cleanup()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def check_uv_available() -> bool:
|
|
189
|
+
"""Check if uv is available in PATH."""
|
|
190
|
+
return shutil.which("uv") is not None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def list_environments(root: Path | str) -> list[tuple[str, Path]]:
|
|
194
|
+
"""
|
|
195
|
+
List registered environment source files.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
root: Root directory containing environments/
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
List of (name, path) tuples for each environment source file.
|
|
202
|
+
"""
|
|
203
|
+
root = Path(root)
|
|
204
|
+
env_dir = root / "environments"
|
|
205
|
+
|
|
206
|
+
if not env_dir.exists():
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
result = []
|
|
210
|
+
for path in sorted(env_dir.glob("*.py")):
|
|
211
|
+
name = path.stem
|
|
212
|
+
result.append((name, path))
|
|
213
|
+
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def list_built_environments(root: Path | str) -> list[tuple[str, Path]]:
|
|
218
|
+
"""
|
|
219
|
+
List pre-built environments.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
root: Root directory containing envs/
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
List of (name, path) tuples for each built environment.
|
|
226
|
+
"""
|
|
227
|
+
root = Path(root)
|
|
228
|
+
envs_dir = root / "envs"
|
|
229
|
+
|
|
230
|
+
if not envs_dir.exists():
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
result = []
|
|
234
|
+
for path in sorted(envs_dir.iterdir()):
|
|
235
|
+
if path.is_dir() and (path / "bin" / "python").exists():
|
|
236
|
+
result.append((path.name, path))
|
|
237
|
+
|
|
238
|
+
return result
|
rootstock/pep723.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PEP 723 inline script metadata parser.
|
|
3
|
+
|
|
4
|
+
PEP 723 defines a standard format for embedding metadata in Python scripts
|
|
5
|
+
using a TOML block in comments:
|
|
6
|
+
|
|
7
|
+
# /// script
|
|
8
|
+
# requires-python = ">=3.10"
|
|
9
|
+
# dependencies = ["numpy", "ase"]
|
|
10
|
+
# ///
|
|
11
|
+
|
|
12
|
+
Reference: https://peps.python.org/pep-0723/
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
if sys.version_info >= (3, 11):
|
|
23
|
+
import tomllib
|
|
24
|
+
else:
|
|
25
|
+
import tomli as tomllib
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Pattern to match PEP 723 metadata block
|
|
29
|
+
# Matches: # /// script\n...# ///
|
|
30
|
+
PEP723_PATTERN = re.compile(
|
|
31
|
+
r"^# /// script\s*\n((?:#[^\n]*\n)*?)# ///$",
|
|
32
|
+
re.MULTILINE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_pep723_metadata(content: str) -> dict | None:
|
|
37
|
+
"""
|
|
38
|
+
Extract PEP 723 metadata from script content.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
content: The full content of a Python script
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Dict with parsed TOML metadata, or None if no valid metadata block found.
|
|
45
|
+
Typical keys: 'requires-python', 'dependencies'
|
|
46
|
+
"""
|
|
47
|
+
match = PEP723_PATTERN.search(content)
|
|
48
|
+
if match is None:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
# Extract the TOML content, stripping leading "# " from each line
|
|
52
|
+
toml_lines = []
|
|
53
|
+
for line in match.group(1).splitlines():
|
|
54
|
+
# Remove leading "# " or just "#" for empty lines
|
|
55
|
+
if line.startswith("# "):
|
|
56
|
+
toml_lines.append(line[2:])
|
|
57
|
+
elif line == "#":
|
|
58
|
+
toml_lines.append("")
|
|
59
|
+
else:
|
|
60
|
+
# Shouldn't happen with well-formed metadata, but handle it
|
|
61
|
+
toml_lines.append(line.lstrip("# "))
|
|
62
|
+
|
|
63
|
+
toml_content = "\n".join(toml_lines)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
return tomllib.loads(toml_content)
|
|
67
|
+
except tomllib.TOMLDecodeError:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def validate_environment_file(path: Path | str) -> tuple[bool, str]:
|
|
72
|
+
"""
|
|
73
|
+
Validate that a file is a valid Rootstock environment file.
|
|
74
|
+
|
|
75
|
+
A valid environment file must:
|
|
76
|
+
1. Exist and be readable
|
|
77
|
+
2. Have valid PEP 723 metadata with dependencies
|
|
78
|
+
3. Define a setup() function at module level
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
path: Path to the environment file
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
(is_valid, message) tuple. If valid, message is "OK".
|
|
85
|
+
If invalid, message describes the problem.
|
|
86
|
+
"""
|
|
87
|
+
path = Path(path)
|
|
88
|
+
|
|
89
|
+
# Check file exists
|
|
90
|
+
if not path.exists():
|
|
91
|
+
return False, f"File not found: {path}"
|
|
92
|
+
|
|
93
|
+
if not path.is_file():
|
|
94
|
+
return False, f"Not a file: {path}"
|
|
95
|
+
|
|
96
|
+
# Read content
|
|
97
|
+
try:
|
|
98
|
+
content = path.read_text()
|
|
99
|
+
except Exception as e:
|
|
100
|
+
return False, f"Cannot read file: {e}"
|
|
101
|
+
|
|
102
|
+
# Check PEP 723 metadata
|
|
103
|
+
metadata = parse_pep723_metadata(content)
|
|
104
|
+
if metadata is None:
|
|
105
|
+
return False, "No valid PEP 723 metadata block found"
|
|
106
|
+
|
|
107
|
+
if "dependencies" not in metadata:
|
|
108
|
+
return False, "PEP 723 metadata missing 'dependencies' field"
|
|
109
|
+
|
|
110
|
+
# Check for setup() function using AST
|
|
111
|
+
try:
|
|
112
|
+
tree = ast.parse(content, filename=str(path))
|
|
113
|
+
except SyntaxError as e:
|
|
114
|
+
return False, f"Syntax error in file: {e}"
|
|
115
|
+
|
|
116
|
+
# Look for a function named 'setup' at module level
|
|
117
|
+
setup_found = False
|
|
118
|
+
for node in ast.iter_child_nodes(tree):
|
|
119
|
+
if isinstance(node, ast.FunctionDef) and node.name == "setup":
|
|
120
|
+
setup_found = True
|
|
121
|
+
# Check it has at least 'model' parameter
|
|
122
|
+
args = node.args
|
|
123
|
+
if len(args.args) == 0 and len(args.posonlyargs) == 0:
|
|
124
|
+
return False, "setup() function must accept at least 'model' parameter"
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
if not setup_found:
|
|
128
|
+
return False, "No setup() function found at module level"
|
|
129
|
+
|
|
130
|
+
return True, "OK"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_dependencies(path: Path | str) -> list[str]:
|
|
134
|
+
"""
|
|
135
|
+
Get the dependencies list from an environment file.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
path: Path to the environment file
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of dependency strings, or empty list if not found.
|
|
142
|
+
"""
|
|
143
|
+
path = Path(path)
|
|
144
|
+
try:
|
|
145
|
+
content = path.read_text()
|
|
146
|
+
metadata = parse_pep723_metadata(content)
|
|
147
|
+
if metadata and "dependencies" in metadata:
|
|
148
|
+
return metadata["dependencies"]
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_requires_python(path: Path | str) -> str | None:
|
|
155
|
+
"""
|
|
156
|
+
Get the requires-python specifier from an environment file.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
path: Path to the environment file
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Python version specifier string, or None if not specified.
|
|
163
|
+
"""
|
|
164
|
+
path = Path(path)
|
|
165
|
+
try:
|
|
166
|
+
content = path.read_text()
|
|
167
|
+
metadata = parse_pep723_metadata(content)
|
|
168
|
+
if metadata:
|
|
169
|
+
return metadata.get("requires-python")
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
return None
|