env-module-manager 0.5.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.
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/PKG-INFO +8 -1
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/README.md +7 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/collective-environments.md +2 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/python-tools.md +31 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/reference.md +18 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/module_manager/__init__.py +1 -1
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/module_manager/cli.py +95 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/module_manager/deploy.py +183 -1
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/pyproject.toml +1 -1
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/tests/test_cli.py +57 -0
- env_module_manager-0.7.0/tests/test_integration.py +138 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/tests/test_modulefile.py +139 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/uv.lock +1 -1
- env_module_manager-0.5.0/tests/test_integration.py +0 -49
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.github/workflows/publish.yml +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.github/workflows/tag.yml +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.github/workflows/test.yml +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.gitignore +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.python-version +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/.readthedocs.yaml +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/AGENTS.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/CONTRIBUTING.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/LICENSE.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/configuration.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/development.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/environment-modules.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/index.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/installation.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/rust-tools.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/shell-tools.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/stylesheets/extra.css +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/docs/uninstall.md +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/example.bash +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/mkdocs.yml +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/module_manager/config.py +0 -0
- {env_module_manager-0.5.0 → env_module_manager-0.7.0}/module_manager/modulefile.py +0 -0
- {env_module_manager-0.5.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.
|
|
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/
|
|
@@ -73,6 +73,7 @@ Deploy a Python CLI with `uv tool install`:
|
|
|
73
73
|
```sh
|
|
74
74
|
module-manager deploy-python ruff 0.8.0 \
|
|
75
75
|
--package 'ruff==0.8.0' \
|
|
76
|
+
--constraints /prod/constraints/runtime.txt \
|
|
76
77
|
--prefix /prod/tools \
|
|
77
78
|
--module-root /prod/modulefiles \
|
|
78
79
|
--execute-install
|
|
@@ -87,6 +88,12 @@ module-manager deploy-rust ripgrep 14.1.1 \
|
|
|
87
88
|
--module-root /prod/modulefiles
|
|
88
89
|
```
|
|
89
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
|
+
|
|
90
97
|
Deploy a shell script:
|
|
91
98
|
|
|
92
99
|
```sh
|
|
@@ -41,6 +41,7 @@ Deploy a Python CLI with `uv tool install`:
|
|
|
41
41
|
```sh
|
|
42
42
|
module-manager deploy-python ruff 0.8.0 \
|
|
43
43
|
--package 'ruff==0.8.0' \
|
|
44
|
+
--constraints /prod/constraints/runtime.txt \
|
|
44
45
|
--prefix /prod/tools \
|
|
45
46
|
--module-root /prod/modulefiles \
|
|
46
47
|
--execute-install
|
|
@@ -55,6 +56,12 @@ module-manager deploy-rust ripgrep 14.1.1 \
|
|
|
55
56
|
--module-root /prod/modulefiles
|
|
56
57
|
```
|
|
57
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
|
+
|
|
58
65
|
Deploy a shell script:
|
|
59
66
|
|
|
60
67
|
```sh
|
|
@@ -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
|
|
|
@@ -55,6 +55,37 @@ module-manager deploy-python gitconductor 0.1.0 \
|
|
|
55
55
|
|
|
56
56
|
`--find-links` may be used more than once.
|
|
57
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
|
+
|
|
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
|
+
|
|
58
89
|
## Use a uv Config File
|
|
59
90
|
|
|
60
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
|
|
@@ -44,6 +50,7 @@ module-manager deploy-python NAME VERSION \
|
|
|
44
50
|
[--python PYTHON] \
|
|
45
51
|
[--index URL] \
|
|
46
52
|
[--find-links PATH_OR_URL] \
|
|
53
|
+
[--constraints PATH] \
|
|
47
54
|
[--uv-config-file PATH] \
|
|
48
55
|
[--prefix PATH] \
|
|
49
56
|
[--module-root PATH] \
|
|
@@ -51,6 +58,17 @@ module-manager deploy-python NAME VERSION \
|
|
|
51
58
|
[--execute-install]
|
|
52
59
|
```
|
|
53
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
|
+
|
|
54
72
|
## Rust Command
|
|
55
73
|
|
|
56
74
|
```sh
|
|
@@ -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 "
|
|
@@ -199,6 +209,12 @@ def main(ctx: click.Context, config: Path | None) -> None:
|
|
|
199
209
|
multiple=True,
|
|
200
210
|
help="Directory or HTML page of packages passed to uv. May be used more than once.",
|
|
201
211
|
)
|
|
212
|
+
@click.option(
|
|
213
|
+
"--constraints",
|
|
214
|
+
"constraints",
|
|
215
|
+
multiple=True,
|
|
216
|
+
help="Requirements constraint file passed to uv. May be used more than once.",
|
|
217
|
+
)
|
|
202
218
|
@click.option(
|
|
203
219
|
"--uv-config-file",
|
|
204
220
|
type=PATH,
|
|
@@ -223,6 +239,7 @@ def deploy_python(
|
|
|
223
239
|
python: str | None,
|
|
224
240
|
indexes: tuple[str, ...],
|
|
225
241
|
find_links: tuple[str, ...],
|
|
242
|
+
constraints: tuple[str, ...],
|
|
226
243
|
uv_config_file: Path | None,
|
|
227
244
|
execute_install: bool,
|
|
228
245
|
) -> None:
|
|
@@ -241,6 +258,7 @@ def deploy_python(
|
|
|
241
258
|
python: Optional Python interpreter or version passed to uv.
|
|
242
259
|
indexes: Additional package index URLs passed to uv.
|
|
243
260
|
find_links: Wheelhouse directories or HTML package pages passed to uv.
|
|
261
|
+
constraints: Requirements constraint files passed to uv.
|
|
244
262
|
uv_config_file: Optional uv configuration file passed to `uv tool`.
|
|
245
263
|
execute_install: Whether to run `uv tool install` immediately.
|
|
246
264
|
|
|
@@ -265,6 +283,7 @@ def deploy_python(
|
|
|
265
283
|
python=python,
|
|
266
284
|
indexes=indexes or config.indexes,
|
|
267
285
|
find_links=find_links or config.find_links,
|
|
286
|
+
constraints=constraints,
|
|
268
287
|
uv_config_file=(uv_config_file.expanduser() if uv_config_file else config.uv_config_file),
|
|
269
288
|
execute_install=execute_install,
|
|
270
289
|
make_default=make_default,
|
|
@@ -274,6 +293,82 @@ def deploy_python(
|
|
|
274
293
|
)
|
|
275
294
|
|
|
276
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
|
+
|
|
277
372
|
@main.command(
|
|
278
373
|
"deploy-rust",
|
|
279
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.
|
|
@@ -68,6 +73,7 @@ class EnvironmentToolSpec:
|
|
|
68
73
|
python: Optional Python interpreter or version passed to uv.
|
|
69
74
|
indexes: Additional package index URLs passed to uv.
|
|
70
75
|
find_links: Wheelhouse directories or HTML package pages passed to uv.
|
|
76
|
+
constraints: Constraint requirement files passed to uv.
|
|
71
77
|
description: Optional tool description.
|
|
72
78
|
homepage: Optional upstream homepage.
|
|
73
79
|
"""
|
|
@@ -82,6 +88,7 @@ class EnvironmentToolSpec:
|
|
|
82
88
|
python: str | None = None
|
|
83
89
|
indexes: tuple[str, ...] = ()
|
|
84
90
|
find_links: tuple[str, ...] = ()
|
|
91
|
+
constraints: tuple[str, ...] = ()
|
|
85
92
|
description: str | None = None
|
|
86
93
|
homepage: str | None = None
|
|
87
94
|
|
|
@@ -124,6 +131,26 @@ class EnvironmentDeploymentResult:
|
|
|
124
131
|
actions: tuple[str, ...]
|
|
125
132
|
|
|
126
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
|
+
|
|
127
154
|
def deployment_paths(module_root: Path, prefix: Path, name: str, version: str) -> DeploymentPaths:
|
|
128
155
|
"""Compute versioned installation and modulefile paths for a tool.
|
|
129
156
|
|
|
@@ -150,6 +177,7 @@ def uv_install_command(
|
|
|
150
177
|
python: str | None = None,
|
|
151
178
|
indexes: tuple[str, ...] = (),
|
|
152
179
|
find_links: tuple[str, ...] = (),
|
|
180
|
+
constraints: tuple[str, ...] = (),
|
|
153
181
|
uv_config_file: Path | None = None,
|
|
154
182
|
) -> list[str]:
|
|
155
183
|
"""Build the uv command used to install a Python CLI tool.
|
|
@@ -159,6 +187,7 @@ def uv_install_command(
|
|
|
159
187
|
python: Optional Python interpreter or version passed to uv.
|
|
160
188
|
indexes: Additional package index URLs.
|
|
161
189
|
find_links: Wheelhouse directories or HTML package pages.
|
|
190
|
+
constraints: Constraint requirement files.
|
|
162
191
|
uv_config_file: Optional uv configuration file passed to `uv tool`.
|
|
163
192
|
|
|
164
193
|
Returns:
|
|
@@ -174,10 +203,135 @@ def uv_install_command(
|
|
|
174
203
|
command.extend(["--index", index])
|
|
175
204
|
for link in find_links:
|
|
176
205
|
command.extend(["--find-links", link])
|
|
206
|
+
for constraint in constraints:
|
|
207
|
+
command.extend(["--constraints", constraint])
|
|
177
208
|
command.append(package)
|
|
178
209
|
return command
|
|
179
210
|
|
|
180
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
|
+
|
|
181
335
|
def uv_install_environment(tool_dir: Path, bin_dir: Path) -> dict[str, str]:
|
|
182
336
|
"""Build an isolated environment for a managed uv tool installation.
|
|
183
337
|
|
|
@@ -400,6 +554,29 @@ def optional_manifest_path(data: dict[str, Any], key: str, base_dir: Path | None
|
|
|
400
554
|
return path
|
|
401
555
|
|
|
402
556
|
|
|
557
|
+
def optional_manifest_paths(data: dict[str, Any], key: str, base_dir: Path | None = None) -> tuple[Path, ...]:
|
|
558
|
+
"""Read optional paths from manifest data.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
data: Manifest table.
|
|
562
|
+
key: Optional path-list key.
|
|
563
|
+
base_dir: Directory used to resolve relative paths.
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
Expanded path values, or an empty tuple.
|
|
567
|
+
|
|
568
|
+
Raises:
|
|
569
|
+
TypeError: If the value is not a list of strings.
|
|
570
|
+
"""
|
|
571
|
+
paths = []
|
|
572
|
+
for value in optional_string_tuple(data, key):
|
|
573
|
+
path = Path(value).expanduser()
|
|
574
|
+
if base_dir and not path.is_absolute():
|
|
575
|
+
path = base_dir / path
|
|
576
|
+
paths.append(path)
|
|
577
|
+
return tuple(paths)
|
|
578
|
+
|
|
579
|
+
|
|
403
580
|
def optional_string_tuple(data: dict[str, Any], key: str) -> tuple[str, ...]:
|
|
404
581
|
"""Read an optional list of strings from manifest data.
|
|
405
582
|
|
|
@@ -512,6 +689,7 @@ def parse_environment_tool(data: object, base_dir: Path, index: int) -> Environm
|
|
|
512
689
|
python=optional_string(data, "python"),
|
|
513
690
|
indexes=optional_string_tuple(data, "indexes"),
|
|
514
691
|
find_links=optional_string_tuple(data, "find_links"),
|
|
692
|
+
constraints=tuple(str(path) for path in optional_manifest_paths(data, "constraints", base_dir)),
|
|
515
693
|
description=optional_string(data, "description"),
|
|
516
694
|
homepage=optional_string(data, "homepage"),
|
|
517
695
|
)
|
|
@@ -570,6 +748,7 @@ def environment_actions(spec: EnvironmentSpec, paths: DeploymentPaths) -> tuple[
|
|
|
570
748
|
tool.python,
|
|
571
749
|
tool.indexes,
|
|
572
750
|
tool.find_links,
|
|
751
|
+
tool.constraints,
|
|
573
752
|
tool.uv_config_file,
|
|
574
753
|
)
|
|
575
754
|
actions.append(
|
|
@@ -628,6 +807,7 @@ def deploy_environment(
|
|
|
628
807
|
tool.python,
|
|
629
808
|
tool.indexes,
|
|
630
809
|
tool.find_links,
|
|
810
|
+
tool.constraints,
|
|
631
811
|
tool.uv_config_file,
|
|
632
812
|
),
|
|
633
813
|
check=True,
|
|
@@ -668,6 +848,7 @@ def deploy_python_tool(
|
|
|
668
848
|
python: str | None = None,
|
|
669
849
|
indexes: tuple[str, ...] = (),
|
|
670
850
|
find_links: tuple[str, ...] = (),
|
|
851
|
+
constraints: tuple[str, ...] = (),
|
|
671
852
|
uv_config_file: Path | None = None,
|
|
672
853
|
execute_install: bool = False,
|
|
673
854
|
make_default: bool = True,
|
|
@@ -685,6 +866,7 @@ def deploy_python_tool(
|
|
|
685
866
|
python: Optional Python interpreter or version passed to uv.
|
|
686
867
|
indexes: Additional package index URLs passed to uv.
|
|
687
868
|
find_links: Wheelhouse directories or HTML package pages passed to uv.
|
|
869
|
+
constraints: Constraint requirement files passed to uv.
|
|
688
870
|
uv_config_file: Optional uv configuration file passed to `uv tool`.
|
|
689
871
|
execute_install: Whether to run `uv tool install` immediately.
|
|
690
872
|
make_default: Whether to make this version the module default.
|
|
@@ -700,7 +882,7 @@ def deploy_python_tool(
|
|
|
700
882
|
paths = deployment_paths(module_root, prefix, name, version)
|
|
701
883
|
paths.bin_dir.mkdir(parents=True, exist_ok=True)
|
|
702
884
|
tool_dir = paths.install_root / "uv-tools"
|
|
703
|
-
command = uv_install_command(package, python, indexes, find_links, uv_config_file)
|
|
885
|
+
command = uv_install_command(package, python, indexes, find_links, constraints, uv_config_file)
|
|
704
886
|
|
|
705
887
|
if execute_install:
|
|
706
888
|
require_executable("uv")
|
|
@@ -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
|
|
|
@@ -56,6 +58,8 @@ def test_deploy_python_command_writes_modulefile() -> None:
|
|
|
56
58
|
"https://packages.example/simple",
|
|
57
59
|
"--find-links",
|
|
58
60
|
"/prod/wheels",
|
|
61
|
+
"--constraints",
|
|
62
|
+
"/prod/constraints.txt",
|
|
59
63
|
"--uv-config-file",
|
|
60
64
|
"/prod/uv.toml",
|
|
61
65
|
],
|
|
@@ -66,6 +70,7 @@ def test_deploy_python_command_writes_modulefile() -> None:
|
|
|
66
70
|
assert "modulefile: modules/ruff/0.8.0" in result.output
|
|
67
71
|
assert "default version: modules/ruff/.version" in result.output
|
|
68
72
|
assert "--config-file /prod/uv.toml" in modulefile
|
|
73
|
+
assert "--constraints /prod/constraints.txt" in modulefile
|
|
69
74
|
|
|
70
75
|
|
|
71
76
|
def test_deploy_python_command_uses_config_defaults() -> None:
|
|
@@ -132,6 +137,56 @@ def test_deploy_command_rejects_matching_prefix_and_module_root() -> None:
|
|
|
132
137
|
assert not collision_path_exists
|
|
133
138
|
|
|
134
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
|
+
|
|
135
190
|
def test_cli_options_override_config_defaults() -> None:
|
|
136
191
|
"""CLI options should take precedence over configured defaults."""
|
|
137
192
|
runner = CliRunner()
|
|
@@ -408,6 +463,7 @@ name = "ruff"
|
|
|
408
463
|
version = "0.8.0"
|
|
409
464
|
package = "ruff==0.8.0"
|
|
410
465
|
indexes = ["https://packages.example/simple"]
|
|
466
|
+
constraints = ["constraints.txt"]
|
|
411
467
|
""".strip(),
|
|
412
468
|
encoding="utf-8",
|
|
413
469
|
)
|
|
@@ -418,6 +474,7 @@ indexes = ["https://packages.example/simple"]
|
|
|
418
474
|
assert "would create install root: tools/dev-tools/2026.05" in result.output
|
|
419
475
|
assert "would install python ruff:" in result.output
|
|
420
476
|
assert "--index https://packages.example/simple" in result.output
|
|
477
|
+
assert "--constraints constraints.txt" in result.output
|
|
421
478
|
assert not install_root_exists
|
|
422
479
|
|
|
423
480
|
|
|
@@ -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.7.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.7.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.7.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,8 @@
|
|
|
1
1
|
"""Modulefile rendering and deployment helper tests."""
|
|
2
2
|
|
|
3
|
+
import subprocess
|
|
3
4
|
from pathlib import Path
|
|
5
|
+
from shlex import quote as shlex_quote
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
6
8
|
|
|
@@ -10,9 +12,12 @@ from module_manager.deploy import (
|
|
|
10
12
|
deploy_python_tool,
|
|
11
13
|
deploy_rust_tool,
|
|
12
14
|
deploy_script_tool,
|
|
15
|
+
discover_url_requirements,
|
|
16
|
+
generate_constraints,
|
|
13
17
|
load_environment_spec,
|
|
14
18
|
require_executable,
|
|
15
19
|
uninstall_tool,
|
|
20
|
+
uv_compile_command,
|
|
16
21
|
uv_install_command,
|
|
17
22
|
uv_install_environment,
|
|
18
23
|
)
|
|
@@ -84,6 +89,7 @@ def test_python_tool_install_command_accepts_indexes() -> None:
|
|
|
84
89
|
"internal-tool==1.2.3",
|
|
85
90
|
indexes=("https://packages.example/simple", "https://mirror.example/simple"),
|
|
86
91
|
find_links=("/prod/wheels",),
|
|
92
|
+
constraints=("/prod/constraints.txt",),
|
|
87
93
|
) == [
|
|
88
94
|
"uv",
|
|
89
95
|
"tool",
|
|
@@ -94,6 +100,8 @@ def test_python_tool_install_command_accepts_indexes() -> None:
|
|
|
94
100
|
"https://mirror.example/simple",
|
|
95
101
|
"--find-links",
|
|
96
102
|
"/prod/wheels",
|
|
103
|
+
"--constraints",
|
|
104
|
+
"/prod/constraints.txt",
|
|
97
105
|
"internal-tool==1.2.3",
|
|
98
106
|
]
|
|
99
107
|
|
|
@@ -122,6 +130,109 @@ def test_python_tool_install_command_accepts_uv_config_file() -> None:
|
|
|
122
130
|
]
|
|
123
131
|
|
|
124
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
|
+
|
|
222
|
+
def test_python_tool_install_command_accepts_url_dependency() -> None:
|
|
223
|
+
"""Uv install commands should pass direct URL dependencies unchanged."""
|
|
224
|
+
package = "mytool @ https://packages.example/files/mytool-1.0.0-py3-none-any.whl"
|
|
225
|
+
|
|
226
|
+
assert uv_install_command(package, constraints=("/prod/constraints.txt",)) == [
|
|
227
|
+
"uv",
|
|
228
|
+
"tool",
|
|
229
|
+
"install",
|
|
230
|
+
"--constraints",
|
|
231
|
+
"/prod/constraints.txt",
|
|
232
|
+
package,
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
125
236
|
def test_uv_install_environment_ignores_inherited_uv_tool_settings(
|
|
126
237
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
127
238
|
) -> None:
|
|
@@ -158,6 +269,7 @@ def test_deploy_python_tool_writes_package_sources_to_install_hint(tmp_path: Pat
|
|
|
158
269
|
prefix=tmp_path / "tools",
|
|
159
270
|
indexes=("https://packages.example/simple",),
|
|
160
271
|
find_links=("/prod/wheels",),
|
|
272
|
+
constraints=("/prod/constraints.txt",),
|
|
161
273
|
uv_config_file=Path("/prod/uv.toml"),
|
|
162
274
|
)
|
|
163
275
|
|
|
@@ -165,6 +277,28 @@ def test_deploy_python_tool_writes_package_sources_to_install_hint(tmp_path: Pat
|
|
|
165
277
|
assert "--config-file /prod/uv.toml" in modulefile
|
|
166
278
|
assert "--index https://packages.example/simple" in modulefile
|
|
167
279
|
assert "--find-links /prod/wheels" in modulefile
|
|
280
|
+
assert "--constraints /prod/constraints.txt" in modulefile
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_deploy_python_tool_writes_url_dependency_to_install_hint(tmp_path: Path) -> None:
|
|
284
|
+
"""Generated module help should preserve direct URL package specs.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
tmp_path: Temporary deployment root.
|
|
288
|
+
"""
|
|
289
|
+
package = "mytool @ https://packages.example/files/mytool-1.0.0-py3-none-any.whl"
|
|
290
|
+
paths = deploy_python_tool(
|
|
291
|
+
name="mytool",
|
|
292
|
+
version="1.0.0",
|
|
293
|
+
package=package,
|
|
294
|
+
module_root=tmp_path / "modules",
|
|
295
|
+
prefix=tmp_path / "tools",
|
|
296
|
+
constraints=("/prod/constraints.txt",),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
modulefile = paths.modulefile.read_text(encoding="utf-8")
|
|
300
|
+
assert shlex_quote(package) in modulefile
|
|
301
|
+
assert "--constraints /prod/constraints.txt" in modulefile
|
|
168
302
|
|
|
169
303
|
|
|
170
304
|
def test_require_executable_reports_missing_command(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -403,6 +537,7 @@ package = "ruff==0.8.0"
|
|
|
403
537
|
python = "3.12"
|
|
404
538
|
indexes = ["https://packages.example/simple"]
|
|
405
539
|
find_links = ["/prod/wheels"]
|
|
540
|
+
constraints = ["constraints.txt", "/prod/global-constraints.txt"]
|
|
406
541
|
uv_config_file = "uv.toml"
|
|
407
542
|
|
|
408
543
|
[[tools]]
|
|
@@ -422,6 +557,7 @@ script = "scripts/helper"
|
|
|
422
557
|
assert not spec.make_default
|
|
423
558
|
assert spec.tools[0].package == "ruff==0.8.0"
|
|
424
559
|
assert spec.tools[0].indexes == ("https://packages.example/simple",)
|
|
560
|
+
assert spec.tools[0].constraints == (str(tmp_path / "constraints.txt"), "/prod/global-constraints.txt")
|
|
425
561
|
assert spec.tools[0].uv_config_file == tmp_path / "uv.toml"
|
|
426
562
|
assert spec.tools[1].script == tmp_path / "scripts/helper"
|
|
427
563
|
|
|
@@ -530,6 +666,7 @@ type = "python"
|
|
|
530
666
|
name = "ruff"
|
|
531
667
|
package = "ruff==0.8.0"
|
|
532
668
|
python = "3.12"
|
|
669
|
+
constraints = ["constraints.txt"]
|
|
533
670
|
uv_config_file = "uv.toml"
|
|
534
671
|
""".strip(),
|
|
535
672
|
encoding="utf-8",
|
|
@@ -550,6 +687,8 @@ uv_config_file = "uv.toml"
|
|
|
550
687
|
"install",
|
|
551
688
|
"--python",
|
|
552
689
|
"3.12",
|
|
690
|
+
"--constraints",
|
|
691
|
+
str(tmp_path / "constraints.txt"),
|
|
553
692
|
"ruff==0.8.0",
|
|
554
693
|
]
|
|
555
694
|
assert calls[0][1]["UV_TOOL_DIR"] == str(result.paths.install_root / "uv-tools")
|
|
@@ -1,49 +0,0 @@
|
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|