env-module-manager 0.4.0__tar.gz → 0.6.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 (36) hide show
  1. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/.github/workflows/test.yml +21 -0
  2. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/.gitignore +1 -0
  3. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/PKG-INFO +4 -1
  4. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/README.md +3 -0
  5. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/collective-environments.md +2 -0
  6. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/configuration.md +4 -0
  7. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/development.md +8 -0
  8. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/python-tools.md +19 -0
  9. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/reference.md +1 -0
  10. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/module_manager/__init__.py +1 -1
  11. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/module_manager/cli.py +52 -12
  12. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/module_manager/deploy.py +54 -9
  13. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/pyproject.toml +6 -1
  14. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/tests/test_cli.py +32 -0
  15. env_module_manager-0.6.0/tests/test_integration.py +138 -0
  16. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/tests/test_modulefile.py +69 -0
  17. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/uv.lock +1 -1
  18. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/.github/workflows/publish.yml +0 -0
  19. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/.github/workflows/tag.yml +0 -0
  20. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/.python-version +0 -0
  21. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/.readthedocs.yaml +0 -0
  22. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/AGENTS.md +0 -0
  23. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/CONTRIBUTING.md +0 -0
  24. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/LICENSE.md +0 -0
  25. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/environment-modules.md +0 -0
  26. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/index.md +0 -0
  27. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/installation.md +0 -0
  28. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/rust-tools.md +0 -0
  29. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/shell-tools.md +0 -0
  30. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/stylesheets/extra.css +0 -0
  31. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/docs/uninstall.md +0 -0
  32. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/example.bash +0 -0
  33. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/mkdocs.yml +0 -0
  34. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/module_manager/config.py +0 -0
  35. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/module_manager/modulefile.py +0 -0
  36. {env_module_manager-0.4.0 → env_module_manager-0.6.0}/tests/test_config.py +0 -0
@@ -74,3 +74,24 @@ jobs:
74
74
 
75
75
  - name: Verify artifacts
76
76
  run: ls -l dist/
77
+
78
+ integration:
79
+ runs-on: ${{ matrix.os }}
80
+ strategy:
81
+ fail-fast: false
82
+ matrix:
83
+ os: [ubuntu-latest, macos-latest]
84
+
85
+ steps:
86
+ - uses: actions/checkout@v4
87
+
88
+ - name: Set up Python
89
+ uses: astral-sh/setup-uv@v3
90
+ with:
91
+ python-version: "3.12"
92
+
93
+ - name: Install dependencies
94
+ run: uv sync --group dev
95
+
96
+ - name: Test real uv deployment
97
+ run: uv run pytest tests/test_integration.py -m integration --no-cov
@@ -218,6 +218,7 @@ __marimo__/
218
218
 
219
219
  # Local
220
220
  .gitconductor
221
+ /test/
221
222
 
222
223
  # OS-generated files
223
224
  .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: env-module-manager
3
- Version: 0.4.0
3
+ Version: 0.6.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/
@@ -50,6 +50,8 @@ It writes tools and modulefiles into predictable versioned locations:
50
50
  /prod/modulefiles/<name>/<version>
51
51
  ```
52
52
 
53
+ The installation prefix and module root must be different directories.
54
+
53
55
  ## Installation
54
56
 
55
57
  Install from PyPI:
@@ -71,6 +73,7 @@ Deploy a Python CLI with `uv tool install`:
71
73
  ```sh
72
74
  module-manager deploy-python ruff 0.8.0 \
73
75
  --package 'ruff==0.8.0' \
76
+ --constraints /prod/constraints/runtime.txt \
74
77
  --prefix /prod/tools \
75
78
  --module-root /prod/modulefiles \
76
79
  --execute-install
@@ -18,6 +18,8 @@ It writes tools and modulefiles into predictable versioned locations:
18
18
  /prod/modulefiles/<name>/<version>
19
19
  ```
20
20
 
21
+ The installation prefix and module root must be different directories.
22
+
21
23
  ## Installation
22
24
 
23
25
  Install from PyPI:
@@ -39,6 +41,7 @@ Deploy a Python CLI with `uv tool install`:
39
41
  ```sh
40
42
  module-manager deploy-python ruff 0.8.0 \
41
43
  --package 'ruff==0.8.0' \
44
+ --constraints /prod/constraints/runtime.txt \
42
45
  --prefix /prod/tools \
43
46
  --module-root /prod/modulefiles \
44
47
  --execute-install
@@ -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
 
@@ -20,6 +20,10 @@ find_links = ["/prod/wheels"]
20
20
  uv_config_file = "/prod/config/uv.toml"
21
21
  ```
22
22
 
23
+ `prefix` and `module_root` must resolve to different directories. Using the
24
+ same root makes `<prefix>/<name>/<version>` both an installation directory and
25
+ a modulefile path.
26
+
23
27
  Use a different config file with `--config`:
24
28
 
25
29
  ```sh
@@ -14,6 +14,14 @@ uv run ruff format --check .
14
14
  uv run pytest
15
15
  ```
16
16
 
17
+ Run the real `uv tool install` integration test with:
18
+
19
+ ```sh
20
+ uv run pytest tests/test_integration.py -m integration --no-cov
21
+ ```
22
+
23
+ CI runs this integration test on Linux and macOS.
24
+
17
25
  Build distributions:
18
26
 
19
27
  ```sh
@@ -3,6 +3,10 @@
3
3
  Python CLIs are installed with `uv tool install`. Each deployed version gets
4
4
  isolated `UV_TOOL_DIR` and `UV_TOOL_BIN_DIR` paths.
5
5
 
6
+ The install subprocess ignores inherited `UV_TOOL_*` settings so the managed
7
+ destination and install behavior are not changed by the invoking environment.
8
+ Other uv settings, including `UV_CONFIG_FILE`, are preserved.
9
+
6
10
  `uv` is an external runtime requirement for `--execute-install`. It is not a
7
11
  Python package dependency because `module-manager` shells out to the `uv`
8
12
  executable and is intended to be installable by `uv` itself.
@@ -51,6 +55,21 @@ module-manager deploy-python gitconductor 0.1.0 \
51
55
 
52
56
  `--find-links` may be used more than once.
53
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
+
54
73
  ## Use a uv Config File
55
74
 
56
75
  Pass a specific uv configuration file to `uv tool install`:
@@ -44,6 +44,7 @@ module-manager deploy-python NAME VERSION \
44
44
  [--python PYTHON] \
45
45
  [--index URL] \
46
46
  [--find-links PATH_OR_URL] \
47
+ [--constraints PATH] \
47
48
  [--uv-config-file PATH] \
48
49
  [--prefix PATH] \
49
50
  [--module-root PATH] \
@@ -1,3 +1,3 @@
1
1
  """Deployment helpers for HPC environment module files."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.6.0"
@@ -199,6 +199,12 @@ def main(ctx: click.Context, config: Path | None) -> None:
199
199
  multiple=True,
200
200
  help="Directory or HTML page of packages passed to uv. May be used more than once.",
201
201
  )
202
+ @click.option(
203
+ "--constraints",
204
+ "constraints",
205
+ multiple=True,
206
+ help="Requirements constraint file passed to uv. May be used more than once.",
207
+ )
202
208
  @click.option(
203
209
  "--uv-config-file",
204
210
  type=PATH,
@@ -223,6 +229,7 @@ def deploy_python(
223
229
  python: str | None,
224
230
  indexes: tuple[str, ...],
225
231
  find_links: tuple[str, ...],
232
+ constraints: tuple[str, ...],
226
233
  uv_config_file: Path | None,
227
234
  execute_install: bool,
228
235
  ) -> None:
@@ -241,6 +248,7 @@ def deploy_python(
241
248
  python: Optional Python interpreter or version passed to uv.
242
249
  indexes: Additional package index URLs passed to uv.
243
250
  find_links: Wheelhouse directories or HTML package pages passed to uv.
251
+ constraints: Requirements constraint files passed to uv.
244
252
  uv_config_file: Optional uv configuration file passed to `uv tool`.
245
253
  execute_install: Whether to run `uv tool install` immediately.
246
254
 
@@ -250,8 +258,10 @@ def deploy_python(
250
258
  `PATH`.
251
259
  subprocess.CalledProcessError: If `uv tool install` fails.
252
260
  """
253
- resolved_module_root = require_path(module_root or config.module_root, "module root", "--module-root")
254
- resolved_prefix = require_path(prefix or config.prefix, "install prefix", "--prefix")
261
+ resolved_module_root, resolved_prefix = require_locations(
262
+ module_root or config.module_root,
263
+ prefix or config.prefix,
264
+ )
255
265
  paths = deploy_python_tool(
256
266
  name=name,
257
267
  version=version,
@@ -263,6 +273,7 @@ def deploy_python(
263
273
  python=python,
264
274
  indexes=indexes or config.indexes,
265
275
  find_links=find_links or config.find_links,
276
+ constraints=constraints,
266
277
  uv_config_file=(uv_config_file.expanduser() if uv_config_file else config.uv_config_file),
267
278
  execute_install=execute_install,
268
279
  make_default=make_default,
@@ -319,8 +330,10 @@ def deploy_rust(
319
330
  Raises:
320
331
  click.UsageError: If required paths are missing.
321
332
  """
322
- resolved_module_root = require_path(module_root or config.module_root, "module root", "--module-root")
323
- resolved_prefix = require_path(prefix or config.prefix, "install prefix", "--prefix")
333
+ resolved_module_root, resolved_prefix = require_locations(
334
+ module_root or config.module_root,
335
+ prefix or config.prefix,
336
+ )
324
337
  paths = deploy_rust_tool(
325
338
  name=name,
326
339
  version=version,
@@ -387,8 +400,10 @@ def deploy_script(
387
400
  Raises:
388
401
  click.UsageError: If required paths are missing.
389
402
  """
390
- resolved_module_root = require_path(module_root or config.module_root, "module root", "--module-root")
391
- resolved_prefix = require_path(prefix or config.prefix, "install prefix", "--prefix")
403
+ resolved_module_root, resolved_prefix = require_locations(
404
+ module_root or config.module_root,
405
+ prefix or config.prefix,
406
+ )
392
407
  paths = deploy_script_tool(
393
408
  name=name,
394
409
  version=version,
@@ -471,12 +486,10 @@ def deploy_env(
471
486
  except (OSError, TypeError, tomllib.TOMLDecodeError) as error:
472
487
  raise click.ClickException(str(error)) from error
473
488
 
474
- resolved_module_root = require_path(
489
+ resolved_module_root, resolved_prefix = require_locations(
475
490
  module_root or spec.module_root or config.module_root,
476
- "module root",
477
- "--module-root",
491
+ prefix or spec.prefix or config.prefix,
478
492
  )
479
- resolved_prefix = require_path(prefix or spec.prefix or config.prefix, "install prefix", "--prefix")
480
493
  if make_default is not None:
481
494
  spec = spec.__class__(
482
495
  name=spec.name,
@@ -533,8 +546,10 @@ def uninstall(
533
546
  Raises:
534
547
  click.UsageError: If required paths are missing.
535
548
  """
536
- resolved_module_root = require_path(module_root or config.module_root, "module root", "--module-root")
537
- resolved_prefix = require_path(prefix or config.prefix, "install prefix", "--prefix")
549
+ resolved_module_root, resolved_prefix = require_locations(
550
+ module_root or config.module_root,
551
+ prefix or config.prefix,
552
+ )
538
553
  result = uninstall_tool(
539
554
  name=name,
540
555
  version=version,
@@ -569,6 +584,31 @@ def require_path(value: Path | None, label: str, option: str) -> Path:
569
584
  return value.expanduser()
570
585
 
571
586
 
587
+ def require_locations(module_root: Path | None, prefix: Path | None) -> tuple[Path, Path]:
588
+ """Return distinct configured module and installation roots.
589
+
590
+ Args:
591
+ module_root: Candidate root of the module tree.
592
+ prefix: Candidate installation prefix.
593
+
594
+ Returns:
595
+ Expanded module root and installation prefix.
596
+
597
+ Raises:
598
+ click.UsageError: If either path is missing or both resolve to the same
599
+ directory.
600
+ """
601
+ resolved_module_root = require_path(module_root, "module root", "--module-root")
602
+ resolved_prefix = require_path(prefix, "install prefix", "--prefix")
603
+ if resolved_module_root.resolve() == resolved_prefix.resolve():
604
+ msg = (
605
+ "--module-root and --prefix must resolve to different directories; "
606
+ "otherwise the versioned modulefile collides with the install root."
607
+ )
608
+ raise click.UsageError(msg)
609
+ return resolved_module_root, resolved_prefix
610
+
611
+
572
612
  def print_result(
573
613
  modulefile: Path,
574
614
  install_root: Path,
@@ -68,6 +68,7 @@ class EnvironmentToolSpec:
68
68
  python: Optional Python interpreter or version passed to uv.
69
69
  indexes: Additional package index URLs passed to uv.
70
70
  find_links: Wheelhouse directories or HTML package pages passed to uv.
71
+ constraints: Constraint requirement files passed to uv.
71
72
  description: Optional tool description.
72
73
  homepage: Optional upstream homepage.
73
74
  """
@@ -82,6 +83,7 @@ class EnvironmentToolSpec:
82
83
  python: str | None = None
83
84
  indexes: tuple[str, ...] = ()
84
85
  find_links: tuple[str, ...] = ()
86
+ constraints: tuple[str, ...] = ()
85
87
  description: str | None = None
86
88
  homepage: str | None = None
87
89
 
@@ -150,6 +152,7 @@ def uv_install_command(
150
152
  python: str | None = None,
151
153
  indexes: tuple[str, ...] = (),
152
154
  find_links: tuple[str, ...] = (),
155
+ constraints: tuple[str, ...] = (),
153
156
  uv_config_file: Path | None = None,
154
157
  ) -> list[str]:
155
158
  """Build the uv command used to install a Python CLI tool.
@@ -159,6 +162,7 @@ def uv_install_command(
159
162
  python: Optional Python interpreter or version passed to uv.
160
163
  indexes: Additional package index URLs.
161
164
  find_links: Wheelhouse directories or HTML package pages.
165
+ constraints: Constraint requirement files.
162
166
  uv_config_file: Optional uv configuration file passed to `uv tool`.
163
167
 
164
168
  Returns:
@@ -174,10 +178,29 @@ def uv_install_command(
174
178
  command.extend(["--index", index])
175
179
  for link in find_links:
176
180
  command.extend(["--find-links", link])
181
+ for constraint in constraints:
182
+ command.extend(["--constraints", constraint])
177
183
  command.append(package)
178
184
  return command
179
185
 
180
186
 
187
+ def uv_install_environment(tool_dir: Path, bin_dir: Path) -> dict[str, str]:
188
+ """Build an isolated environment for a managed uv tool installation.
189
+
190
+ Args:
191
+ tool_dir: Directory where uv should store installed tools.
192
+ bin_dir: Directory where uv should install tool executables.
193
+
194
+ Returns:
195
+ Process environment with inherited `UV_TOOL_*` settings removed and
196
+ the managed tool destinations set.
197
+ """
198
+ env = {key: value for key, value in os.environ.items() if not key.startswith("UV_TOOL_")}
199
+ env["UV_TOOL_DIR"] = str(tool_dir)
200
+ env["UV_TOOL_BIN_DIR"] = str(bin_dir)
201
+ return env
202
+
203
+
181
204
  def write_text(path: Path, content: str) -> None:
182
205
  """Write UTF-8 text to a path, creating parent directories first.
183
206
 
@@ -383,6 +406,29 @@ def optional_manifest_path(data: dict[str, Any], key: str, base_dir: Path | None
383
406
  return path
384
407
 
385
408
 
409
+ def optional_manifest_paths(data: dict[str, Any], key: str, base_dir: Path | None = None) -> tuple[Path, ...]:
410
+ """Read optional paths from manifest data.
411
+
412
+ Args:
413
+ data: Manifest table.
414
+ key: Optional path-list key.
415
+ base_dir: Directory used to resolve relative paths.
416
+
417
+ Returns:
418
+ Expanded path values, or an empty tuple.
419
+
420
+ Raises:
421
+ TypeError: If the value is not a list of strings.
422
+ """
423
+ paths = []
424
+ for value in optional_string_tuple(data, key):
425
+ path = Path(value).expanduser()
426
+ if base_dir and not path.is_absolute():
427
+ path = base_dir / path
428
+ paths.append(path)
429
+ return tuple(paths)
430
+
431
+
386
432
  def optional_string_tuple(data: dict[str, Any], key: str) -> tuple[str, ...]:
387
433
  """Read an optional list of strings from manifest data.
388
434
 
@@ -495,6 +541,7 @@ def parse_environment_tool(data: object, base_dir: Path, index: int) -> Environm
495
541
  python=optional_string(data, "python"),
496
542
  indexes=optional_string_tuple(data, "indexes"),
497
543
  find_links=optional_string_tuple(data, "find_links"),
544
+ constraints=tuple(str(path) for path in optional_manifest_paths(data, "constraints", base_dir)),
498
545
  description=optional_string(data, "description"),
499
546
  homepage=optional_string(data, "homepage"),
500
547
  )
@@ -553,6 +600,7 @@ def environment_actions(spec: EnvironmentSpec, paths: DeploymentPaths) -> tuple[
553
600
  tool.python,
554
601
  tool.indexes,
555
602
  tool.find_links,
603
+ tool.constraints,
556
604
  tool.uv_config_file,
557
605
  )
558
606
  actions.append(
@@ -605,19 +653,17 @@ def deploy_environment(
605
653
  for tool in spec.tools:
606
654
  if tool.tool_type == "python" and tool.package:
607
655
  require_executable("uv")
608
- env = os.environ.copy()
609
- env["UV_TOOL_DIR"] = str(tool_dir)
610
- env["UV_TOOL_BIN_DIR"] = str(paths.bin_dir)
611
656
  subprocess.run(
612
657
  uv_install_command(
613
658
  tool.package,
614
659
  tool.python,
615
660
  tool.indexes,
616
661
  tool.find_links,
662
+ tool.constraints,
617
663
  tool.uv_config_file,
618
664
  ),
619
665
  check=True,
620
- env=env,
666
+ env=uv_install_environment(tool_dir, paths.bin_dir),
621
667
  )
622
668
  elif tool.tool_type == "rust" and tool.binary:
623
669
  copy_executable(tool.binary, paths.bin_dir / tool.name)
@@ -654,6 +700,7 @@ def deploy_python_tool(
654
700
  python: str | None = None,
655
701
  indexes: tuple[str, ...] = (),
656
702
  find_links: tuple[str, ...] = (),
703
+ constraints: tuple[str, ...] = (),
657
704
  uv_config_file: Path | None = None,
658
705
  execute_install: bool = False,
659
706
  make_default: bool = True,
@@ -671,6 +718,7 @@ def deploy_python_tool(
671
718
  python: Optional Python interpreter or version passed to uv.
672
719
  indexes: Additional package index URLs passed to uv.
673
720
  find_links: Wheelhouse directories or HTML package pages passed to uv.
721
+ constraints: Constraint requirement files passed to uv.
674
722
  uv_config_file: Optional uv configuration file passed to `uv tool`.
675
723
  execute_install: Whether to run `uv tool install` immediately.
676
724
  make_default: Whether to make this version the module default.
@@ -686,14 +734,11 @@ def deploy_python_tool(
686
734
  paths = deployment_paths(module_root, prefix, name, version)
687
735
  paths.bin_dir.mkdir(parents=True, exist_ok=True)
688
736
  tool_dir = paths.install_root / "uv-tools"
689
- command = uv_install_command(package, python, indexes, find_links, uv_config_file)
737
+ command = uv_install_command(package, python, indexes, find_links, constraints, uv_config_file)
690
738
 
691
739
  if execute_install:
692
740
  require_executable("uv")
693
- env = os.environ.copy()
694
- env["UV_TOOL_DIR"] = str(tool_dir)
695
- env["UV_TOOL_BIN_DIR"] = str(paths.bin_dir)
696
- subprocess.run(command, check=True, env=env)
741
+ subprocess.run(command, check=True, env=uv_install_environment(tool_dir, paths.bin_dir))
697
742
 
698
743
  install_hint = (
699
744
  f"UV_TOOL_DIR={shlex.quote(str(tool_dir))} "
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "env-module-manager"
3
- version = "0.4.0"
3
+ version = "0.6.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"
@@ -111,8 +111,13 @@ docstring-code-format = true
111
111
  [tool.pytest.ini_options]
112
112
  minversion = "7.0"
113
113
  pythonpath = ["."]
114
+ markers = [
115
+ "integration: executes external tools for end-to-end verification",
116
+ ]
114
117
  addopts = [
115
118
  "-ra",
119
+ "-m",
120
+ "not integration",
116
121
  "--strict-markers",
117
122
  "--strict-config",
118
123
  "--tb=short",
@@ -56,6 +56,8 @@ def test_deploy_python_command_writes_modulefile() -> None:
56
56
  "https://packages.example/simple",
57
57
  "--find-links",
58
58
  "/prod/wheels",
59
+ "--constraints",
60
+ "/prod/constraints.txt",
59
61
  "--uv-config-file",
60
62
  "/prod/uv.toml",
61
63
  ],
@@ -66,6 +68,7 @@ def test_deploy_python_command_writes_modulefile() -> None:
66
68
  assert "modulefile: modules/ruff/0.8.0" in result.output
67
69
  assert "default version: modules/ruff/.version" in result.output
68
70
  assert "--config-file /prod/uv.toml" in modulefile
71
+ assert "--constraints /prod/constraints.txt" in modulefile
69
72
 
70
73
 
71
74
  def test_deploy_python_command_uses_config_defaults() -> None:
@@ -105,6 +108,33 @@ uv_config_file = "uv.toml"
105
108
  assert "--config-file uv.toml" in modulefile
106
109
 
107
110
 
111
+ def test_deploy_command_rejects_matching_prefix_and_module_root() -> None:
112
+ """Deployment roots must differ to avoid a modulefile path collision."""
113
+ runner = CliRunner()
114
+
115
+ with runner.isolated_filesystem():
116
+ result = runner.invoke(
117
+ main,
118
+ [
119
+ "deploy-python",
120
+ "gitconductor",
121
+ "0.7.0",
122
+ "--package",
123
+ "gitconductor",
124
+ "--prefix",
125
+ "test",
126
+ "--module-root",
127
+ "test",
128
+ ],
129
+ )
130
+ collision_path_exists = Path("test/gitconductor/0.7.0").exists()
131
+
132
+ assert result.exit_code != 0
133
+ output = " ".join(strip_ansi(result.output).split())
134
+ assert "--module-root and --prefix must resolve to different directories" in output
135
+ assert not collision_path_exists
136
+
137
+
108
138
  def test_cli_options_override_config_defaults() -> None:
109
139
  """CLI options should take precedence over configured defaults."""
110
140
  runner = CliRunner()
@@ -381,6 +411,7 @@ name = "ruff"
381
411
  version = "0.8.0"
382
412
  package = "ruff==0.8.0"
383
413
  indexes = ["https://packages.example/simple"]
414
+ constraints = ["constraints.txt"]
384
415
  """.strip(),
385
416
  encoding="utf-8",
386
417
  )
@@ -391,6 +422,7 @@ indexes = ["https://packages.example/simple"]
391
422
  assert "would create install root: tools/dev-tools/2026.05" in result.output
392
423
  assert "would install python ruff:" in result.output
393
424
  assert "--index https://packages.example/simple" in result.output
425
+ assert "--constraints constraints.txt" in result.output
394
426
  assert not install_root_exists
395
427
 
396
428
 
@@ -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.6.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.6.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.6.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,7 @@
1
1
  """Modulefile rendering and deployment helper tests."""
2
2
 
3
3
  from pathlib import Path
4
+ from shlex import quote as shlex_quote
4
5
 
5
6
  import pytest
6
7
 
@@ -14,6 +15,7 @@ from module_manager.deploy import (
14
15
  require_executable,
15
16
  uninstall_tool,
16
17
  uv_install_command,
18
+ uv_install_environment,
17
19
  )
18
20
  from module_manager.modulefile import (
19
21
  ModuleSpec,
@@ -83,6 +85,7 @@ def test_python_tool_install_command_accepts_indexes() -> None:
83
85
  "internal-tool==1.2.3",
84
86
  indexes=("https://packages.example/simple", "https://mirror.example/simple"),
85
87
  find_links=("/prod/wheels",),
88
+ constraints=("/prod/constraints.txt",),
86
89
  ) == [
87
90
  "uv",
88
91
  "tool",
@@ -93,6 +96,8 @@ def test_python_tool_install_command_accepts_indexes() -> None:
93
96
  "https://mirror.example/simple",
94
97
  "--find-links",
95
98
  "/prod/wheels",
99
+ "--constraints",
100
+ "/prod/constraints.txt",
96
101
  "internal-tool==1.2.3",
97
102
  ]
98
103
 
@@ -121,6 +126,42 @@ def test_python_tool_install_command_accepts_uv_config_file() -> None:
121
126
  ]
122
127
 
123
128
 
129
+ def test_python_tool_install_command_accepts_url_dependency() -> None:
130
+ """Uv install commands should pass direct URL dependencies unchanged."""
131
+ package = "mytool @ https://packages.example/files/mytool-1.0.0-py3-none-any.whl"
132
+
133
+ assert uv_install_command(package, constraints=("/prod/constraints.txt",)) == [
134
+ "uv",
135
+ "tool",
136
+ "install",
137
+ "--constraints",
138
+ "/prod/constraints.txt",
139
+ package,
140
+ ]
141
+
142
+
143
+ def test_uv_install_environment_ignores_inherited_uv_tool_settings(
144
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
145
+ ) -> None:
146
+ """Managed uv installs should ignore inherited tool-specific settings.
147
+
148
+ Args:
149
+ tmp_path: Temporary deployment root.
150
+ monkeypatch: Pytest helper used to control the process environment.
151
+ """
152
+ monkeypatch.setenv("UV_TOOL_DIR", "/inherited/tools")
153
+ monkeypatch.setenv("UV_TOOL_BIN_DIR", "/inherited/bin")
154
+ monkeypatch.setenv("UV_TOOL_UPGRADE", "1")
155
+ monkeypatch.setenv("UV_CONFIG_FILE", "/prod/uv.toml")
156
+
157
+ env = uv_install_environment(tmp_path / "tools", tmp_path / "bin")
158
+
159
+ assert env["UV_TOOL_DIR"] == str(tmp_path / "tools")
160
+ assert env["UV_TOOL_BIN_DIR"] == str(tmp_path / "bin")
161
+ assert "UV_TOOL_UPGRADE" not in env
162
+ assert env["UV_CONFIG_FILE"] == "/prod/uv.toml"
163
+
164
+
124
165
  def test_deploy_python_tool_writes_package_sources_to_install_hint(tmp_path: Path) -> None:
125
166
  """Generated module help should show the package source options.
126
167
 
@@ -135,6 +176,7 @@ def test_deploy_python_tool_writes_package_sources_to_install_hint(tmp_path: Pat
135
176
  prefix=tmp_path / "tools",
136
177
  indexes=("https://packages.example/simple",),
137
178
  find_links=("/prod/wheels",),
179
+ constraints=("/prod/constraints.txt",),
138
180
  uv_config_file=Path("/prod/uv.toml"),
139
181
  )
140
182
 
@@ -142,6 +184,28 @@ def test_deploy_python_tool_writes_package_sources_to_install_hint(tmp_path: Pat
142
184
  assert "--config-file /prod/uv.toml" in modulefile
143
185
  assert "--index https://packages.example/simple" in modulefile
144
186
  assert "--find-links /prod/wheels" in modulefile
187
+ assert "--constraints /prod/constraints.txt" in modulefile
188
+
189
+
190
+ def test_deploy_python_tool_writes_url_dependency_to_install_hint(tmp_path: Path) -> None:
191
+ """Generated module help should preserve direct URL package specs.
192
+
193
+ Args:
194
+ tmp_path: Temporary deployment root.
195
+ """
196
+ package = "mytool @ https://packages.example/files/mytool-1.0.0-py3-none-any.whl"
197
+ paths = deploy_python_tool(
198
+ name="mytool",
199
+ version="1.0.0",
200
+ package=package,
201
+ module_root=tmp_path / "modules",
202
+ prefix=tmp_path / "tools",
203
+ constraints=("/prod/constraints.txt",),
204
+ )
205
+
206
+ modulefile = paths.modulefile.read_text(encoding="utf-8")
207
+ assert shlex_quote(package) in modulefile
208
+ assert "--constraints /prod/constraints.txt" in modulefile
145
209
 
146
210
 
147
211
  def test_require_executable_reports_missing_command(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -380,6 +444,7 @@ package = "ruff==0.8.0"
380
444
  python = "3.12"
381
445
  indexes = ["https://packages.example/simple"]
382
446
  find_links = ["/prod/wheels"]
447
+ constraints = ["constraints.txt", "/prod/global-constraints.txt"]
383
448
  uv_config_file = "uv.toml"
384
449
 
385
450
  [[tools]]
@@ -399,6 +464,7 @@ script = "scripts/helper"
399
464
  assert not spec.make_default
400
465
  assert spec.tools[0].package == "ruff==0.8.0"
401
466
  assert spec.tools[0].indexes == ("https://packages.example/simple",)
467
+ assert spec.tools[0].constraints == (str(tmp_path / "constraints.txt"), "/prod/global-constraints.txt")
402
468
  assert spec.tools[0].uv_config_file == tmp_path / "uv.toml"
403
469
  assert spec.tools[1].script == tmp_path / "scripts/helper"
404
470
 
@@ -507,6 +573,7 @@ type = "python"
507
573
  name = "ruff"
508
574
  package = "ruff==0.8.0"
509
575
  python = "3.12"
576
+ constraints = ["constraints.txt"]
510
577
  uv_config_file = "uv.toml"
511
578
  """.strip(),
512
579
  encoding="utf-8",
@@ -527,6 +594,8 @@ uv_config_file = "uv.toml"
527
594
  "install",
528
595
  "--python",
529
596
  "3.12",
597
+ "--constraints",
598
+ str(tmp_path / "constraints.txt"),
530
599
  "ruff==0.8.0",
531
600
  ]
532
601
  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.4.0"
347
+ version = "0.6.0"
348
348
  source = { editable = "." }
349
349
  dependencies = [
350
350
  { name = "click" },