env-module-manager 0.4.0__tar.gz → 0.5.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.5.0}/.github/workflows/test.yml +21 -0
  2. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.gitignore +1 -0
  3. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/PKG-INFO +3 -1
  4. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/README.md +2 -0
  5. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/configuration.md +4 -0
  6. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/development.md +8 -0
  7. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/python-tools.md +4 -0
  8. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/module_manager/__init__.py +1 -1
  9. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/module_manager/cli.py +43 -12
  10. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/module_manager/deploy.py +19 -8
  11. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/pyproject.toml +6 -1
  12. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/tests/test_cli.py +27 -0
  13. env_module_manager-0.5.0/tests/test_integration.py +49 -0
  14. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/tests/test_modulefile.py +23 -0
  15. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/uv.lock +1 -1
  16. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.github/workflows/publish.yml +0 -0
  17. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.github/workflows/tag.yml +0 -0
  18. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.python-version +0 -0
  19. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.readthedocs.yaml +0 -0
  20. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/AGENTS.md +0 -0
  21. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/CONTRIBUTING.md +0 -0
  22. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/LICENSE.md +0 -0
  23. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/collective-environments.md +0 -0
  24. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/environment-modules.md +0 -0
  25. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/index.md +0 -0
  26. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/installation.md +0 -0
  27. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/reference.md +0 -0
  28. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/rust-tools.md +0 -0
  29. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/shell-tools.md +0 -0
  30. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/stylesheets/extra.css +0 -0
  31. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/uninstall.md +0 -0
  32. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/example.bash +0 -0
  33. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/mkdocs.yml +0 -0
  34. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/module_manager/config.py +0 -0
  35. {env_module_manager-0.4.0 → env_module_manager-0.5.0}/module_manager/modulefile.py +0 -0
  36. {env_module_manager-0.4.0 → env_module_manager-0.5.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.5.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:
@@ -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:
@@ -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.
@@ -1,3 +1,3 @@
1
1
  """Deployment helpers for HPC environment module files."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -250,8 +250,10 @@ def deploy_python(
250
250
  `PATH`.
251
251
  subprocess.CalledProcessError: If `uv tool install` fails.
252
252
  """
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")
253
+ resolved_module_root, resolved_prefix = require_locations(
254
+ module_root or config.module_root,
255
+ prefix or config.prefix,
256
+ )
255
257
  paths = deploy_python_tool(
256
258
  name=name,
257
259
  version=version,
@@ -319,8 +321,10 @@ def deploy_rust(
319
321
  Raises:
320
322
  click.UsageError: If required paths are missing.
321
323
  """
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")
324
+ resolved_module_root, resolved_prefix = require_locations(
325
+ module_root or config.module_root,
326
+ prefix or config.prefix,
327
+ )
324
328
  paths = deploy_rust_tool(
325
329
  name=name,
326
330
  version=version,
@@ -387,8 +391,10 @@ def deploy_script(
387
391
  Raises:
388
392
  click.UsageError: If required paths are missing.
389
393
  """
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")
394
+ resolved_module_root, resolved_prefix = require_locations(
395
+ module_root or config.module_root,
396
+ prefix or config.prefix,
397
+ )
392
398
  paths = deploy_script_tool(
393
399
  name=name,
394
400
  version=version,
@@ -471,12 +477,10 @@ def deploy_env(
471
477
  except (OSError, TypeError, tomllib.TOMLDecodeError) as error:
472
478
  raise click.ClickException(str(error)) from error
473
479
 
474
- resolved_module_root = require_path(
480
+ resolved_module_root, resolved_prefix = require_locations(
475
481
  module_root or spec.module_root or config.module_root,
476
- "module root",
477
- "--module-root",
482
+ prefix or spec.prefix or config.prefix,
478
483
  )
479
- resolved_prefix = require_path(prefix or spec.prefix or config.prefix, "install prefix", "--prefix")
480
484
  if make_default is not None:
481
485
  spec = spec.__class__(
482
486
  name=spec.name,
@@ -533,8 +537,10 @@ def uninstall(
533
537
  Raises:
534
538
  click.UsageError: If required paths are missing.
535
539
  """
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")
540
+ resolved_module_root, resolved_prefix = require_locations(
541
+ module_root or config.module_root,
542
+ prefix or config.prefix,
543
+ )
538
544
  result = uninstall_tool(
539
545
  name=name,
540
546
  version=version,
@@ -569,6 +575,31 @@ def require_path(value: Path | None, label: str, option: str) -> Path:
569
575
  return value.expanduser()
570
576
 
571
577
 
578
+ def require_locations(module_root: Path | None, prefix: Path | None) -> tuple[Path, Path]:
579
+ """Return distinct configured module and installation roots.
580
+
581
+ Args:
582
+ module_root: Candidate root of the module tree.
583
+ prefix: Candidate installation prefix.
584
+
585
+ Returns:
586
+ Expanded module root and installation prefix.
587
+
588
+ Raises:
589
+ click.UsageError: If either path is missing or both resolve to the same
590
+ directory.
591
+ """
592
+ resolved_module_root = require_path(module_root, "module root", "--module-root")
593
+ resolved_prefix = require_path(prefix, "install prefix", "--prefix")
594
+ if resolved_module_root.resolve() == resolved_prefix.resolve():
595
+ msg = (
596
+ "--module-root and --prefix must resolve to different directories; "
597
+ "otherwise the versioned modulefile collides with the install root."
598
+ )
599
+ raise click.UsageError(msg)
600
+ return resolved_module_root, resolved_prefix
601
+
602
+
572
603
  def print_result(
573
604
  modulefile: Path,
574
605
  install_root: Path,
@@ -178,6 +178,23 @@ def uv_install_command(
178
178
  return command
179
179
 
180
180
 
181
+ def uv_install_environment(tool_dir: Path, bin_dir: Path) -> dict[str, str]:
182
+ """Build an isolated environment for a managed uv tool installation.
183
+
184
+ Args:
185
+ tool_dir: Directory where uv should store installed tools.
186
+ bin_dir: Directory where uv should install tool executables.
187
+
188
+ Returns:
189
+ Process environment with inherited `UV_TOOL_*` settings removed and
190
+ the managed tool destinations set.
191
+ """
192
+ env = {key: value for key, value in os.environ.items() if not key.startswith("UV_TOOL_")}
193
+ env["UV_TOOL_DIR"] = str(tool_dir)
194
+ env["UV_TOOL_BIN_DIR"] = str(bin_dir)
195
+ return env
196
+
197
+
181
198
  def write_text(path: Path, content: str) -> None:
182
199
  """Write UTF-8 text to a path, creating parent directories first.
183
200
 
@@ -605,9 +622,6 @@ def deploy_environment(
605
622
  for tool in spec.tools:
606
623
  if tool.tool_type == "python" and tool.package:
607
624
  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
625
  subprocess.run(
612
626
  uv_install_command(
613
627
  tool.package,
@@ -617,7 +631,7 @@ def deploy_environment(
617
631
  tool.uv_config_file,
618
632
  ),
619
633
  check=True,
620
- env=env,
634
+ env=uv_install_environment(tool_dir, paths.bin_dir),
621
635
  )
622
636
  elif tool.tool_type == "rust" and tool.binary:
623
637
  copy_executable(tool.binary, paths.bin_dir / tool.name)
@@ -690,10 +704,7 @@ def deploy_python_tool(
690
704
 
691
705
  if execute_install:
692
706
  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)
707
+ subprocess.run(command, check=True, env=uv_install_environment(tool_dir, paths.bin_dir))
697
708
 
698
709
  install_hint = (
699
710
  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.5.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",
@@ -105,6 +105,33 @@ uv_config_file = "uv.toml"
105
105
  assert "--config-file uv.toml" in modulefile
106
106
 
107
107
 
108
+ def test_deploy_command_rejects_matching_prefix_and_module_root() -> None:
109
+ """Deployment roots must differ to avoid a modulefile path collision."""
110
+ runner = CliRunner()
111
+
112
+ with runner.isolated_filesystem():
113
+ result = runner.invoke(
114
+ main,
115
+ [
116
+ "deploy-python",
117
+ "gitconductor",
118
+ "0.7.0",
119
+ "--package",
120
+ "gitconductor",
121
+ "--prefix",
122
+ "test",
123
+ "--module-root",
124
+ "test",
125
+ ],
126
+ )
127
+ collision_path_exists = Path("test/gitconductor/0.7.0").exists()
128
+
129
+ assert result.exit_code != 0
130
+ output = " ".join(strip_ansi(result.output).split())
131
+ assert "--module-root and --prefix must resolve to different directories" in output
132
+ assert not collision_path_exists
133
+
134
+
108
135
  def test_cli_options_override_config_defaults() -> None:
109
136
  """CLI options should take precedence over configured defaults."""
110
137
  runner = CliRunner()
@@ -0,0 +1,49 @@
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()
@@ -14,6 +14,7 @@ from module_manager.deploy import (
14
14
  require_executable,
15
15
  uninstall_tool,
16
16
  uv_install_command,
17
+ uv_install_environment,
17
18
  )
18
19
  from module_manager.modulefile import (
19
20
  ModuleSpec,
@@ -121,6 +122,28 @@ def test_python_tool_install_command_accepts_uv_config_file() -> None:
121
122
  ]
122
123
 
123
124
 
125
+ def test_uv_install_environment_ignores_inherited_uv_tool_settings(
126
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
127
+ ) -> None:
128
+ """Managed uv installs should ignore inherited tool-specific settings.
129
+
130
+ Args:
131
+ tmp_path: Temporary deployment root.
132
+ monkeypatch: Pytest helper used to control the process environment.
133
+ """
134
+ monkeypatch.setenv("UV_TOOL_DIR", "/inherited/tools")
135
+ monkeypatch.setenv("UV_TOOL_BIN_DIR", "/inherited/bin")
136
+ monkeypatch.setenv("UV_TOOL_UPGRADE", "1")
137
+ monkeypatch.setenv("UV_CONFIG_FILE", "/prod/uv.toml")
138
+
139
+ env = uv_install_environment(tmp_path / "tools", tmp_path / "bin")
140
+
141
+ assert env["UV_TOOL_DIR"] == str(tmp_path / "tools")
142
+ assert env["UV_TOOL_BIN_DIR"] == str(tmp_path / "bin")
143
+ assert "UV_TOOL_UPGRADE" not in env
144
+ assert env["UV_CONFIG_FILE"] == "/prod/uv.toml"
145
+
146
+
124
147
  def test_deploy_python_tool_writes_package_sources_to_install_hint(tmp_path: Path) -> None:
125
148
  """Generated module help should show the package source options.
126
149
 
@@ -344,7 +344,7 @@ wheels = [
344
344
 
345
345
  [[package]]
346
346
  name = "env-module-manager"
347
- version = "0.4.0"
347
+ version = "0.5.0"
348
348
  source = { editable = "." }
349
349
  dependencies = [
350
350
  { name = "click" },