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.
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.github/workflows/test.yml +21 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.gitignore +1 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/PKG-INFO +3 -1
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/README.md +2 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/configuration.md +4 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/development.md +8 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/python-tools.md +4 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/module_manager/__init__.py +1 -1
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/module_manager/cli.py +43 -12
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/module_manager/deploy.py +19 -8
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/pyproject.toml +6 -1
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/tests/test_cli.py +27 -0
- env_module_manager-0.5.0/tests/test_integration.py +49 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/tests/test_modulefile.py +23 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/uv.lock +1 -1
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.github/workflows/publish.yml +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.github/workflows/tag.yml +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.python-version +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/.readthedocs.yaml +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/AGENTS.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/CONTRIBUTING.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/LICENSE.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/collective-environments.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/environment-modules.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/index.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/installation.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/reference.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/rust-tools.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/shell-tools.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/stylesheets/extra.css +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/docs/uninstall.md +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/example.bash +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/mkdocs.yml +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/module_manager/config.py +0 -0
- {env_module_manager-0.4.0 → env_module_manager-0.5.0}/module_manager/modulefile.py +0 -0
- {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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: env-module-manager
|
|
3
|
-
Version: 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:
|
|
@@ -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.
|
|
@@ -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 =
|
|
254
|
-
|
|
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 =
|
|
323
|
-
|
|
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 =
|
|
391
|
-
|
|
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 =
|
|
480
|
+
resolved_module_root, resolved_prefix = require_locations(
|
|
475
481
|
module_root or spec.module_root or config.module_root,
|
|
476
|
-
|
|
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 =
|
|
537
|
-
|
|
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=
|
|
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
|
-
|
|
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.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|