env-module-manager 0.3.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.3.0 → env_module_manager-0.5.0}/.github/workflows/test.yml +21 -0
  2. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/.gitignore +1 -0
  3. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/AGENTS.md +3 -0
  4. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/PKG-INFO +19 -10
  5. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/README.md +18 -9
  6. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/collective-environments.md +1 -0
  7. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/configuration.md +9 -0
  8. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/development.md +8 -0
  9. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/installation.md +9 -3
  10. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/python-tools.md +27 -0
  11. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/reference.md +1 -0
  12. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/module_manager/__init__.py +1 -1
  13. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/module_manager/cli.py +128 -21
  14. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/module_manager/config.py +4 -0
  15. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/module_manager/deploy.py +45 -12
  16. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/pyproject.toml +6 -1
  17. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/tests/test_cli.py +34 -0
  18. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/tests/test_config.py +2 -0
  19. env_module_manager-0.5.0/tests/test_integration.py +49 -0
  20. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/tests/test_modulefile.py +50 -1
  21. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/uv.lock +1 -1
  22. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/.github/workflows/publish.yml +0 -0
  23. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/.github/workflows/tag.yml +0 -0
  24. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/.python-version +0 -0
  25. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/.readthedocs.yaml +0 -0
  26. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/CONTRIBUTING.md +0 -0
  27. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/LICENSE.md +0 -0
  28. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/environment-modules.md +0 -0
  29. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/index.md +0 -0
  30. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/rust-tools.md +0 -0
  31. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/shell-tools.md +0 -0
  32. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/stylesheets/extra.css +0 -0
  33. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/docs/uninstall.md +0 -0
  34. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/example.bash +0 -0
  35. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/mkdocs.yml +0 -0
  36. {env_module_manager-0.3.0 → env_module_manager-0.5.0}/module_manager/modulefile.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
@@ -90,6 +90,9 @@ Common settings:
90
90
  * `module_root` or `MODULE_MANAGER_MODULE_ROOT`
91
91
  * `[python].indexes`, `MODULE_MANAGER_INDEX`, or `MODULE_MANAGER_INDEXES`
92
92
  * `[python].find_links` or `MODULE_MANAGER_FIND_LINKS`
93
+ * `[python].uv_config_file` for the uv `--config-file` option. Do not add a
94
+ `MODULE_MANAGER_*` wrapper for this; users should use uv's `UV_CONFIG_FILE`
95
+ environment variable when they want environment-based uv configuration.
93
96
 
94
97
  ## Documentation
95
98
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: env-module-manager
3
- Version: 0.3.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,22 @@ 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
+
55
+ ## Installation
56
+
57
+ Install from PyPI:
58
+
59
+ ```sh
60
+ pip install env-module-manager
61
+ ```
62
+
63
+ Or install as a uv-managed tool:
64
+
65
+ ```sh
66
+ uv tool install env-module-manager
67
+ ```
68
+
53
69
  ## Quick Start
54
70
 
55
71
  Deploy a Python CLI with `uv tool install`:
@@ -108,12 +124,5 @@ module-manager uninstall ruff 0.8.0 \
108
124
 
109
125
  ## Documentation
110
126
 
111
- Full documentation lives in `docs/` and can be served with MkDocs:
112
-
113
- ```sh
114
- uv run mkdocs serve
115
- ```
116
-
117
- Start with `docs/index.md` for installation, configuration, Python tools, Rust
118
- tools, shell scripts, collective environments, uninstall, and local development
119
- notes.
127
+ Read the full documentation on
128
+ [Read the Docs](https://module-manager.readthedocs.io/).
@@ -18,6 +18,22 @@ 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
+
23
+ ## Installation
24
+
25
+ Install from PyPI:
26
+
27
+ ```sh
28
+ pip install env-module-manager
29
+ ```
30
+
31
+ Or install as a uv-managed tool:
32
+
33
+ ```sh
34
+ uv tool install env-module-manager
35
+ ```
36
+
21
37
  ## Quick Start
22
38
 
23
39
  Deploy a Python CLI with `uv tool install`:
@@ -76,12 +92,5 @@ module-manager uninstall ruff 0.8.0 \
76
92
 
77
93
  ## Documentation
78
94
 
79
- Full documentation lives in `docs/` and can be served with MkDocs:
80
-
81
- ```sh
82
- uv run mkdocs serve
83
- ```
84
-
85
- Start with `docs/index.md` for installation, configuration, Python tools, Rust
86
- tools, shell scripts, collective environments, uninstall, and local development
87
- notes.
95
+ Read the full documentation on
96
+ [Read the Docs](https://module-manager.readthedocs.io/).
@@ -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
+ uv_config_file = "/prod/config/uv.toml"
25
26
 
26
27
  [[tools]]
27
28
  type = "rust"
@@ -17,8 +17,13 @@ module_root = "/prod/modulefiles"
17
17
  [python]
18
18
  indexes = ["https://packages.example/simple"]
19
19
  find_links = ["/prod/wheels"]
20
+ uv_config_file = "/prod/config/uv.toml"
20
21
  ```
21
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
+
22
27
  Use a different config file with `--config`:
23
28
 
24
29
  ```sh
@@ -36,6 +41,10 @@ export MODULE_MANAGER_FIND_LINKS=/prod/wheels,/prod/more-wheels
36
41
 
37
42
  `MODULE_MANAGER_CONFIG` can point to an alternate TOML config file.
38
43
 
44
+ There is no `MODULE_MANAGER_*` environment variable for uv configuration files.
45
+ Use uv's own `UV_CONFIG_FILE` environment variable when you want to set that via
46
+ the environment.
47
+
39
48
  ## CLI Precedence
40
49
 
41
50
  CLI options override both environment variables and TOML config:
@@ -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
@@ -38,18 +38,24 @@ in every new shell.
38
38
 
39
39
  ## Install module-manager
40
40
 
41
- From a local checkout:
41
+ From PyPI with pip:
42
42
 
43
43
  ```sh
44
- uv tool install .
44
+ pip install env-module-manager
45
45
  ```
46
46
 
47
- From PyPI:
47
+ As a uv-managed tool:
48
48
 
49
49
  ```sh
50
50
  uv tool install env-module-manager
51
51
  ```
52
52
 
53
+ From a local checkout:
54
+
55
+ ```sh
56
+ uv tool install .
57
+ ```
58
+
53
59
  During development, use:
54
60
 
55
61
  ```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,29 @@ module-manager deploy-python gitconductor 0.1.0 \
51
55
 
52
56
  `--find-links` may be used more than once.
53
57
 
58
+ ## Use a uv Config File
59
+
60
+ Pass a specific uv configuration file to `uv tool install`:
61
+
62
+ ```sh
63
+ module-manager deploy-python internal-tool 1.2.3 \
64
+ --package internal-tool==1.2.3 \
65
+ --uv-config-file /prod/config/uv.toml \
66
+ --prefix /prod/tools \
67
+ --module-root /prod/modulefiles \
68
+ --execute-install
69
+ ```
70
+
71
+ The same option can be set in the module-manager config file:
72
+
73
+ ```toml
74
+ [python]
75
+ uv_config_file = "/prod/config/uv.toml"
76
+ ```
77
+
78
+ Use uv's own `UV_CONFIG_FILE` environment variable if you want to configure uv
79
+ through the environment.
80
+
54
81
  ## Deploy from VCS
55
82
 
56
83
  `--package` is passed directly to `uv tool install`, so VCS package specs are
@@ -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
+ [--uv-config-file PATH] \
47
48
  [--prefix PATH] \
48
49
  [--module-root PATH] \
49
50
  [--no-default] \
@@ -1,3 +1,3 @@
1
1
  """Deployment helpers for HPC environment module files."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.5.0"
@@ -29,14 +29,61 @@ click.rich_click.STYLE_ARGUMENT = "bold yellow"
29
29
  click.rich_click.STYLE_SWITCH = "bold magenta"
30
30
  click.rich_click.STYLE_METAVAR = "yellow"
31
31
  click.rich_click.HEADER_TEXT = "module-manager"
32
- click.rich_click.FOOTER_TEXT = (
33
- "Examples: [cyan]module-manager deploy-python ruff 0.8.0 --package "
34
- "ruff==0.8.0 --prefix /prod/tools --module-root /prod/modulefiles[/cyan]"
35
- )
36
32
 
37
33
  PATH = click.Path(path_type=Path)
38
34
  ClickCommand = TypeVar("ClickCommand", bound=Command)
39
35
 
36
+ TOP_LEVEL_EXAMPLES = (
37
+ "Examples:\n\n"
38
+ "[cyan]module-manager deploy-python ruff 0.8.0 --package ruff==0.8.0 "
39
+ "--prefix /prod/tools --module-root /prod/modulefiles[/cyan]\n\n"
40
+ "[cyan]module-manager deploy-rust ripgrep 14.1.1 --binary ./target/release/rg "
41
+ "--prefix /prod/tools --module-root /prod/modulefiles[/cyan]\n\n"
42
+ "[cyan]module-manager deploy-script my-tool 1.0.0 --script ./scripts/my-tool "
43
+ "--prefix /prod/tools --module-root /prod/modulefiles[/cyan]\n\n"
44
+ "[cyan]module-manager deploy-env --file dev-tools.toml[/cyan]\n\n"
45
+ "[cyan]module-manager uninstall ruff 0.8.0 --prefix /prod/tools --module-root /prod/modulefiles[/cyan]"
46
+ )
47
+
48
+ DEPLOY_PYTHON_EXAMPLES = (
49
+ "Examples:\n\n"
50
+ "[cyan]module-manager deploy-python ruff 0.8.0 --package ruff==0.8.0 "
51
+ "--prefix /prod/tools --module-root /prod/modulefiles --execute-install[/cyan]\n\n"
52
+ "[cyan]module-manager deploy-python internal-tool 1.2.3 --package internal-tool==1.2.3 "
53
+ "--uv-config-file /prod/config/uv.toml --prefix /prod/tools --module-root /prod/modulefiles[/cyan]"
54
+ )
55
+
56
+ DEPLOY_RUST_EXAMPLES = (
57
+ "Examples:\n\n"
58
+ "[cyan]module-manager deploy-rust ripgrep 14.1.1 --binary ./target/release/rg "
59
+ "--prefix /prod/tools --module-root /prod/modulefiles[/cyan]\n\n"
60
+ "[cyan]module-manager deploy-rust ripgrep 14.1.1 --binary ./target/release/rg "
61
+ "--prefix /prod/tools --module-root /prod/modulefiles --dry-run[/cyan]"
62
+ )
63
+
64
+ DEPLOY_SCRIPT_EXAMPLES = (
65
+ "Examples:\n\n"
66
+ "[cyan]module-manager deploy-script my-tool 1.0.0 --script ./scripts/my-tool "
67
+ "--prefix /prod/tools --module-root /prod/modulefiles[/cyan]\n\n"
68
+ "[cyan]module-manager deploy-script my-tool 1.0.0 --script ./scripts/my-tool "
69
+ "--prefix /prod/tools --module-root /prod/modulefiles --dry-run[/cyan]"
70
+ )
71
+
72
+ DEPLOY_ENV_EXAMPLES = (
73
+ "Examples:\n\n"
74
+ "[cyan]module-manager deploy-env --file dev-tools.toml[/cyan]\n\n"
75
+ "[cyan]module-manager deploy-env --file dev-tools.toml --prefix /scratch/tools "
76
+ "--module-root /scratch/modulefiles --dry-run[/cyan]"
77
+ )
78
+
79
+ UNINSTALL_EXAMPLES = (
80
+ "Examples:\n\n"
81
+ "[cyan]module-manager uninstall ruff 0.8.0 --prefix /prod/tools "
82
+ "--module-root /prod/modulefiles[/cyan]\n\n"
83
+ "[cyan]module-manager uninstall ruff 0.8.0 --prefix /prod/tools "
84
+ "--module-root /prod/modulefiles --dry-run[/cyan]"
85
+ )
86
+
40
87
 
41
88
  def common_options(command: ClickCommand) -> ClickCommand:
42
89
  """Attach options shared by deployment commands.
@@ -108,6 +155,7 @@ def location_options(command: ClickCommand) -> ClickCommand:
108
155
  "Build versioned modulefiles for Python tools, Rust binaries, shell scripts, "
109
156
  "and collective environments."
110
157
  ),
158
+ epilog=TOP_LEVEL_EXAMPLES,
111
159
  )
112
160
  @click.version_option(__version__)
113
161
  @click.option(
@@ -131,7 +179,11 @@ def main(ctx: click.Context, config: Path | None) -> None:
131
179
  ctx.obj = load_config(config)
132
180
 
133
181
 
134
- @main.command("deploy-python", help="Write a modulefile for a uv tool install Python CLI.")
182
+ @main.command(
183
+ "deploy-python",
184
+ help="Write a modulefile for a uv tool install Python CLI.",
185
+ epilog=DEPLOY_PYTHON_EXAMPLES,
186
+ )
135
187
  @common_options
136
188
  @click.option("--package", "package", required=True, help="Package spec passed to uv tool install.")
137
189
  @click.option("--python", "python", help="Python interpreter/version passed to uv.")
@@ -147,6 +199,11 @@ def main(ctx: click.Context, config: Path | None) -> None:
147
199
  multiple=True,
148
200
  help="Directory or HTML page of packages passed to uv. May be used more than once.",
149
201
  )
202
+ @click.option(
203
+ "--uv-config-file",
204
+ type=PATH,
205
+ help="uv.toml file passed to uv tool install with --config-file.",
206
+ )
150
207
  @click.option(
151
208
  "--execute-install",
152
209
  is_flag=True,
@@ -166,6 +223,7 @@ def deploy_python(
166
223
  python: str | None,
167
224
  indexes: tuple[str, ...],
168
225
  find_links: tuple[str, ...],
226
+ uv_config_file: Path | None,
169
227
  execute_install: bool,
170
228
  ) -> None:
171
229
  """Write a modulefile for a [cyan]uv tool install[/cyan] Python CLI.
@@ -183,6 +241,7 @@ def deploy_python(
183
241
  python: Optional Python interpreter or version passed to uv.
184
242
  indexes: Additional package index URLs passed to uv.
185
243
  find_links: Wheelhouse directories or HTML package pages passed to uv.
244
+ uv_config_file: Optional uv configuration file passed to `uv tool`.
186
245
  execute_install: Whether to run `uv tool install` immediately.
187
246
 
188
247
  Raises:
@@ -191,8 +250,10 @@ def deploy_python(
191
250
  `PATH`.
192
251
  subprocess.CalledProcessError: If `uv tool install` fails.
193
252
  """
194
- resolved_module_root = require_path(module_root or config.module_root, "module root", "--module-root")
195
- 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
+ )
196
257
  paths = deploy_python_tool(
197
258
  name=name,
198
259
  version=version,
@@ -204,6 +265,7 @@ def deploy_python(
204
265
  python=python,
205
266
  indexes=indexes or config.indexes,
206
267
  find_links=find_links or config.find_links,
268
+ uv_config_file=(uv_config_file.expanduser() if uv_config_file else config.uv_config_file),
207
269
  execute_install=execute_install,
208
270
  make_default=make_default,
209
271
  )
@@ -212,7 +274,11 @@ def deploy_python(
212
274
  )
213
275
 
214
276
 
215
- @main.command("deploy-rust", help="Copy a Rust CLI binary and write a modulefile for it.")
277
+ @main.command(
278
+ "deploy-rust",
279
+ help="Copy a Rust CLI binary and write a modulefile for it.",
280
+ epilog=DEPLOY_RUST_EXAMPLES,
281
+ )
216
282
  @common_options
217
283
  @click.option(
218
284
  "--binary",
@@ -255,8 +321,10 @@ def deploy_rust(
255
321
  Raises:
256
322
  click.UsageError: If required paths are missing.
257
323
  """
258
- resolved_module_root = require_path(module_root or config.module_root, "module root", "--module-root")
259
- 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
+ )
260
328
  paths = deploy_rust_tool(
261
329
  name=name,
262
330
  version=version,
@@ -277,7 +345,11 @@ def deploy_rust(
277
345
  )
278
346
 
279
347
 
280
- @main.command("deploy-script", help="Copy a shell script and write a modulefile for it.")
348
+ @main.command(
349
+ "deploy-script",
350
+ help="Copy a shell script and write a modulefile for it.",
351
+ epilog=DEPLOY_SCRIPT_EXAMPLES,
352
+ )
281
353
  @common_options
282
354
  @click.option(
283
355
  "--script",
@@ -319,8 +391,10 @@ def deploy_script(
319
391
  Raises:
320
392
  click.UsageError: If required paths are missing.
321
393
  """
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")
394
+ resolved_module_root, resolved_prefix = require_locations(
395
+ module_root or config.module_root,
396
+ prefix or config.prefix,
397
+ )
324
398
  paths = deploy_script_tool(
325
399
  name=name,
326
400
  version=version,
@@ -341,7 +415,11 @@ def deploy_script(
341
415
  )
342
416
 
343
417
 
344
- @main.command("deploy-env", help="Deploy a collective environment from a TOML manifest.")
418
+ @main.command(
419
+ "deploy-env",
420
+ help="Deploy a collective environment from a TOML manifest.",
421
+ epilog=DEPLOY_ENV_EXAMPLES,
422
+ )
345
423
  @click.option(
346
424
  "--file",
347
425
  "manifest",
@@ -399,12 +477,10 @@ def deploy_env(
399
477
  except (OSError, TypeError, tomllib.TOMLDecodeError) as error:
400
478
  raise click.ClickException(str(error)) from error
401
479
 
402
- resolved_module_root = require_path(
480
+ resolved_module_root, resolved_prefix = require_locations(
403
481
  module_root or spec.module_root or config.module_root,
404
- "module root",
405
- "--module-root",
482
+ prefix or spec.prefix or config.prefix,
406
483
  )
407
- resolved_prefix = require_path(prefix or spec.prefix or config.prefix, "install prefix", "--prefix")
408
484
  if make_default is not None:
409
485
  spec = spec.__class__(
410
486
  name=spec.name,
@@ -421,7 +497,11 @@ def deploy_env(
421
497
  print_environment_result(result, dry_run)
422
498
 
423
499
 
424
- @main.command("uninstall", help="Remove a deployed tool version and its modulefile.")
500
+ @main.command(
501
+ "uninstall",
502
+ help="Remove a deployed tool version and its modulefile.",
503
+ epilog=UNINSTALL_EXAMPLES,
504
+ )
425
505
  @location_options
426
506
  @click.option(
427
507
  "--keep-default",
@@ -457,8 +537,10 @@ def uninstall(
457
537
  Raises:
458
538
  click.UsageError: If required paths are missing.
459
539
  """
460
- resolved_module_root = require_path(module_root or config.module_root, "module root", "--module-root")
461
- 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
+ )
462
544
  result = uninstall_tool(
463
545
  name=name,
464
546
  version=version,
@@ -493,6 +575,31 @@ def require_path(value: Path | None, label: str, option: str) -> Path:
493
575
  return value.expanduser()
494
576
 
495
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
+
496
603
  def print_result(
497
604
  modulefile: Path,
498
605
  install_root: Path,
@@ -22,12 +22,14 @@ class AppConfig:
22
22
  indexes: Default Python package indexes passed to `uv tool install`.
23
23
  find_links: Default Python package locations passed to
24
24
  `uv tool install`.
25
+ uv_config_file: Default uv configuration file passed to `uv tool`.
25
26
  """
26
27
 
27
28
  prefix: Path | None = None
28
29
  module_root: Path | None = None
29
30
  indexes: tuple[str, ...] = ()
30
31
  find_links: tuple[str, ...] = ()
32
+ uv_config_file: Path | None = None
31
33
 
32
34
 
33
35
  def default_config_path() -> Path:
@@ -81,6 +83,7 @@ def load_file_config(path: Path) -> AppConfig:
81
83
  module_root=optional_path(data.get("module_root"), "module_root"),
82
84
  indexes=string_tuple(python_data.get("indexes"), "python.indexes"),
83
85
  find_links=string_tuple(python_data.get("find_links"), "python.find_links"),
86
+ uv_config_file=optional_path(python_data.get("uv_config_file"), "python.uv_config_file"),
84
87
  )
85
88
 
86
89
 
@@ -98,6 +101,7 @@ def overlay_env_config(config: AppConfig) -> AppConfig:
98
101
  module_root=env_path("MODULE_MANAGER_MODULE_ROOT") or config.module_root,
99
102
  indexes=env_tuple("MODULE_MANAGER_INDEXES") or env_tuple("MODULE_MANAGER_INDEX") or config.indexes,
100
103
  find_links=env_tuple("MODULE_MANAGER_FIND_LINKS") or config.find_links,
104
+ uv_config_file=config.uv_config_file,
101
105
  )
102
106
 
103
107
 
@@ -62,6 +62,7 @@ class EnvironmentToolSpec:
62
62
  name: Tool name used for copied binaries and reporting.
63
63
  version: Optional source tool version for documentation.
64
64
  package: Python package spec passed to `uv tool install`.
65
+ uv_config_file: uv configuration file passed to `uv tool`.
65
66
  binary: Rust binary path to copy into the shared `bin` directory.
66
67
  script: Shell script path to copy into the shared `bin` directory.
67
68
  python: Optional Python interpreter or version passed to uv.
@@ -75,6 +76,7 @@ class EnvironmentToolSpec:
75
76
  name: str
76
77
  version: str | None = None
77
78
  package: str | None = None
79
+ uv_config_file: Path | None = None
78
80
  binary: Path | None = None
79
81
  script: Path | None = None
80
82
  python: str | None = None
@@ -148,6 +150,7 @@ def uv_install_command(
148
150
  python: str | None = None,
149
151
  indexes: tuple[str, ...] = (),
150
152
  find_links: tuple[str, ...] = (),
153
+ uv_config_file: Path | None = None,
151
154
  ) -> list[str]:
152
155
  """Build the uv command used to install a Python CLI tool.
153
156
 
@@ -156,11 +159,15 @@ def uv_install_command(
156
159
  python: Optional Python interpreter or version passed to uv.
157
160
  indexes: Additional package index URLs.
158
161
  find_links: Wheelhouse directories or HTML package pages.
162
+ uv_config_file: Optional uv configuration file passed to `uv tool`.
159
163
 
160
164
  Returns:
161
165
  Tokenized uv command suitable for `subprocess.run`.
162
166
  """
163
- command = ["uv", "tool", "install"]
167
+ command = ["uv", "tool"]
168
+ if uv_config_file:
169
+ command.extend(["--config-file", str(uv_config_file)])
170
+ command.append("install")
164
171
  if python:
165
172
  command.extend(["--python", python])
166
173
  for index in indexes:
@@ -171,6 +178,23 @@ def uv_install_command(
171
178
  return command
172
179
 
173
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
+
174
198
  def write_text(path: Path, content: str) -> None:
175
199
  """Write UTF-8 text to a path, creating parent directories first.
176
200
 
@@ -482,6 +506,7 @@ def parse_environment_tool(data: object, base_dir: Path, index: int) -> Environm
482
506
  name=name,
483
507
  version=optional_string(data, "version"),
484
508
  package=optional_string(data, "package"),
509
+ uv_config_file=optional_manifest_path(data, "uv_config_file", base_dir),
485
510
  binary=optional_manifest_path(data, "binary", base_dir),
486
511
  script=optional_manifest_path(data, "script", base_dir),
487
512
  python=optional_string(data, "python"),
@@ -540,7 +565,13 @@ def environment_actions(spec: EnvironmentSpec, paths: DeploymentPaths) -> tuple[
540
565
  tool_dir = paths.install_root / "uv-tools"
541
566
  for tool in spec.tools:
542
567
  if tool.tool_type == "python" and tool.package:
543
- command = uv_install_command(tool.package, tool.python, tool.indexes, tool.find_links)
568
+ command = uv_install_command(
569
+ tool.package,
570
+ tool.python,
571
+ tool.indexes,
572
+ tool.find_links,
573
+ tool.uv_config_file,
574
+ )
544
575
  actions.append(
545
576
  f"install python {tool.name}: "
546
577
  f"UV_TOOL_DIR={shlex.quote(str(tool_dir))} "
@@ -591,13 +622,16 @@ def deploy_environment(
591
622
  for tool in spec.tools:
592
623
  if tool.tool_type == "python" and tool.package:
593
624
  require_executable("uv")
594
- env = os.environ.copy()
595
- env["UV_TOOL_DIR"] = str(tool_dir)
596
- env["UV_TOOL_BIN_DIR"] = str(paths.bin_dir)
597
625
  subprocess.run(
598
- uv_install_command(tool.package, tool.python, tool.indexes, tool.find_links),
626
+ uv_install_command(
627
+ tool.package,
628
+ tool.python,
629
+ tool.indexes,
630
+ tool.find_links,
631
+ tool.uv_config_file,
632
+ ),
599
633
  check=True,
600
- env=env,
634
+ env=uv_install_environment(tool_dir, paths.bin_dir),
601
635
  )
602
636
  elif tool.tool_type == "rust" and tool.binary:
603
637
  copy_executable(tool.binary, paths.bin_dir / tool.name)
@@ -634,6 +668,7 @@ def deploy_python_tool(
634
668
  python: str | None = None,
635
669
  indexes: tuple[str, ...] = (),
636
670
  find_links: tuple[str, ...] = (),
671
+ uv_config_file: Path | None = None,
637
672
  execute_install: bool = False,
638
673
  make_default: bool = True,
639
674
  ) -> DeploymentPaths:
@@ -650,6 +685,7 @@ def deploy_python_tool(
650
685
  python: Optional Python interpreter or version passed to uv.
651
686
  indexes: Additional package index URLs passed to uv.
652
687
  find_links: Wheelhouse directories or HTML package pages passed to uv.
688
+ uv_config_file: Optional uv configuration file passed to `uv tool`.
653
689
  execute_install: Whether to run `uv tool install` immediately.
654
690
  make_default: Whether to make this version the module default.
655
691
 
@@ -664,14 +700,11 @@ def deploy_python_tool(
664
700
  paths = deployment_paths(module_root, prefix, name, version)
665
701
  paths.bin_dir.mkdir(parents=True, exist_ok=True)
666
702
  tool_dir = paths.install_root / "uv-tools"
667
- command = uv_install_command(package, python, indexes, find_links)
703
+ command = uv_install_command(package, python, indexes, find_links, uv_config_file)
668
704
 
669
705
  if execute_install:
670
706
  require_executable("uv")
671
- env = os.environ.copy()
672
- env["UV_TOOL_DIR"] = str(tool_dir)
673
- env["UV_TOOL_BIN_DIR"] = str(paths.bin_dir)
674
- subprocess.run(command, check=True, env=env)
707
+ subprocess.run(command, check=True, env=uv_install_environment(tool_dir, paths.bin_dir))
675
708
 
676
709
  install_hint = (
677
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.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",
@@ -56,12 +56,16 @@ def test_deploy_python_command_writes_modulefile() -> None:
56
56
  "https://packages.example/simple",
57
57
  "--find-links",
58
58
  "/prod/wheels",
59
+ "--uv-config-file",
60
+ "/prod/uv.toml",
59
61
  ],
60
62
  )
63
+ modulefile = Path("modules/ruff/0.8.0").read_text(encoding="utf-8")
61
64
 
62
65
  assert result.exit_code == 0
63
66
  assert "modulefile: modules/ruff/0.8.0" in result.output
64
67
  assert "default version: modules/ruff/.version" in result.output
68
+ assert "--config-file /prod/uv.toml" in modulefile
65
69
 
66
70
 
67
71
  def test_deploy_python_command_uses_config_defaults() -> None:
@@ -78,6 +82,7 @@ module_root = "modules"
78
82
  [python]
79
83
  indexes = ["https://packages.example/simple"]
80
84
  find_links = ["/prod/wheels"]
85
+ uv_config_file = "uv.toml"
81
86
  """.strip(),
82
87
  encoding="utf-8",
83
88
  )
@@ -93,9 +98,38 @@ find_links = ["/prod/wheels"]
93
98
  "ruff==0.8.0",
94
99
  ],
95
100
  )
101
+ modulefile = Path("modules/ruff/0.8.0").read_text(encoding="utf-8")
96
102
 
97
103
  assert result.exit_code == 0
98
104
  assert "modulefile: modules/ruff/0.8.0" in result.output
105
+ assert "--config-file uv.toml" in modulefile
106
+
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
99
133
 
100
134
 
101
135
  def test_cli_options_override_config_defaults() -> None:
@@ -22,6 +22,7 @@ module_root = "/scratch/modules"
22
22
  [python]
23
23
  indexes = ["https://packages.example/simple"]
24
24
  find_links = ["/scratch/wheels"]
25
+ uv_config_file = "/scratch/uv.toml"
25
26
  """.strip(),
26
27
  encoding="utf-8",
27
28
  )
@@ -31,6 +32,7 @@ find_links = ["/scratch/wheels"]
31
32
  module_root=Path("/scratch/modules"),
32
33
  indexes=("https://packages.example/simple",),
33
34
  find_links=("/scratch/wheels",),
35
+ uv_config_file=Path("/scratch/uv.toml"),
34
36
  )
35
37
 
36
38
 
@@ -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,
@@ -109,6 +110,40 @@ def test_python_tool_install_command_accepts_python() -> None:
109
110
  ]
110
111
 
111
112
 
113
+ def test_python_tool_install_command_accepts_uv_config_file() -> None:
114
+ """Uv install commands should include a requested uv config file."""
115
+ assert uv_install_command("ruff==0.8.0", uv_config_file=Path("/prod/uv.toml")) == [
116
+ "uv",
117
+ "tool",
118
+ "--config-file",
119
+ "/prod/uv.toml",
120
+ "install",
121
+ "ruff==0.8.0",
122
+ ]
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
+
112
147
  def test_deploy_python_tool_writes_package_sources_to_install_hint(tmp_path: Path) -> None:
113
148
  """Generated module help should show the package source options.
114
149
 
@@ -123,9 +158,11 @@ def test_deploy_python_tool_writes_package_sources_to_install_hint(tmp_path: Pat
123
158
  prefix=tmp_path / "tools",
124
159
  indexes=("https://packages.example/simple",),
125
160
  find_links=("/prod/wheels",),
161
+ uv_config_file=Path("/prod/uv.toml"),
126
162
  )
127
163
 
128
164
  modulefile = paths.modulefile.read_text(encoding="utf-8")
165
+ assert "--config-file /prod/uv.toml" in modulefile
129
166
  assert "--index https://packages.example/simple" in modulefile
130
167
  assert "--find-links /prod/wheels" in modulefile
131
168
 
@@ -366,6 +403,7 @@ package = "ruff==0.8.0"
366
403
  python = "3.12"
367
404
  indexes = ["https://packages.example/simple"]
368
405
  find_links = ["/prod/wheels"]
406
+ uv_config_file = "uv.toml"
369
407
 
370
408
  [[tools]]
371
409
  type = "script"
@@ -384,6 +422,7 @@ script = "scripts/helper"
384
422
  assert not spec.make_default
385
423
  assert spec.tools[0].package == "ruff==0.8.0"
386
424
  assert spec.tools[0].indexes == ("https://packages.example/simple",)
425
+ assert spec.tools[0].uv_config_file == tmp_path / "uv.toml"
387
426
  assert spec.tools[1].script == tmp_path / "scripts/helper"
388
427
 
389
428
 
@@ -491,6 +530,7 @@ type = "python"
491
530
  name = "ruff"
492
531
  package = "ruff==0.8.0"
493
532
  python = "3.12"
533
+ uv_config_file = "uv.toml"
494
534
  """.strip(),
495
535
  encoding="utf-8",
496
536
  )
@@ -502,7 +542,16 @@ python = "3.12"
502
542
  prefix=tmp_path / "tools",
503
543
  )
504
544
 
505
- assert calls[0][0] == ["uv", "tool", "install", "--python", "3.12", "ruff==0.8.0"]
545
+ assert calls[0][0] == [
546
+ "uv",
547
+ "tool",
548
+ "--config-file",
549
+ str(tmp_path / "uv.toml"),
550
+ "install",
551
+ "--python",
552
+ "3.12",
553
+ "ruff==0.8.0",
554
+ ]
506
555
  assert calls[0][1]["UV_TOOL_DIR"] == str(result.paths.install_root / "uv-tools")
507
556
  assert calls[0][1]["UV_TOOL_BIN_DIR"] == str(result.paths.bin_dir)
508
557
  assert result.paths.modulefile.exists()
@@ -344,7 +344,7 @@ wheels = [
344
344
 
345
345
  [[package]]
346
346
  name = "env-module-manager"
347
- version = "0.3.0"
347
+ version = "0.5.0"
348
348
  source = { editable = "." }
349
349
  dependencies = [
350
350
  { name = "click" },