env-module-manager 0.5.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (37) hide show
  1. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/PKG-INFO +8 -1
  2. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/README.md +7 -0
  3. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/collective-environments.md +2 -0
  4. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/python-tools.md +31 -0
  5. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/reference.md +18 -0
  6. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/module_manager/__init__.py +1 -1
  7. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/module_manager/cli.py +95 -0
  8. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/module_manager/deploy.py +183 -1
  9. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/pyproject.toml +1 -1
  10. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/tests/test_cli.py +57 -0
  11. env_module_manager-0.7.0/tests/test_integration.py +138 -0
  12. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/tests/test_modulefile.py +139 -0
  13. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/uv.lock +1 -1
  14. env_module_manager-0.5.0/tests/test_integration.py +0 -49
  15. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.github/workflows/publish.yml +0 -0
  16. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.github/workflows/tag.yml +0 -0
  17. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.github/workflows/test.yml +0 -0
  18. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.gitignore +0 -0
  19. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.python-version +0 -0
  20. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.readthedocs.yaml +0 -0
  21. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/AGENTS.md +0 -0
  22. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/CONTRIBUTING.md +0 -0
  23. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/LICENSE.md +0 -0
  24. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/configuration.md +0 -0
  25. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/development.md +0 -0
  26. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/environment-modules.md +0 -0
  27. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/index.md +0 -0
  28. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/installation.md +0 -0
  29. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/rust-tools.md +0 -0
  30. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/shell-tools.md +0 -0
  31. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/stylesheets/extra.css +0 -0
  32. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/uninstall.md +0 -0
  33. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/example.bash +0 -0
  34. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/mkdocs.yml +0 -0
  35. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/module_manager/config.py +0 -0
  36. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/module_manager/modulefile.py +0 -0
  37. {env_module_manager-0.5.0 → env_module_manager-0.7.0}/tests/test_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: env-module-manager
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Deploy Python, Rust, shell, and collective CLI environments behind GNU environment modulefiles
5
5
  Project-URL: Homepage, https://github.com/ejb90/module-manager
6
6
  Project-URL: Documentation, https://module-manager.readthedocs.io/
@@ -73,6 +73,7 @@ Deploy a Python CLI with `uv tool install`:
73
73
  ```sh
74
74
  module-manager deploy-python ruff 0.8.0 \
75
75
  --package 'ruff==0.8.0' \
76
+ --constraints /prod/constraints/runtime.txt \
76
77
  --prefix /prod/tools \
77
78
  --module-root /prod/modulefiles \
78
79
  --execute-install
@@ -87,6 +88,12 @@ module-manager deploy-rust ripgrep 14.1.1 \
87
88
  --module-root /prod/modulefiles
88
89
  ```
89
90
 
91
+ Generate a constraints file for a Python CLI:
92
+
93
+ ```sh
94
+ module-manager auto-constraints ruff==0.8.0 --output constraints.txt
95
+ ```
96
+
90
97
  Deploy a shell script:
91
98
 
92
99
  ```sh
@@ -41,6 +41,7 @@ Deploy a Python CLI with `uv tool install`:
41
41
  ```sh
42
42
  module-manager deploy-python ruff 0.8.0 \
43
43
  --package 'ruff==0.8.0' \
44
+ --constraints /prod/constraints/runtime.txt \
44
45
  --prefix /prod/tools \
45
46
  --module-root /prod/modulefiles \
46
47
  --execute-install
@@ -55,6 +56,12 @@ module-manager deploy-rust ripgrep 14.1.1 \
55
56
  --module-root /prod/modulefiles
56
57
  ```
57
58
 
59
+ Generate a constraints file for a Python CLI:
60
+
61
+ ```sh
62
+ module-manager auto-constraints ruff==0.8.0 --output constraints.txt
63
+ ```
64
+
58
65
  Deploy a shell script:
59
66
 
60
67
  ```sh
@@ -22,6 +22,7 @@ package = "ruff==0.8.0"
22
22
  python = "3.12"
23
23
  indexes = ["https://packages.example/simple"]
24
24
  find_links = ["/prod/wheels"]
25
+ constraints = ["/prod/constraints/runtime.txt"]
25
26
  uv_config_file = "/prod/config/uv.toml"
26
27
 
27
28
  [[tools]]
@@ -59,6 +60,7 @@ module load dev-tools
59
60
  ```
60
61
 
61
62
  Relative `binary` and `script` paths are resolved relative to the manifest file.
63
+ Relative Python `constraints` paths are resolved the same way.
62
64
 
63
65
  ## Preview
64
66
 
@@ -55,6 +55,37 @@ module-manager deploy-python gitconductor 0.1.0 \
55
55
 
56
56
  `--find-links` may be used more than once.
57
57
 
58
+ ## Use Constraint Files
59
+
60
+ Pass requirement constraint files to `uv tool install`:
61
+
62
+ ```sh
63
+ module-manager deploy-python gitconductor 0.1.0 \
64
+ --package gitconductor==0.1.0 \
65
+ --constraints /prod/constraints/runtime.txt \
66
+ --prefix /prod/tools \
67
+ --module-root /prod/modulefiles \
68
+ --execute-install
69
+ ```
70
+
71
+ `--constraints` may be used more than once.
72
+
73
+ ## Generate Constraint Files
74
+
75
+ Use `auto-constraints` to compile a package and its dependencies into a
76
+ constraints file:
77
+
78
+ ```sh
79
+ module-manager auto-constraints gitconductor==0.7.0 \
80
+ --index https://packages.example/simple \
81
+ --output constraints.txt
82
+ ```
83
+
84
+ The command runs `uv pip compile` and writes `constraints.txt` by default. If uv
85
+ reports a transitive URL dependency, `module-manager` adds the suggested
86
+ `name @ URL` requirement and retries until all URL dependencies have been
87
+ included or uv reports an unrecoverable error.
88
+
58
89
  ## Use a uv Config File
59
90
 
60
91
  Pass a specific uv configuration file to `uv tool install`:
@@ -30,6 +30,12 @@ Show collective environment deployment options:
30
30
  module-manager deploy-env --help
31
31
  ```
32
32
 
33
+ Show constraint generation options:
34
+
35
+ ```sh
36
+ module-manager auto-constraints --help
37
+ ```
38
+
33
39
  Show uninstall options:
34
40
 
35
41
  ```sh
@@ -44,6 +50,7 @@ module-manager deploy-python NAME VERSION \
44
50
  [--python PYTHON] \
45
51
  [--index URL] \
46
52
  [--find-links PATH_OR_URL] \
53
+ [--constraints PATH] \
47
54
  [--uv-config-file PATH] \
48
55
  [--prefix PATH] \
49
56
  [--module-root PATH] \
@@ -51,6 +58,17 @@ module-manager deploy-python NAME VERSION \
51
58
  [--execute-install]
52
59
  ```
53
60
 
61
+ ## Auto-Constraints Command
62
+
63
+ ```sh
64
+ module-manager auto-constraints PACKAGE \
65
+ [--output PATH] \
66
+ [--python PYTHON] \
67
+ [--index URL] \
68
+ [--find-links PATH_OR_URL] \
69
+ [--uv-config-file PATH]
70
+ ```
71
+
54
72
  ## Rust Command
55
73
 
56
74
  ```sh
@@ -1,3 +1,3 @@
1
1
  """Deployment helpers for HPC environment module files."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
@@ -12,12 +12,14 @@ from click import Command
12
12
  from . import __version__
13
13
  from .config import AppConfig, load_config
14
14
  from .deploy import (
15
+ ConstraintGenerationError,
15
16
  EnvironmentDeploymentResult,
16
17
  UninstallResult,
17
18
  deploy_environment,
18
19
  deploy_python_tool,
19
20
  deploy_rust_tool,
20
21
  deploy_script_tool,
22
+ generate_constraints,
21
23
  load_environment_spec,
22
24
  uninstall_tool,
23
25
  )
@@ -41,6 +43,7 @@ TOP_LEVEL_EXAMPLES = (
41
43
  "--prefix /prod/tools --module-root /prod/modulefiles[/cyan]\n\n"
42
44
  "[cyan]module-manager deploy-script my-tool 1.0.0 --script ./scripts/my-tool "
43
45
  "--prefix /prod/tools --module-root /prod/modulefiles[/cyan]\n\n"
46
+ "[cyan]module-manager auto-constraints ruff==0.8.0 --output constraints.txt[/cyan]\n\n"
44
47
  "[cyan]module-manager deploy-env --file dev-tools.toml[/cyan]\n\n"
45
48
  "[cyan]module-manager uninstall ruff 0.8.0 --prefix /prod/tools --module-root /prod/modulefiles[/cyan]"
46
49
  )
@@ -76,6 +79,13 @@ DEPLOY_ENV_EXAMPLES = (
76
79
  "--module-root /scratch/modulefiles --dry-run[/cyan]"
77
80
  )
78
81
 
82
+ AUTO_CONSTRAINTS_EXAMPLES = (
83
+ "Examples:\n\n"
84
+ "[cyan]module-manager auto-constraints gitconductor==0.7.0[/cyan]\n\n"
85
+ "[cyan]module-manager auto-constraints internal-tool==1.2.3 --index https://packages.example/simple "
86
+ "--output /prod/constraints/internal-tool.txt[/cyan]"
87
+ )
88
+
79
89
  UNINSTALL_EXAMPLES = (
80
90
  "Examples:\n\n"
81
91
  "[cyan]module-manager uninstall ruff 0.8.0 --prefix /prod/tools "
@@ -199,6 +209,12 @@ def main(ctx: click.Context, config: Path | None) -> None:
199
209
  multiple=True,
200
210
  help="Directory or HTML page of packages passed to uv. May be used more than once.",
201
211
  )
212
+ @click.option(
213
+ "--constraints",
214
+ "constraints",
215
+ multiple=True,
216
+ help="Requirements constraint file passed to uv. May be used more than once.",
217
+ )
202
218
  @click.option(
203
219
  "--uv-config-file",
204
220
  type=PATH,
@@ -223,6 +239,7 @@ def deploy_python(
223
239
  python: str | None,
224
240
  indexes: tuple[str, ...],
225
241
  find_links: tuple[str, ...],
242
+ constraints: tuple[str, ...],
226
243
  uv_config_file: Path | None,
227
244
  execute_install: bool,
228
245
  ) -> None:
@@ -241,6 +258,7 @@ def deploy_python(
241
258
  python: Optional Python interpreter or version passed to uv.
242
259
  indexes: Additional package index URLs passed to uv.
243
260
  find_links: Wheelhouse directories or HTML package pages passed to uv.
261
+ constraints: Requirements constraint files passed to uv.
244
262
  uv_config_file: Optional uv configuration file passed to `uv tool`.
245
263
  execute_install: Whether to run `uv tool install` immediately.
246
264
 
@@ -265,6 +283,7 @@ def deploy_python(
265
283
  python=python,
266
284
  indexes=indexes or config.indexes,
267
285
  find_links=find_links or config.find_links,
286
+ constraints=constraints,
268
287
  uv_config_file=(uv_config_file.expanduser() if uv_config_file else config.uv_config_file),
269
288
  execute_install=execute_install,
270
289
  make_default=make_default,
@@ -274,6 +293,82 @@ def deploy_python(
274
293
  )
275
294
 
276
295
 
296
+ @main.command(
297
+ "auto-constraints",
298
+ help="Generate constraints for a Python package with uv.",
299
+ epilog=AUTO_CONSTRAINTS_EXAMPLES,
300
+ )
301
+ @click.argument("package")
302
+ @click.option(
303
+ "--output",
304
+ "output_file",
305
+ type=PATH,
306
+ default=Path("constraints.txt"),
307
+ show_default=True,
308
+ help="Constraints file to write.",
309
+ )
310
+ @click.option("--python", "python", help="Python interpreter/version passed to uv.")
311
+ @click.option(
312
+ "--index",
313
+ "indexes",
314
+ multiple=True,
315
+ help="Additional package index URL passed to uv. May be used more than once.",
316
+ )
317
+ @click.option(
318
+ "--find-links",
319
+ "find_links",
320
+ multiple=True,
321
+ help="Directory or HTML page of packages passed to uv. May be used more than once.",
322
+ )
323
+ @click.option(
324
+ "--uv-config-file",
325
+ type=PATH,
326
+ help="uv.toml file passed to uv with --config-file.",
327
+ )
328
+ @click.pass_obj
329
+ def auto_constraints(
330
+ config: AppConfig,
331
+ package: str,
332
+ output_file: Path,
333
+ python: str | None,
334
+ indexes: tuple[str, ...],
335
+ find_links: tuple[str, ...],
336
+ uv_config_file: Path | None,
337
+ ) -> None:
338
+ """Generate a constraints file for a Python package.
339
+
340
+ Args:
341
+ config: Resolved application configuration from the Click context.
342
+ package: Package requirement to compile.
343
+ output_file: Constraints file to write.
344
+ python: Optional Python interpreter or version passed to uv.
345
+ indexes: Additional package index URLs passed to uv.
346
+ find_links: Wheelhouse directories or HTML package pages passed to uv.
347
+ uv_config_file: Optional uv configuration file passed to uv.
348
+
349
+ Raises:
350
+ click.ClickException: If uv cannot generate constraints.
351
+ MissingExecutableError: If uv is not on `PATH`.
352
+ """
353
+ try:
354
+ result = generate_constraints(
355
+ package=package,
356
+ output_file=output_file.expanduser(),
357
+ python=python,
358
+ indexes=indexes or config.indexes,
359
+ find_links=find_links or config.find_links,
360
+ uv_config_file=(uv_config_file.expanduser() if uv_config_file else config.uv_config_file),
361
+ )
362
+ except ConstraintGenerationError as error:
363
+ raise click.ClickException(str(error)) from error
364
+
365
+ click.echo(f"constraints: {result.output_file}")
366
+ if result.discovered_url_dependencies:
367
+ click.echo(f"discovered URL dependencies: {len(result.discovered_url_dependencies)}")
368
+ for requirement in result.discovered_url_dependencies:
369
+ click.echo(f" {requirement}")
370
+
371
+
277
372
  @main.command(
278
373
  "deploy-rust",
279
374
  help="Copy a Rust CLI binary and write a modulefile for it.",
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import re
6
7
  import shlex
7
8
  import shutil
8
9
  import stat
@@ -20,6 +21,10 @@ class MissingExecutableError(RuntimeError):
20
21
  """Raised when a required external executable is unavailable."""
21
22
 
22
23
 
24
+ class ConstraintGenerationError(RuntimeError):
25
+ """Raised when uv cannot generate constraints after URL discovery."""
26
+
27
+
23
28
  @dataclass(frozen=True)
24
29
  class DeploymentPaths:
25
30
  """Filesystem paths created or targeted by a deployment.
@@ -68,6 +73,7 @@ class EnvironmentToolSpec:
68
73
  python: Optional Python interpreter or version passed to uv.
69
74
  indexes: Additional package index URLs passed to uv.
70
75
  find_links: Wheelhouse directories or HTML package pages passed to uv.
76
+ constraints: Constraint requirement files passed to uv.
71
77
  description: Optional tool description.
72
78
  homepage: Optional upstream homepage.
73
79
  """
@@ -82,6 +88,7 @@ class EnvironmentToolSpec:
82
88
  python: str | None = None
83
89
  indexes: tuple[str, ...] = ()
84
90
  find_links: tuple[str, ...] = ()
91
+ constraints: tuple[str, ...] = ()
85
92
  description: str | None = None
86
93
  homepage: str | None = None
87
94
 
@@ -124,6 +131,26 @@ class EnvironmentDeploymentResult:
124
131
  actions: tuple[str, ...]
125
132
 
126
133
 
134
+ @dataclass(frozen=True)
135
+ class ConstraintGenerationResult:
136
+ """Result of generating a Python constraints file.
137
+
138
+ Attributes:
139
+ output_file: Constraints file written by uv.
140
+ requirements: Initial and discovered direct requirements compiled.
141
+ discovered_url_dependencies: URL requirements discovered from uv errors.
142
+ iterations: Number of uv compile attempts.
143
+ """
144
+
145
+ output_file: Path
146
+ requirements: tuple[str, ...]
147
+ discovered_url_dependencies: tuple[str, ...]
148
+ iterations: int
149
+
150
+
151
+ URL_DEPENDENCY_RE = re.compile(r"`([^`]+?\s@\s[^`]+?)`")
152
+
153
+
127
154
  def deployment_paths(module_root: Path, prefix: Path, name: str, version: str) -> DeploymentPaths:
128
155
  """Compute versioned installation and modulefile paths for a tool.
129
156
 
@@ -150,6 +177,7 @@ def uv_install_command(
150
177
  python: str | None = None,
151
178
  indexes: tuple[str, ...] = (),
152
179
  find_links: tuple[str, ...] = (),
180
+ constraints: tuple[str, ...] = (),
153
181
  uv_config_file: Path | None = None,
154
182
  ) -> list[str]:
155
183
  """Build the uv command used to install a Python CLI tool.
@@ -159,6 +187,7 @@ def uv_install_command(
159
187
  python: Optional Python interpreter or version passed to uv.
160
188
  indexes: Additional package index URLs.
161
189
  find_links: Wheelhouse directories or HTML package pages.
190
+ constraints: Constraint requirement files.
162
191
  uv_config_file: Optional uv configuration file passed to `uv tool`.
163
192
 
164
193
  Returns:
@@ -174,10 +203,135 @@ def uv_install_command(
174
203
  command.extend(["--index", index])
175
204
  for link in find_links:
176
205
  command.extend(["--find-links", link])
206
+ for constraint in constraints:
207
+ command.extend(["--constraints", constraint])
177
208
  command.append(package)
178
209
  return command
179
210
 
180
211
 
212
+ def uv_compile_command(
213
+ output_file: Path,
214
+ python: str | None = None,
215
+ indexes: tuple[str, ...] = (),
216
+ find_links: tuple[str, ...] = (),
217
+ uv_config_file: Path | None = None,
218
+ ) -> list[str]:
219
+ """Build the uv command used to compile Python constraints.
220
+
221
+ Args:
222
+ output_file: Destination constraints file.
223
+ python: Optional Python interpreter or version passed to uv.
224
+ indexes: Additional package index URLs.
225
+ find_links: Wheelhouse directories or HTML package pages.
226
+ uv_config_file: Optional uv configuration file passed to `uv`.
227
+
228
+ Returns:
229
+ Tokenized uv command suitable for `subprocess.run`.
230
+ """
231
+ command = ["uv"]
232
+ if uv_config_file:
233
+ command.extend(["--config-file", str(uv_config_file)])
234
+ command.extend(
235
+ [
236
+ "pip",
237
+ "compile",
238
+ "-",
239
+ "--output-file",
240
+ str(output_file),
241
+ "--no-header",
242
+ "--no-annotate",
243
+ ]
244
+ )
245
+ if python:
246
+ command.extend(["--python", python])
247
+ for index in indexes:
248
+ command.extend(["--index", index])
249
+ for link in find_links:
250
+ command.extend(["--find-links", link])
251
+ return command
252
+
253
+
254
+ def discover_url_requirements(output: str) -> tuple[str, ...]:
255
+ """Extract uv-suggested direct URL requirements from compiler output.
256
+
257
+ Args:
258
+ output: Combined stdout and stderr from `uv pip compile`.
259
+
260
+ Returns:
261
+ Direct URL requirements in discovery order.
262
+ """
263
+ requirements: list[str] = []
264
+ for match in URL_DEPENDENCY_RE.findall(output):
265
+ if match not in requirements:
266
+ requirements.append(match)
267
+ return tuple(requirements)
268
+
269
+
270
+ def generate_constraints(
271
+ *,
272
+ package: str,
273
+ output_file: Path,
274
+ python: str | None = None,
275
+ indexes: tuple[str, ...] = (),
276
+ find_links: tuple[str, ...] = (),
277
+ uv_config_file: Path | None = None,
278
+ max_iterations: int = 20,
279
+ ) -> ConstraintGenerationResult:
280
+ """Generate a constraints file, discovering transitive URL dependencies.
281
+
282
+ Args:
283
+ package: Initial package requirement to compile.
284
+ output_file: Destination constraints file.
285
+ python: Optional Python interpreter or version passed to uv.
286
+ indexes: Additional package index URLs passed to uv.
287
+ find_links: Wheelhouse directories or HTML package pages passed to uv.
288
+ uv_config_file: Optional uv configuration file passed to `uv`.
289
+ max_iterations: Maximum uv compile attempts before failing.
290
+
291
+ Returns:
292
+ Constraints generation result.
293
+
294
+ Raises:
295
+ MissingExecutableError: If uv is not on `PATH`.
296
+ ConstraintGenerationError: If uv fails without a new URL dependency to
297
+ add, or if the iteration limit is reached.
298
+ """
299
+ require_executable("uv")
300
+ output_file.parent.mkdir(parents=True, exist_ok=True)
301
+ requirements = [package]
302
+ discovered: list[str] = []
303
+ command = uv_compile_command(output_file, python, indexes, find_links, uv_config_file)
304
+
305
+ for iteration in range(1, max_iterations + 1):
306
+ completed = subprocess.run(
307
+ command,
308
+ check=False,
309
+ input="\n".join(requirements) + "\n",
310
+ capture_output=True,
311
+ text=True,
312
+ )
313
+ if completed.returncode == 0:
314
+ return ConstraintGenerationResult(
315
+ output_file=output_file,
316
+ requirements=tuple(requirements),
317
+ discovered_url_dependencies=tuple(discovered),
318
+ iterations=iteration,
319
+ )
320
+
321
+ combined_output = f"{completed.stdout}\n{completed.stderr}"
322
+ new_requirements = [
323
+ requirement for requirement in discover_url_requirements(combined_output) if requirement not in requirements
324
+ ]
325
+ if not new_requirements:
326
+ msg = combined_output.strip() or f"uv pip compile failed with exit code {completed.returncode}"
327
+ raise ConstraintGenerationError(msg)
328
+ requirements.extend(new_requirements)
329
+ discovered.extend(new_requirements)
330
+
331
+ msg = f"uv pip compile did not converge after {max_iterations} attempts"
332
+ raise ConstraintGenerationError(msg)
333
+
334
+
181
335
  def uv_install_environment(tool_dir: Path, bin_dir: Path) -> dict[str, str]:
182
336
  """Build an isolated environment for a managed uv tool installation.
183
337
 
@@ -400,6 +554,29 @@ def optional_manifest_path(data: dict[str, Any], key: str, base_dir: Path | None
400
554
  return path
401
555
 
402
556
 
557
+ def optional_manifest_paths(data: dict[str, Any], key: str, base_dir: Path | None = None) -> tuple[Path, ...]:
558
+ """Read optional paths from manifest data.
559
+
560
+ Args:
561
+ data: Manifest table.
562
+ key: Optional path-list key.
563
+ base_dir: Directory used to resolve relative paths.
564
+
565
+ Returns:
566
+ Expanded path values, or an empty tuple.
567
+
568
+ Raises:
569
+ TypeError: If the value is not a list of strings.
570
+ """
571
+ paths = []
572
+ for value in optional_string_tuple(data, key):
573
+ path = Path(value).expanduser()
574
+ if base_dir and not path.is_absolute():
575
+ path = base_dir / path
576
+ paths.append(path)
577
+ return tuple(paths)
578
+
579
+
403
580
  def optional_string_tuple(data: dict[str, Any], key: str) -> tuple[str, ...]:
404
581
  """Read an optional list of strings from manifest data.
405
582
 
@@ -512,6 +689,7 @@ def parse_environment_tool(data: object, base_dir: Path, index: int) -> Environm
512
689
  python=optional_string(data, "python"),
513
690
  indexes=optional_string_tuple(data, "indexes"),
514
691
  find_links=optional_string_tuple(data, "find_links"),
692
+ constraints=tuple(str(path) for path in optional_manifest_paths(data, "constraints", base_dir)),
515
693
  description=optional_string(data, "description"),
516
694
  homepage=optional_string(data, "homepage"),
517
695
  )
@@ -570,6 +748,7 @@ def environment_actions(spec: EnvironmentSpec, paths: DeploymentPaths) -> tuple[
570
748
  tool.python,
571
749
  tool.indexes,
572
750
  tool.find_links,
751
+ tool.constraints,
573
752
  tool.uv_config_file,
574
753
  )
575
754
  actions.append(
@@ -628,6 +807,7 @@ def deploy_environment(
628
807
  tool.python,
629
808
  tool.indexes,
630
809
  tool.find_links,
810
+ tool.constraints,
631
811
  tool.uv_config_file,
632
812
  ),
633
813
  check=True,
@@ -668,6 +848,7 @@ def deploy_python_tool(
668
848
  python: str | None = None,
669
849
  indexes: tuple[str, ...] = (),
670
850
  find_links: tuple[str, ...] = (),
851
+ constraints: tuple[str, ...] = (),
671
852
  uv_config_file: Path | None = None,
672
853
  execute_install: bool = False,
673
854
  make_default: bool = True,
@@ -685,6 +866,7 @@ def deploy_python_tool(
685
866
  python: Optional Python interpreter or version passed to uv.
686
867
  indexes: Additional package index URLs passed to uv.
687
868
  find_links: Wheelhouse directories or HTML package pages passed to uv.
869
+ constraints: Constraint requirement files passed to uv.
688
870
  uv_config_file: Optional uv configuration file passed to `uv tool`.
689
871
  execute_install: Whether to run `uv tool install` immediately.
690
872
  make_default: Whether to make this version the module default.
@@ -700,7 +882,7 @@ def deploy_python_tool(
700
882
  paths = deployment_paths(module_root, prefix, name, version)
701
883
  paths.bin_dir.mkdir(parents=True, exist_ok=True)
702
884
  tool_dir = paths.install_root / "uv-tools"
703
- command = uv_install_command(package, python, indexes, find_links, uv_config_file)
885
+ command = uv_install_command(package, python, indexes, find_links, constraints, uv_config_file)
704
886
 
705
887
  if execute_install:
706
888
  require_executable("uv")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "env-module-manager"
3
- version = "0.5.0"
3
+ version = "0.7.0"
4
4
  description = "Deploy Python, Rust, shell, and collective CLI environments behind GNU environment modulefiles"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -3,9 +3,11 @@
3
3
  import re
4
4
  from pathlib import Path
5
5
 
6
+ import pytest
6
7
  from click.testing import CliRunner
7
8
 
8
9
  from module_manager.cli import main
10
+ from module_manager.deploy import ConstraintGenerationResult
9
11
 
10
12
  ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
11
13
 
@@ -56,6 +58,8 @@ def test_deploy_python_command_writes_modulefile() -> None:
56
58
  "https://packages.example/simple",
57
59
  "--find-links",
58
60
  "/prod/wheels",
61
+ "--constraints",
62
+ "/prod/constraints.txt",
59
63
  "--uv-config-file",
60
64
  "/prod/uv.toml",
61
65
  ],
@@ -66,6 +70,7 @@ def test_deploy_python_command_writes_modulefile() -> None:
66
70
  assert "modulefile: modules/ruff/0.8.0" in result.output
67
71
  assert "default version: modules/ruff/.version" in result.output
68
72
  assert "--config-file /prod/uv.toml" in modulefile
73
+ assert "--constraints /prod/constraints.txt" in modulefile
69
74
 
70
75
 
71
76
  def test_deploy_python_command_uses_config_defaults() -> None:
@@ -132,6 +137,56 @@ def test_deploy_command_rejects_matching_prefix_and_module_root() -> None:
132
137
  assert not collision_path_exists
133
138
 
134
139
 
140
+ def test_auto_constraints_command_writes_output(monkeypatch: pytest.MonkeyPatch) -> None:
141
+ """The auto-constraints command should report the generated file.
142
+
143
+ Args:
144
+ monkeypatch: Pytest helper used to replace constraint generation.
145
+ """
146
+ runner = CliRunner()
147
+ calls: list[dict[str, object]] = []
148
+
149
+ def fake_generate_constraints(**kwargs: object) -> ConstraintGenerationResult:
150
+ """Record generation options."""
151
+ calls.append(kwargs)
152
+ return ConstraintGenerationResult(
153
+ output_file=Path("constraints.txt"),
154
+ requirements=("url-tool", "url-dep @ file:///tmp/url-dep"),
155
+ discovered_url_dependencies=("url-dep @ file:///tmp/url-dep",),
156
+ iterations=2,
157
+ )
158
+
159
+ monkeypatch.setattr("module_manager.cli.generate_constraints", fake_generate_constraints)
160
+
161
+ with runner.isolated_filesystem():
162
+ result = runner.invoke(
163
+ main,
164
+ [
165
+ "auto-constraints",
166
+ "url-tool",
167
+ "--index",
168
+ "https://packages.example/simple",
169
+ "--output",
170
+ "constraints.txt",
171
+ ],
172
+ )
173
+
174
+ assert result.exit_code == 0
175
+ assert calls == [
176
+ {
177
+ "package": "url-tool",
178
+ "output_file": Path("constraints.txt"),
179
+ "python": None,
180
+ "indexes": ("https://packages.example/simple",),
181
+ "find_links": (),
182
+ "uv_config_file": None,
183
+ }
184
+ ]
185
+ assert "constraints: constraints.txt" in result.output
186
+ assert "discovered URL dependencies: 1" in result.output
187
+ assert "url-dep @ file:///tmp/url-dep" in result.output
188
+
189
+
135
190
  def test_cli_options_override_config_defaults() -> None:
136
191
  """CLI options should take precedence over configured defaults."""
137
192
  runner = CliRunner()
@@ -408,6 +463,7 @@ name = "ruff"
408
463
  version = "0.8.0"
409
464
  package = "ruff==0.8.0"
410
465
  indexes = ["https://packages.example/simple"]
466
+ constraints = ["constraints.txt"]
411
467
  """.strip(),
412
468
  encoding="utf-8",
413
469
  )
@@ -418,6 +474,7 @@ indexes = ["https://packages.example/simple"]
418
474
  assert "would create install root: tools/dev-tools/2026.05" in result.output
419
475
  assert "would install python ruff:" in result.output
420
476
  assert "--index https://packages.example/simple" in result.output
477
+ assert "--constraints constraints.txt" in result.output
421
478
  assert not install_root_exists
422
479
 
423
480
 
@@ -0,0 +1,138 @@
1
+ """Integration tests that execute external deployment tools."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from click.testing import CliRunner
8
+
9
+ from module_manager.cli import main
10
+
11
+
12
+ @pytest.mark.integration
13
+ def test_deploy_python_real_uv_install_uses_cli_roots(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
14
+ """A real uv install should use the CLI prefix and module root.
15
+
16
+ Args:
17
+ tmp_path: Temporary deployment root.
18
+ monkeypatch: Pytest helper used to control inherited uv destinations.
19
+ """
20
+ project_root = Path(__file__).parent.parent
21
+ prefix = tmp_path / "tools"
22
+ module_root = tmp_path / "modules"
23
+ inherited_tool_dir = tmp_path / "inherited-tools"
24
+ inherited_bin_dir = tmp_path / "inherited-bin"
25
+ monkeypatch.setenv("UV_TOOL_DIR", str(inherited_tool_dir))
26
+ monkeypatch.setenv("UV_TOOL_BIN_DIR", str(inherited_bin_dir))
27
+
28
+ result = CliRunner().invoke(
29
+ main,
30
+ [
31
+ "deploy-python",
32
+ "module-manager",
33
+ "0.7.0",
34
+ "--package",
35
+ str(project_root),
36
+ "--prefix",
37
+ str(prefix),
38
+ "--module-root",
39
+ str(module_root),
40
+ "--execute-install",
41
+ ],
42
+ )
43
+
44
+ install_root = prefix / "module-manager" / "0.7.0"
45
+ assert result.exit_code == 0, result.output
46
+ assert (install_root / "bin" / "module-manager").exists()
47
+ assert (install_root / "uv-tools" / "env-module-manager" / "uv-receipt.toml").exists()
48
+ assert (module_root / "module-manager" / "0.7.0").is_file()
49
+ assert not inherited_tool_dir.exists()
50
+ assert not inherited_bin_dir.exists()
51
+
52
+
53
+ @pytest.mark.integration
54
+ def test_deploy_python_real_uv_install_handles_url_dependency(tmp_path: Path) -> None:
55
+ """A real uv install should resolve and run direct URL dependencies.
56
+
57
+ Args:
58
+ tmp_path: Temporary deployment root.
59
+ """
60
+ dependency = tmp_path / "url-dependency"
61
+ dependency.mkdir()
62
+ (dependency / "pyproject.toml").write_text(
63
+ """
64
+ [project]
65
+ name = "url-dependency"
66
+ version = "1.0.0"
67
+ requires-python = ">=3.11"
68
+ """.strip(),
69
+ encoding="utf-8",
70
+ )
71
+ dependency_package = dependency / "url_dependency"
72
+ dependency_package.mkdir()
73
+ (dependency_package / "__init__.py").write_text(
74
+ """
75
+ def message() -> str:
76
+ return "url dependency works"
77
+ """.strip(),
78
+ encoding="utf-8",
79
+ )
80
+
81
+ tool = tmp_path / "url-tool"
82
+ tool.mkdir()
83
+ (tool / "pyproject.toml").write_text(
84
+ f"""
85
+ [project]
86
+ name = "url-tool"
87
+ version = "1.0.0"
88
+ requires-python = ">=3.11"
89
+ dependencies = [
90
+ "url-dependency @ {dependency.as_uri()}",
91
+ ]
92
+
93
+ [project.scripts]
94
+ url-tool = "url_tool:main"
95
+ """.strip(),
96
+ encoding="utf-8",
97
+ )
98
+ tool_package = tool / "url_tool"
99
+ tool_package.mkdir()
100
+ (tool_package / "__init__.py").write_text(
101
+ """
102
+ from url_dependency import message
103
+
104
+
105
+ def main() -> None:
106
+ print(message())
107
+ """.strip(),
108
+ encoding="utf-8",
109
+ )
110
+
111
+ prefix = tmp_path / "tools"
112
+ module_root = tmp_path / "modules"
113
+ result = CliRunner().invoke(
114
+ main,
115
+ [
116
+ "deploy-python",
117
+ "url-tool",
118
+ "1.0.0",
119
+ "--package",
120
+ str(tool),
121
+ "--prefix",
122
+ str(prefix),
123
+ "--module-root",
124
+ str(module_root),
125
+ "--execute-install",
126
+ ],
127
+ )
128
+
129
+ executable = prefix / "url-tool" / "1.0.0" / "bin" / "url-tool"
130
+ assert result.exit_code == 0, result.output
131
+ assert executable.exists()
132
+ completed = subprocess.run(
133
+ [str(executable)],
134
+ check=True,
135
+ capture_output=True,
136
+ text=True,
137
+ )
138
+ assert completed.stdout.strip() == "url dependency works"
@@ -1,6 +1,8 @@
1
1
  """Modulefile rendering and deployment helper tests."""
2
2
 
3
+ import subprocess
3
4
  from pathlib import Path
5
+ from shlex import quote as shlex_quote
4
6
 
5
7
  import pytest
6
8
 
@@ -10,9 +12,12 @@ from module_manager.deploy import (
10
12
  deploy_python_tool,
11
13
  deploy_rust_tool,
12
14
  deploy_script_tool,
15
+ discover_url_requirements,
16
+ generate_constraints,
13
17
  load_environment_spec,
14
18
  require_executable,
15
19
  uninstall_tool,
20
+ uv_compile_command,
16
21
  uv_install_command,
17
22
  uv_install_environment,
18
23
  )
@@ -84,6 +89,7 @@ def test_python_tool_install_command_accepts_indexes() -> None:
84
89
  "internal-tool==1.2.3",
85
90
  indexes=("https://packages.example/simple", "https://mirror.example/simple"),
86
91
  find_links=("/prod/wheels",),
92
+ constraints=("/prod/constraints.txt",),
87
93
  ) == [
88
94
  "uv",
89
95
  "tool",
@@ -94,6 +100,8 @@ def test_python_tool_install_command_accepts_indexes() -> None:
94
100
  "https://mirror.example/simple",
95
101
  "--find-links",
96
102
  "/prod/wheels",
103
+ "--constraints",
104
+ "/prod/constraints.txt",
97
105
  "internal-tool==1.2.3",
98
106
  ]
99
107
 
@@ -122,6 +130,109 @@ def test_python_tool_install_command_accepts_uv_config_file() -> None:
122
130
  ]
123
131
 
124
132
 
133
+ def test_uv_compile_command_accepts_sources_and_config() -> None:
134
+ """Uv compile commands should include resolver and config options."""
135
+ assert uv_compile_command(
136
+ Path("/prod/constraints.txt"),
137
+ python="3.12",
138
+ indexes=("https://packages.example/simple",),
139
+ find_links=("/prod/wheels",),
140
+ uv_config_file=Path("/prod/uv.toml"),
141
+ ) == [
142
+ "uv",
143
+ "--config-file",
144
+ "/prod/uv.toml",
145
+ "pip",
146
+ "compile",
147
+ "-",
148
+ "--output-file",
149
+ "/prod/constraints.txt",
150
+ "--no-header",
151
+ "--no-annotate",
152
+ "--python",
153
+ "3.12",
154
+ "--index",
155
+ "https://packages.example/simple",
156
+ "--find-links",
157
+ "/prod/wheels",
158
+ ]
159
+
160
+
161
+ def test_discover_url_requirements_extracts_uv_suggestions() -> None:
162
+ """Uv URL dependency suggestions should be parsed from errors."""
163
+ output = """
164
+ Package `url-dep` was included as a URL dependency. URL dependencies
165
+ must be expressed as direct requirements or constraints. Consider adding
166
+ `url-dep @ file:///tmp/url-dep` to your dependencies or constraints file.
167
+ """.strip()
168
+
169
+ assert discover_url_requirements(output) == ("url-dep @ file:///tmp/url-dep",)
170
+
171
+
172
+ def test_generate_constraints_retries_discovered_url_dependencies(
173
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
174
+ ) -> None:
175
+ """Constraint generation should retry with uv-suggested URL requirements.
176
+
177
+ Args:
178
+ tmp_path: Temporary deployment root.
179
+ monkeypatch: Pytest helper used to replace process execution.
180
+ """
181
+ calls: list[tuple[list[str], str]] = []
182
+
183
+ def fake_run(
184
+ command: list[str],
185
+ check: bool,
186
+ input: str,
187
+ capture_output: bool,
188
+ text: bool,
189
+ ) -> subprocess.CompletedProcess[str]:
190
+ """Fail once with a URL suggestion, then write constraints."""
191
+ assert not check
192
+ assert capture_output
193
+ assert text
194
+ calls.append((command, input))
195
+ if len(calls) == 1:
196
+ return subprocess.CompletedProcess(
197
+ command,
198
+ 1,
199
+ stdout="",
200
+ stderr=("Consider adding `url-dep @ file:///tmp/url-dep` to your dependencies or constraints file."),
201
+ )
202
+ output_file = tmp_path / "constraints.txt"
203
+ output_file.write_text("url-dep @ file:///tmp/url-dep\nurl-tool==1.0.0\n", encoding="utf-8")
204
+ return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
205
+
206
+ monkeypatch.setattr("module_manager.deploy.require_executable", lambda _name: "uv")
207
+ monkeypatch.setattr("module_manager.deploy.subprocess.run", fake_run)
208
+
209
+ result = generate_constraints(package="url-tool", output_file=tmp_path / "constraints.txt")
210
+
211
+ assert result.output_file == tmp_path / "constraints.txt"
212
+ assert result.requirements == ("url-tool", "url-dep @ file:///tmp/url-dep")
213
+ assert result.discovered_url_dependencies == ("url-dep @ file:///tmp/url-dep",)
214
+ assert result.iterations == 2
215
+ assert calls[0][1] == "url-tool\n"
216
+ assert calls[1][1] == "url-tool\nurl-dep @ file:///tmp/url-dep\n"
217
+ assert (tmp_path / "constraints.txt").read_text(encoding="utf-8") == (
218
+ "url-dep @ file:///tmp/url-dep\nurl-tool==1.0.0\n"
219
+ )
220
+
221
+
222
+ def test_python_tool_install_command_accepts_url_dependency() -> None:
223
+ """Uv install commands should pass direct URL dependencies unchanged."""
224
+ package = "mytool @ https://packages.example/files/mytool-1.0.0-py3-none-any.whl"
225
+
226
+ assert uv_install_command(package, constraints=("/prod/constraints.txt",)) == [
227
+ "uv",
228
+ "tool",
229
+ "install",
230
+ "--constraints",
231
+ "/prod/constraints.txt",
232
+ package,
233
+ ]
234
+
235
+
125
236
  def test_uv_install_environment_ignores_inherited_uv_tool_settings(
126
237
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
127
238
  ) -> None:
@@ -158,6 +269,7 @@ def test_deploy_python_tool_writes_package_sources_to_install_hint(tmp_path: Pat
158
269
  prefix=tmp_path / "tools",
159
270
  indexes=("https://packages.example/simple",),
160
271
  find_links=("/prod/wheels",),
272
+ constraints=("/prod/constraints.txt",),
161
273
  uv_config_file=Path("/prod/uv.toml"),
162
274
  )
163
275
 
@@ -165,6 +277,28 @@ def test_deploy_python_tool_writes_package_sources_to_install_hint(tmp_path: Pat
165
277
  assert "--config-file /prod/uv.toml" in modulefile
166
278
  assert "--index https://packages.example/simple" in modulefile
167
279
  assert "--find-links /prod/wheels" in modulefile
280
+ assert "--constraints /prod/constraints.txt" in modulefile
281
+
282
+
283
+ def test_deploy_python_tool_writes_url_dependency_to_install_hint(tmp_path: Path) -> None:
284
+ """Generated module help should preserve direct URL package specs.
285
+
286
+ Args:
287
+ tmp_path: Temporary deployment root.
288
+ """
289
+ package = "mytool @ https://packages.example/files/mytool-1.0.0-py3-none-any.whl"
290
+ paths = deploy_python_tool(
291
+ name="mytool",
292
+ version="1.0.0",
293
+ package=package,
294
+ module_root=tmp_path / "modules",
295
+ prefix=tmp_path / "tools",
296
+ constraints=("/prod/constraints.txt",),
297
+ )
298
+
299
+ modulefile = paths.modulefile.read_text(encoding="utf-8")
300
+ assert shlex_quote(package) in modulefile
301
+ assert "--constraints /prod/constraints.txt" in modulefile
168
302
 
169
303
 
170
304
  def test_require_executable_reports_missing_command(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -403,6 +537,7 @@ package = "ruff==0.8.0"
403
537
  python = "3.12"
404
538
  indexes = ["https://packages.example/simple"]
405
539
  find_links = ["/prod/wheels"]
540
+ constraints = ["constraints.txt", "/prod/global-constraints.txt"]
406
541
  uv_config_file = "uv.toml"
407
542
 
408
543
  [[tools]]
@@ -422,6 +557,7 @@ script = "scripts/helper"
422
557
  assert not spec.make_default
423
558
  assert spec.tools[0].package == "ruff==0.8.0"
424
559
  assert spec.tools[0].indexes == ("https://packages.example/simple",)
560
+ assert spec.tools[0].constraints == (str(tmp_path / "constraints.txt"), "/prod/global-constraints.txt")
425
561
  assert spec.tools[0].uv_config_file == tmp_path / "uv.toml"
426
562
  assert spec.tools[1].script == tmp_path / "scripts/helper"
427
563
 
@@ -530,6 +666,7 @@ type = "python"
530
666
  name = "ruff"
531
667
  package = "ruff==0.8.0"
532
668
  python = "3.12"
669
+ constraints = ["constraints.txt"]
533
670
  uv_config_file = "uv.toml"
534
671
  """.strip(),
535
672
  encoding="utf-8",
@@ -550,6 +687,8 @@ uv_config_file = "uv.toml"
550
687
  "install",
551
688
  "--python",
552
689
  "3.12",
690
+ "--constraints",
691
+ str(tmp_path / "constraints.txt"),
553
692
  "ruff==0.8.0",
554
693
  ]
555
694
  assert calls[0][1]["UV_TOOL_DIR"] == str(result.paths.install_root / "uv-tools")
@@ -344,7 +344,7 @@ wheels = [
344
344
 
345
345
  [[package]]
346
346
  name = "env-module-manager"
347
- version = "0.5.0"
347
+ version = "0.7.0"
348
348
  source = { editable = "." }
349
349
  dependencies = [
350
350
  { name = "click" },
@@ -1,49 +0,0 @@
1
- """Integration tests that execute external deployment tools."""
2
-
3
- from pathlib import Path
4
-
5
- import pytest
6
- from click.testing import CliRunner
7
-
8
- from module_manager.cli import main
9
-
10
-
11
- @pytest.mark.integration
12
- def test_deploy_python_real_uv_install_uses_cli_roots(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
13
- """A real uv install should use the CLI prefix and module root.
14
-
15
- Args:
16
- tmp_path: Temporary deployment root.
17
- monkeypatch: Pytest helper used to control inherited uv destinations.
18
- """
19
- project_root = Path(__file__).parent.parent
20
- prefix = tmp_path / "tools"
21
- module_root = tmp_path / "modules"
22
- inherited_tool_dir = tmp_path / "inherited-tools"
23
- inherited_bin_dir = tmp_path / "inherited-bin"
24
- monkeypatch.setenv("UV_TOOL_DIR", str(inherited_tool_dir))
25
- monkeypatch.setenv("UV_TOOL_BIN_DIR", str(inherited_bin_dir))
26
-
27
- result = CliRunner().invoke(
28
- main,
29
- [
30
- "deploy-python",
31
- "module-manager",
32
- "0.5.0",
33
- "--package",
34
- str(project_root),
35
- "--prefix",
36
- str(prefix),
37
- "--module-root",
38
- str(module_root),
39
- "--execute-install",
40
- ],
41
- )
42
-
43
- install_root = prefix / "module-manager" / "0.5.0"
44
- assert result.exit_code == 0, result.output
45
- assert (install_root / "bin" / "module-manager").exists()
46
- assert (install_root / "uv-tools" / "env-module-manager" / "uv-receipt.toml").exists()
47
- assert (module_root / "module-manager" / "0.5.0").is_file()
48
- assert not inherited_tool_dir.exists()
49
- assert not inherited_bin_dir.exists()