env-module-manager 0.6.0__tar.gz → 0.7.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.6.0 → env_module_manager-0.7.0}/PKG-INFO +7 -1
  2. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/README.md +6 -0
  3. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/python-tools.md +16 -0
  4. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/reference.md +17 -0
  5. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/module_manager/__init__.py +1 -1
  6. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/module_manager/cli.py +86 -0
  7. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/module_manager/deploy.py +148 -0
  8. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/pyproject.toml +1 -1
  9. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/tests/test_cli.py +52 -0
  10. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/tests/test_integration.py +3 -3
  11. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/tests/test_modulefile.py +93 -0
  12. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/uv.lock +1 -1
  13. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/.github/workflows/publish.yml +0 -0
  14. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/.github/workflows/tag.yml +0 -0
  15. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/.github/workflows/test.yml +0 -0
  16. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/.gitignore +0 -0
  17. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/.python-version +0 -0
  18. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/.readthedocs.yaml +0 -0
  19. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/AGENTS.md +0 -0
  20. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/CONTRIBUTING.md +0 -0
  21. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/LICENSE.md +0 -0
  22. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/collective-environments.md +0 -0
  23. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/configuration.md +0 -0
  24. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/development.md +0 -0
  25. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/environment-modules.md +0 -0
  26. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/index.md +0 -0
  27. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/installation.md +0 -0
  28. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/rust-tools.md +0 -0
  29. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/shell-tools.md +0 -0
  30. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/stylesheets/extra.css +0 -0
  31. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/docs/uninstall.md +0 -0
  32. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/example.bash +0 -0
  33. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/mkdocs.yml +0 -0
  34. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/module_manager/config.py +0 -0
  35. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/module_manager/modulefile.py +0 -0
  36. {env_module_manager-0.6.0 → env_module_manager-0.7.0}/tests/test_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: env-module-manager
3
- Version: 0.6.0
3
+ Version: 0.7.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/
@@ -88,6 +88,12 @@ module-manager deploy-rust ripgrep 14.1.1 \
88
88
  --module-root /prod/modulefiles
89
89
  ```
90
90
 
91
+ Generate a constraints file for a Python CLI:
92
+
93
+ ```sh
94
+ module-manager auto-constraints ruff==0.8.0 --output constraints.txt
95
+ ```
96
+
91
97
  Deploy a shell script:
92
98
 
93
99
  ```sh
@@ -56,6 +56,12 @@ module-manager deploy-rust ripgrep 14.1.1 \
56
56
  --module-root /prod/modulefiles
57
57
  ```
58
58
 
59
+ Generate a constraints file for a Python CLI:
60
+
61
+ ```sh
62
+ module-manager auto-constraints ruff==0.8.0 --output constraints.txt
63
+ ```
64
+
59
65
  Deploy a shell script:
60
66
 
61
67
  ```sh
@@ -70,6 +70,22 @@ module-manager deploy-python gitconductor 0.1.0 \
70
70
 
71
71
  `--constraints` may be used more than once.
72
72
 
73
+ ## Generate Constraint Files
74
+
75
+ Use `auto-constraints` to compile a package and its dependencies into a
76
+ constraints file:
77
+
78
+ ```sh
79
+ module-manager auto-constraints gitconductor==0.7.0 \
80
+ --index https://packages.example/simple \
81
+ --output constraints.txt
82
+ ```
83
+
84
+ The command runs `uv pip compile` and writes `constraints.txt` by default. If uv
85
+ reports a transitive URL dependency, `module-manager` adds the suggested
86
+ `name @ URL` requirement and retries until all URL dependencies have been
87
+ included or uv reports an unrecoverable error.
88
+
73
89
  ## Use a uv Config File
74
90
 
75
91
  Pass a specific uv configuration file to `uv tool install`:
@@ -30,6 +30,12 @@ Show collective environment deployment options:
30
30
  module-manager deploy-env --help
31
31
  ```
32
32
 
33
+ Show constraint generation options:
34
+
35
+ ```sh
36
+ module-manager auto-constraints --help
37
+ ```
38
+
33
39
  Show uninstall options:
34
40
 
35
41
  ```sh
@@ -52,6 +58,17 @@ module-manager deploy-python NAME VERSION \
52
58
  [--execute-install]
53
59
  ```
54
60
 
61
+ ## Auto-Constraints Command
62
+
63
+ ```sh
64
+ module-manager auto-constraints PACKAGE \
65
+ [--output PATH] \
66
+ [--python PYTHON] \
67
+ [--index URL] \
68
+ [--find-links PATH_OR_URL] \
69
+ [--uv-config-file PATH]
70
+ ```
71
+
55
72
  ## Rust Command
56
73
 
57
74
  ```sh
@@ -1,3 +1,3 @@
1
1
  """Deployment helpers for HPC environment module files."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
@@ -12,12 +12,14 @@ from click import Command
12
12
  from . import __version__
13
13
  from .config import AppConfig, load_config
14
14
  from .deploy import (
15
+ ConstraintGenerationError,
15
16
  EnvironmentDeploymentResult,
16
17
  UninstallResult,
17
18
  deploy_environment,
18
19
  deploy_python_tool,
19
20
  deploy_rust_tool,
20
21
  deploy_script_tool,
22
+ generate_constraints,
21
23
  load_environment_spec,
22
24
  uninstall_tool,
23
25
  )
@@ -41,6 +43,7 @@ TOP_LEVEL_EXAMPLES = (
41
43
  "--prefix /prod/tools --module-root /prod/modulefiles[/cyan]\n\n"
42
44
  "[cyan]module-manager deploy-script my-tool 1.0.0 --script ./scripts/my-tool "
43
45
  "--prefix /prod/tools --module-root /prod/modulefiles[/cyan]\n\n"
46
+ "[cyan]module-manager auto-constraints ruff==0.8.0 --output constraints.txt[/cyan]\n\n"
44
47
  "[cyan]module-manager deploy-env --file dev-tools.toml[/cyan]\n\n"
45
48
  "[cyan]module-manager uninstall ruff 0.8.0 --prefix /prod/tools --module-root /prod/modulefiles[/cyan]"
46
49
  )
@@ -76,6 +79,13 @@ DEPLOY_ENV_EXAMPLES = (
76
79
  "--module-root /scratch/modulefiles --dry-run[/cyan]"
77
80
  )
78
81
 
82
+ AUTO_CONSTRAINTS_EXAMPLES = (
83
+ "Examples:\n\n"
84
+ "[cyan]module-manager auto-constraints gitconductor==0.7.0[/cyan]\n\n"
85
+ "[cyan]module-manager auto-constraints internal-tool==1.2.3 --index https://packages.example/simple "
86
+ "--output /prod/constraints/internal-tool.txt[/cyan]"
87
+ )
88
+
79
89
  UNINSTALL_EXAMPLES = (
80
90
  "Examples:\n\n"
81
91
  "[cyan]module-manager uninstall ruff 0.8.0 --prefix /prod/tools "
@@ -283,6 +293,82 @@ def deploy_python(
283
293
  )
284
294
 
285
295
 
296
+ @main.command(
297
+ "auto-constraints",
298
+ help="Generate constraints for a Python package with uv.",
299
+ epilog=AUTO_CONSTRAINTS_EXAMPLES,
300
+ )
301
+ @click.argument("package")
302
+ @click.option(
303
+ "--output",
304
+ "output_file",
305
+ type=PATH,
306
+ default=Path("constraints.txt"),
307
+ show_default=True,
308
+ help="Constraints file to write.",
309
+ )
310
+ @click.option("--python", "python", help="Python interpreter/version passed to uv.")
311
+ @click.option(
312
+ "--index",
313
+ "indexes",
314
+ multiple=True,
315
+ help="Additional package index URL passed to uv. May be used more than once.",
316
+ )
317
+ @click.option(
318
+ "--find-links",
319
+ "find_links",
320
+ multiple=True,
321
+ help="Directory or HTML page of packages passed to uv. May be used more than once.",
322
+ )
323
+ @click.option(
324
+ "--uv-config-file",
325
+ type=PATH,
326
+ help="uv.toml file passed to uv with --config-file.",
327
+ )
328
+ @click.pass_obj
329
+ def auto_constraints(
330
+ config: AppConfig,
331
+ package: str,
332
+ output_file: Path,
333
+ python: str | None,
334
+ indexes: tuple[str, ...],
335
+ find_links: tuple[str, ...],
336
+ uv_config_file: Path | None,
337
+ ) -> None:
338
+ """Generate a constraints file for a Python package.
339
+
340
+ Args:
341
+ config: Resolved application configuration from the Click context.
342
+ package: Package requirement to compile.
343
+ output_file: Constraints file to write.
344
+ python: Optional Python interpreter or version passed to uv.
345
+ indexes: Additional package index URLs passed to uv.
346
+ find_links: Wheelhouse directories or HTML package pages passed to uv.
347
+ uv_config_file: Optional uv configuration file passed to uv.
348
+
349
+ Raises:
350
+ click.ClickException: If uv cannot generate constraints.
351
+ MissingExecutableError: If uv is not on `PATH`.
352
+ """
353
+ try:
354
+ result = generate_constraints(
355
+ package=package,
356
+ output_file=output_file.expanduser(),
357
+ python=python,
358
+ indexes=indexes or config.indexes,
359
+ find_links=find_links or config.find_links,
360
+ uv_config_file=(uv_config_file.expanduser() if uv_config_file else config.uv_config_file),
361
+ )
362
+ except ConstraintGenerationError as error:
363
+ raise click.ClickException(str(error)) from error
364
+
365
+ click.echo(f"constraints: {result.output_file}")
366
+ if result.discovered_url_dependencies:
367
+ click.echo(f"discovered URL dependencies: {len(result.discovered_url_dependencies)}")
368
+ for requirement in result.discovered_url_dependencies:
369
+ click.echo(f" {requirement}")
370
+
371
+
286
372
  @main.command(
287
373
  "deploy-rust",
288
374
  help="Copy a Rust CLI binary and write a modulefile for it.",
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import re
6
7
  import shlex
7
8
  import shutil
8
9
  import stat
@@ -20,6 +21,10 @@ class MissingExecutableError(RuntimeError):
20
21
  """Raised when a required external executable is unavailable."""
21
22
 
22
23
 
24
+ class ConstraintGenerationError(RuntimeError):
25
+ """Raised when uv cannot generate constraints after URL discovery."""
26
+
27
+
23
28
  @dataclass(frozen=True)
24
29
  class DeploymentPaths:
25
30
  """Filesystem paths created or targeted by a deployment.
@@ -126,6 +131,26 @@ class EnvironmentDeploymentResult:
126
131
  actions: tuple[str, ...]
127
132
 
128
133
 
134
+ @dataclass(frozen=True)
135
+ class ConstraintGenerationResult:
136
+ """Result of generating a Python constraints file.
137
+
138
+ Attributes:
139
+ output_file: Constraints file written by uv.
140
+ requirements: Initial and discovered direct requirements compiled.
141
+ discovered_url_dependencies: URL requirements discovered from uv errors.
142
+ iterations: Number of uv compile attempts.
143
+ """
144
+
145
+ output_file: Path
146
+ requirements: tuple[str, ...]
147
+ discovered_url_dependencies: tuple[str, ...]
148
+ iterations: int
149
+
150
+
151
+ URL_DEPENDENCY_RE = re.compile(r"`([^`]+?\s@\s[^`]+?)`")
152
+
153
+
129
154
  def deployment_paths(module_root: Path, prefix: Path, name: str, version: str) -> DeploymentPaths:
130
155
  """Compute versioned installation and modulefile paths for a tool.
131
156
 
@@ -184,6 +209,129 @@ def uv_install_command(
184
209
  return command
185
210
 
186
211
 
212
+ def uv_compile_command(
213
+ output_file: Path,
214
+ python: str | None = None,
215
+ indexes: tuple[str, ...] = (),
216
+ find_links: tuple[str, ...] = (),
217
+ uv_config_file: Path | None = None,
218
+ ) -> list[str]:
219
+ """Build the uv command used to compile Python constraints.
220
+
221
+ Args:
222
+ output_file: Destination constraints file.
223
+ python: Optional Python interpreter or version passed to uv.
224
+ indexes: Additional package index URLs.
225
+ find_links: Wheelhouse directories or HTML package pages.
226
+ uv_config_file: Optional uv configuration file passed to `uv`.
227
+
228
+ Returns:
229
+ Tokenized uv command suitable for `subprocess.run`.
230
+ """
231
+ command = ["uv"]
232
+ if uv_config_file:
233
+ command.extend(["--config-file", str(uv_config_file)])
234
+ command.extend(
235
+ [
236
+ "pip",
237
+ "compile",
238
+ "-",
239
+ "--output-file",
240
+ str(output_file),
241
+ "--no-header",
242
+ "--no-annotate",
243
+ ]
244
+ )
245
+ if python:
246
+ command.extend(["--python", python])
247
+ for index in indexes:
248
+ command.extend(["--index", index])
249
+ for link in find_links:
250
+ command.extend(["--find-links", link])
251
+ return command
252
+
253
+
254
+ def discover_url_requirements(output: str) -> tuple[str, ...]:
255
+ """Extract uv-suggested direct URL requirements from compiler output.
256
+
257
+ Args:
258
+ output: Combined stdout and stderr from `uv pip compile`.
259
+
260
+ Returns:
261
+ Direct URL requirements in discovery order.
262
+ """
263
+ requirements: list[str] = []
264
+ for match in URL_DEPENDENCY_RE.findall(output):
265
+ if match not in requirements:
266
+ requirements.append(match)
267
+ return tuple(requirements)
268
+
269
+
270
+ def generate_constraints(
271
+ *,
272
+ package: str,
273
+ output_file: Path,
274
+ python: str | None = None,
275
+ indexes: tuple[str, ...] = (),
276
+ find_links: tuple[str, ...] = (),
277
+ uv_config_file: Path | None = None,
278
+ max_iterations: int = 20,
279
+ ) -> ConstraintGenerationResult:
280
+ """Generate a constraints file, discovering transitive URL dependencies.
281
+
282
+ Args:
283
+ package: Initial package requirement to compile.
284
+ output_file: Destination constraints file.
285
+ python: Optional Python interpreter or version passed to uv.
286
+ indexes: Additional package index URLs passed to uv.
287
+ find_links: Wheelhouse directories or HTML package pages passed to uv.
288
+ uv_config_file: Optional uv configuration file passed to `uv`.
289
+ max_iterations: Maximum uv compile attempts before failing.
290
+
291
+ Returns:
292
+ Constraints generation result.
293
+
294
+ Raises:
295
+ MissingExecutableError: If uv is not on `PATH`.
296
+ ConstraintGenerationError: If uv fails without a new URL dependency to
297
+ add, or if the iteration limit is reached.
298
+ """
299
+ require_executable("uv")
300
+ output_file.parent.mkdir(parents=True, exist_ok=True)
301
+ requirements = [package]
302
+ discovered: list[str] = []
303
+ command = uv_compile_command(output_file, python, indexes, find_links, uv_config_file)
304
+
305
+ for iteration in range(1, max_iterations + 1):
306
+ completed = subprocess.run(
307
+ command,
308
+ check=False,
309
+ input="\n".join(requirements) + "\n",
310
+ capture_output=True,
311
+ text=True,
312
+ )
313
+ if completed.returncode == 0:
314
+ return ConstraintGenerationResult(
315
+ output_file=output_file,
316
+ requirements=tuple(requirements),
317
+ discovered_url_dependencies=tuple(discovered),
318
+ iterations=iteration,
319
+ )
320
+
321
+ combined_output = f"{completed.stdout}\n{completed.stderr}"
322
+ new_requirements = [
323
+ requirement for requirement in discover_url_requirements(combined_output) if requirement not in requirements
324
+ ]
325
+ if not new_requirements:
326
+ msg = combined_output.strip() or f"uv pip compile failed with exit code {completed.returncode}"
327
+ raise ConstraintGenerationError(msg)
328
+ requirements.extend(new_requirements)
329
+ discovered.extend(new_requirements)
330
+
331
+ msg = f"uv pip compile did not converge after {max_iterations} attempts"
332
+ raise ConstraintGenerationError(msg)
333
+
334
+
187
335
  def uv_install_environment(tool_dir: Path, bin_dir: Path) -> dict[str, str]:
188
336
  """Build an isolated environment for a managed uv tool installation.
189
337
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "env-module-manager"
3
- version = "0.6.0"
3
+ version = "0.7.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"
@@ -3,9 +3,11 @@
3
3
  import re
4
4
  from pathlib import Path
5
5
 
6
+ import pytest
6
7
  from click.testing import CliRunner
7
8
 
8
9
  from module_manager.cli import main
10
+ from module_manager.deploy import ConstraintGenerationResult
9
11
 
10
12
  ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
11
13
 
@@ -135,6 +137,56 @@ def test_deploy_command_rejects_matching_prefix_and_module_root() -> None:
135
137
  assert not collision_path_exists
136
138
 
137
139
 
140
+ def test_auto_constraints_command_writes_output(monkeypatch: pytest.MonkeyPatch) -> None:
141
+ """The auto-constraints command should report the generated file.
142
+
143
+ Args:
144
+ monkeypatch: Pytest helper used to replace constraint generation.
145
+ """
146
+ runner = CliRunner()
147
+ calls: list[dict[str, object]] = []
148
+
149
+ def fake_generate_constraints(**kwargs: object) -> ConstraintGenerationResult:
150
+ """Record generation options."""
151
+ calls.append(kwargs)
152
+ return ConstraintGenerationResult(
153
+ output_file=Path("constraints.txt"),
154
+ requirements=("url-tool", "url-dep @ file:///tmp/url-dep"),
155
+ discovered_url_dependencies=("url-dep @ file:///tmp/url-dep",),
156
+ iterations=2,
157
+ )
158
+
159
+ monkeypatch.setattr("module_manager.cli.generate_constraints", fake_generate_constraints)
160
+
161
+ with runner.isolated_filesystem():
162
+ result = runner.invoke(
163
+ main,
164
+ [
165
+ "auto-constraints",
166
+ "url-tool",
167
+ "--index",
168
+ "https://packages.example/simple",
169
+ "--output",
170
+ "constraints.txt",
171
+ ],
172
+ )
173
+
174
+ assert result.exit_code == 0
175
+ assert calls == [
176
+ {
177
+ "package": "url-tool",
178
+ "output_file": Path("constraints.txt"),
179
+ "python": None,
180
+ "indexes": ("https://packages.example/simple",),
181
+ "find_links": (),
182
+ "uv_config_file": None,
183
+ }
184
+ ]
185
+ assert "constraints: constraints.txt" in result.output
186
+ assert "discovered URL dependencies: 1" in result.output
187
+ assert "url-dep @ file:///tmp/url-dep" in result.output
188
+
189
+
138
190
  def test_cli_options_override_config_defaults() -> None:
139
191
  """CLI options should take precedence over configured defaults."""
140
192
  runner = CliRunner()
@@ -30,7 +30,7 @@ def test_deploy_python_real_uv_install_uses_cli_roots(tmp_path: Path, monkeypatc
30
30
  [
31
31
  "deploy-python",
32
32
  "module-manager",
33
- "0.6.0",
33
+ "0.7.0",
34
34
  "--package",
35
35
  str(project_root),
36
36
  "--prefix",
@@ -41,11 +41,11 @@ def test_deploy_python_real_uv_install_uses_cli_roots(tmp_path: Path, monkeypatc
41
41
  ],
42
42
  )
43
43
 
44
- install_root = prefix / "module-manager" / "0.6.0"
44
+ install_root = prefix / "module-manager" / "0.7.0"
45
45
  assert result.exit_code == 0, result.output
46
46
  assert (install_root / "bin" / "module-manager").exists()
47
47
  assert (install_root / "uv-tools" / "env-module-manager" / "uv-receipt.toml").exists()
48
- assert (module_root / "module-manager" / "0.6.0").is_file()
48
+ assert (module_root / "module-manager" / "0.7.0").is_file()
49
49
  assert not inherited_tool_dir.exists()
50
50
  assert not inherited_bin_dir.exists()
51
51
 
@@ -1,5 +1,6 @@
1
1
  """Modulefile rendering and deployment helper tests."""
2
2
 
3
+ import subprocess
3
4
  from pathlib import Path
4
5
  from shlex import quote as shlex_quote
5
6
 
@@ -11,9 +12,12 @@ from module_manager.deploy import (
11
12
  deploy_python_tool,
12
13
  deploy_rust_tool,
13
14
  deploy_script_tool,
15
+ discover_url_requirements,
16
+ generate_constraints,
14
17
  load_environment_spec,
15
18
  require_executable,
16
19
  uninstall_tool,
20
+ uv_compile_command,
17
21
  uv_install_command,
18
22
  uv_install_environment,
19
23
  )
@@ -126,6 +130,95 @@ def test_python_tool_install_command_accepts_uv_config_file() -> None:
126
130
  ]
127
131
 
128
132
 
133
+ def test_uv_compile_command_accepts_sources_and_config() -> None:
134
+ """Uv compile commands should include resolver and config options."""
135
+ assert uv_compile_command(
136
+ Path("/prod/constraints.txt"),
137
+ python="3.12",
138
+ indexes=("https://packages.example/simple",),
139
+ find_links=("/prod/wheels",),
140
+ uv_config_file=Path("/prod/uv.toml"),
141
+ ) == [
142
+ "uv",
143
+ "--config-file",
144
+ "/prod/uv.toml",
145
+ "pip",
146
+ "compile",
147
+ "-",
148
+ "--output-file",
149
+ "/prod/constraints.txt",
150
+ "--no-header",
151
+ "--no-annotate",
152
+ "--python",
153
+ "3.12",
154
+ "--index",
155
+ "https://packages.example/simple",
156
+ "--find-links",
157
+ "/prod/wheels",
158
+ ]
159
+
160
+
161
+ def test_discover_url_requirements_extracts_uv_suggestions() -> None:
162
+ """Uv URL dependency suggestions should be parsed from errors."""
163
+ output = """
164
+ Package `url-dep` was included as a URL dependency. URL dependencies
165
+ must be expressed as direct requirements or constraints. Consider adding
166
+ `url-dep @ file:///tmp/url-dep` to your dependencies or constraints file.
167
+ """.strip()
168
+
169
+ assert discover_url_requirements(output) == ("url-dep @ file:///tmp/url-dep",)
170
+
171
+
172
+ def test_generate_constraints_retries_discovered_url_dependencies(
173
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
174
+ ) -> None:
175
+ """Constraint generation should retry with uv-suggested URL requirements.
176
+
177
+ Args:
178
+ tmp_path: Temporary deployment root.
179
+ monkeypatch: Pytest helper used to replace process execution.
180
+ """
181
+ calls: list[tuple[list[str], str]] = []
182
+
183
+ def fake_run(
184
+ command: list[str],
185
+ check: bool,
186
+ input: str,
187
+ capture_output: bool,
188
+ text: bool,
189
+ ) -> subprocess.CompletedProcess[str]:
190
+ """Fail once with a URL suggestion, then write constraints."""
191
+ assert not check
192
+ assert capture_output
193
+ assert text
194
+ calls.append((command, input))
195
+ if len(calls) == 1:
196
+ return subprocess.CompletedProcess(
197
+ command,
198
+ 1,
199
+ stdout="",
200
+ stderr=("Consider adding `url-dep @ file:///tmp/url-dep` to your dependencies or constraints file."),
201
+ )
202
+ output_file = tmp_path / "constraints.txt"
203
+ output_file.write_text("url-dep @ file:///tmp/url-dep\nurl-tool==1.0.0\n", encoding="utf-8")
204
+ return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
205
+
206
+ monkeypatch.setattr("module_manager.deploy.require_executable", lambda _name: "uv")
207
+ monkeypatch.setattr("module_manager.deploy.subprocess.run", fake_run)
208
+
209
+ result = generate_constraints(package="url-tool", output_file=tmp_path / "constraints.txt")
210
+
211
+ assert result.output_file == tmp_path / "constraints.txt"
212
+ assert result.requirements == ("url-tool", "url-dep @ file:///tmp/url-dep")
213
+ assert result.discovered_url_dependencies == ("url-dep @ file:///tmp/url-dep",)
214
+ assert result.iterations == 2
215
+ assert calls[0][1] == "url-tool\n"
216
+ assert calls[1][1] == "url-tool\nurl-dep @ file:///tmp/url-dep\n"
217
+ assert (tmp_path / "constraints.txt").read_text(encoding="utf-8") == (
218
+ "url-dep @ file:///tmp/url-dep\nurl-tool==1.0.0\n"
219
+ )
220
+
221
+
129
222
  def test_python_tool_install_command_accepts_url_dependency() -> None:
130
223
  """Uv install commands should pass direct URL dependencies unchanged."""
131
224
  package = "mytool @ https://packages.example/files/mytool-1.0.0-py3-none-any.whl"
@@ -344,7 +344,7 @@ wheels = [
344
344
 
345
345
  [[package]]
346
346
  name = "env-module-manager"
347
- version = "0.6.0"
347
+ version = "0.7.0"
348
348
  source = { editable = "." }
349
349
  dependencies = [
350
350
  { name = "click" },