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