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.
@@ -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