env-module-manager 0.1.2__py3-none-any.whl
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.1.2.dist-info/METADATA +91 -0
- env_module_manager-0.1.2.dist-info/RECORD +10 -0
- env_module_manager-0.1.2.dist-info/WHEEL +4 -0
- env_module_manager-0.1.2.dist-info/entry_points.txt +2 -0
- env_module_manager-0.1.2.dist-info/licenses/LICENSE +21 -0
- module_manager/__init__.py +3 -0
- module_manager/cli.py +178 -0
- module_manager/config.py +105 -0
- module_manager/deploy.py +150 -0
- module_manager/modulefile.py +72 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: env-module-manager
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Deploy Python and Rust CLI tools behind GNU environment modulefiles
|
|
5
|
+
Project-URL: Homepage, https://github.com/ejb90/module-manager
|
|
6
|
+
Project-URL: Documentation, https://module-manager.readthedocs.io/
|
|
7
|
+
Project-URL: Tests, https://app.codecov.io/gh/ejb90/module-manager
|
|
8
|
+
Project-URL: Repository, https://github.com/ejb90/module-manager
|
|
9
|
+
Author: ejb90
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering
|
|
24
|
+
Classifier: Topic :: System :: Systems Administration
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Requires-Dist: click>=8.1
|
|
28
|
+
Requires-Dist: rich-click>=1.8
|
|
29
|
+
Provides-Extra: docs
|
|
30
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# module-manager
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
[](https://codecov.io/gh/ejb90/module-manager)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+

|
|
39
|
+
[](https://module-manager.readthedocs.io/en/latest/?badge=latest)
|
|
40
|
+
[](https://pypi.org/project/env-module-manager/)
|
|
41
|
+
[](https://pypi.org/project/env-module-manager/)
|
|
42
|
+
|
|
43
|
+
`module-manager` deploys versioned Python and Rust CLI tools behind GNU
|
|
44
|
+
environment modulefiles for Linux/HPC systems.
|
|
45
|
+
|
|
46
|
+
It writes tools and modulefiles into predictable versioned locations:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
/prod/tools/<name>/<version>/bin
|
|
50
|
+
/prod/modulefiles/<name>/<version>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
Deploy a Python CLI with `uv tool install`:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
module-manager deploy-python ruff 0.8.0 \
|
|
59
|
+
--package 'ruff==0.8.0' \
|
|
60
|
+
--prefix /prod/tools \
|
|
61
|
+
--module-root /prod/modulefiles \
|
|
62
|
+
--execute-install
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Deploy a Rust binary:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
module-manager deploy-rust ripgrep 14.1.1 \
|
|
69
|
+
--binary ./target/release/rg \
|
|
70
|
+
--prefix /prod/tools \
|
|
71
|
+
--module-root /prod/modulefiles
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Use the generated module:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
module use /prod/modulefiles
|
|
78
|
+
module load ruff/0.8.0
|
|
79
|
+
ruff --version
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Documentation
|
|
83
|
+
|
|
84
|
+
Full documentation lives in `docs/` and can be served with MkDocs:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
uv run mkdocs serve
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Start with `docs/index.md` for installation, configuration, Python tools, Rust
|
|
91
|
+
tools, and local development notes.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module_manager/__init__.py,sha256=_SirP-p9OPx91rWxRMLliccvlfwndGIMT9suKHlM978,82
|
|
2
|
+
module_manager/cli.py,sha256=L9Kref6sQHR9zVQv66tfmx1JYug32VaD_ZUtgkMWaTA,5790
|
|
3
|
+
module_manager/config.py,sha256=_FFvW4umsvOc7JuFm2kbI-hr6p0UyZCl5m4TrbB_ACw,3394
|
|
4
|
+
module_manager/deploy.py,sha256=theHJYijlTkPP87nixB6fKV6mHOrrwfYcPXAKZbcZVg,4438
|
|
5
|
+
module_manager/modulefile.py,sha256=-ZujF2kP4dfCaw8fPaCRTMbNgVvbGn2iWfJXrFEFmww,1979
|
|
6
|
+
env_module_manager-0.1.2.dist-info/METADATA,sha256=syRYdIzSWX4EBa75SpcG3D9oq4yEpSoCpVLCz1Qsl8A,3076
|
|
7
|
+
env_module_manager-0.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
env_module_manager-0.1.2.dist-info/entry_points.txt,sha256=yTHfIbCQ9cUZqq2oeUQBgrM6GFKGOzgseOz0oe4YIqQ,59
|
|
9
|
+
env_module_manager-0.1.2.dist-info/licenses/LICENSE,sha256=di0toPB3VLzfEyPm6Ru9_nCyPJmZ8z5Lc_GRRj9PfAY,1062
|
|
10
|
+
env_module_manager-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ellis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
module_manager/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Command-line interface for deploying CLI tools as environment modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
import rich_click as click
|
|
9
|
+
from click import Command
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import AppConfig, load_config
|
|
13
|
+
from .deploy import deploy_python_tool, deploy_rust_tool
|
|
14
|
+
|
|
15
|
+
click.rich_click.TEXT_MARKUP = "rich"
|
|
16
|
+
click.rich_click.STYLE_COMMAND = "bold cyan"
|
|
17
|
+
click.rich_click.STYLE_OPTION = "bold green"
|
|
18
|
+
click.rich_click.STYLE_ARGUMENT = "bold yellow"
|
|
19
|
+
click.rich_click.STYLE_SWITCH = "bold magenta"
|
|
20
|
+
click.rich_click.STYLE_METAVAR = "yellow"
|
|
21
|
+
click.rich_click.HEADER_TEXT = "module-manager"
|
|
22
|
+
click.rich_click.FOOTER_TEXT = (
|
|
23
|
+
"Examples: [cyan]module-manager deploy-python ruff 0.8.0 --package "
|
|
24
|
+
"ruff==0.8.0 --prefix /prod/tools --module-root /prod/modulefiles[/cyan]"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
PATH = click.Path(path_type=Path)
|
|
28
|
+
ClickCommand = TypeVar("ClickCommand", bound=Command)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def common_options(command: ClickCommand) -> ClickCommand:
|
|
32
|
+
"""Attach options shared by Python and Rust deployment commands."""
|
|
33
|
+
command = click.option(
|
|
34
|
+
"--homepage",
|
|
35
|
+
help="Optional upstream homepage shown in module help.",
|
|
36
|
+
)(command)
|
|
37
|
+
command = click.option(
|
|
38
|
+
"--description",
|
|
39
|
+
help="Text shown by module help and module-whatis.",
|
|
40
|
+
)(command)
|
|
41
|
+
command = click.option(
|
|
42
|
+
"--prefix",
|
|
43
|
+
type=PATH,
|
|
44
|
+
help="Root installation prefix, for example /prod/tools.",
|
|
45
|
+
)(command)
|
|
46
|
+
command = click.option(
|
|
47
|
+
"--module-root",
|
|
48
|
+
type=PATH,
|
|
49
|
+
help="Root of the module tree, for example /prod/modulefiles.",
|
|
50
|
+
)(command)
|
|
51
|
+
command = click.argument("version")(command)
|
|
52
|
+
command = click.argument("name")(command)
|
|
53
|
+
return command
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
57
|
+
@click.version_option(__version__)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--config",
|
|
60
|
+
type=PATH,
|
|
61
|
+
envvar="MODULE_MANAGER_CONFIG",
|
|
62
|
+
help="TOML config path. Defaults to ~/.config/module-manager/config.toml.",
|
|
63
|
+
)
|
|
64
|
+
@click.pass_context
|
|
65
|
+
def main(ctx: click.Context, config: Path | None) -> None:
|
|
66
|
+
"""[bold]Deploy CLI tools behind GNU environment modulefiles.[/bold].
|
|
67
|
+
|
|
68
|
+
Build versioned modulefiles for Python tools installed with
|
|
69
|
+
[cyan]uv tool install[/cyan] and Rust binaries copied into a production
|
|
70
|
+
prefix.
|
|
71
|
+
"""
|
|
72
|
+
ctx.obj = load_config(config)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@main.command("deploy-python")
|
|
76
|
+
@common_options
|
|
77
|
+
@click.option("--package", "package", required=True, help="Package spec passed to uv tool install.")
|
|
78
|
+
@click.option("--python", "python", help="Python interpreter/version passed to uv.")
|
|
79
|
+
@click.option(
|
|
80
|
+
"--index",
|
|
81
|
+
"indexes",
|
|
82
|
+
multiple=True,
|
|
83
|
+
help="Additional package index URL passed to uv. May be used more than once.",
|
|
84
|
+
)
|
|
85
|
+
@click.option(
|
|
86
|
+
"--find-links",
|
|
87
|
+
"find_links",
|
|
88
|
+
multiple=True,
|
|
89
|
+
help="Directory or HTML page of packages passed to uv. May be used more than once.",
|
|
90
|
+
)
|
|
91
|
+
@click.option(
|
|
92
|
+
"--execute-install",
|
|
93
|
+
is_flag=True,
|
|
94
|
+
help="Run uv tool install before writing the modulefile.",
|
|
95
|
+
)
|
|
96
|
+
@click.pass_obj
|
|
97
|
+
def deploy_python(
|
|
98
|
+
config: AppConfig,
|
|
99
|
+
name: str,
|
|
100
|
+
version: str,
|
|
101
|
+
module_root: Path | None,
|
|
102
|
+
prefix: Path | None,
|
|
103
|
+
description: str | None,
|
|
104
|
+
homepage: str | None,
|
|
105
|
+
package: str,
|
|
106
|
+
python: str | None,
|
|
107
|
+
indexes: tuple[str, ...],
|
|
108
|
+
find_links: tuple[str, ...],
|
|
109
|
+
execute_install: bool,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Write a modulefile for a [cyan]uv tool install[/cyan] Python CLI."""
|
|
112
|
+
resolved_module_root = require_path(module_root or config.module_root, "module root", "--module-root")
|
|
113
|
+
resolved_prefix = require_path(prefix or config.prefix, "install prefix", "--prefix")
|
|
114
|
+
paths = deploy_python_tool(
|
|
115
|
+
name=name,
|
|
116
|
+
version=version,
|
|
117
|
+
package=package,
|
|
118
|
+
module_root=resolved_module_root,
|
|
119
|
+
prefix=resolved_prefix,
|
|
120
|
+
description=description,
|
|
121
|
+
homepage=homepage,
|
|
122
|
+
python=python,
|
|
123
|
+
indexes=indexes or config.indexes,
|
|
124
|
+
find_links=find_links or config.find_links,
|
|
125
|
+
execute_install=execute_install,
|
|
126
|
+
)
|
|
127
|
+
print_result(paths.modulefile, paths.install_root, paths.bin_dir)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@main.command("deploy-rust")
|
|
131
|
+
@common_options
|
|
132
|
+
@click.option(
|
|
133
|
+
"--binary",
|
|
134
|
+
type=PATH,
|
|
135
|
+
help="Compiled binary to copy into the versioned prefix.",
|
|
136
|
+
)
|
|
137
|
+
@click.pass_obj
|
|
138
|
+
def deploy_rust(
|
|
139
|
+
config: AppConfig,
|
|
140
|
+
name: str,
|
|
141
|
+
version: str,
|
|
142
|
+
module_root: Path | None,
|
|
143
|
+
prefix: Path | None,
|
|
144
|
+
description: str | None,
|
|
145
|
+
homepage: str | None,
|
|
146
|
+
binary: Path | None,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Copy a Rust CLI binary and write a modulefile for it."""
|
|
149
|
+
resolved_module_root = require_path(module_root or config.module_root, "module root", "--module-root")
|
|
150
|
+
resolved_prefix = require_path(prefix or config.prefix, "install prefix", "--prefix")
|
|
151
|
+
paths = deploy_rust_tool(
|
|
152
|
+
name=name,
|
|
153
|
+
version=version,
|
|
154
|
+
module_root=resolved_module_root,
|
|
155
|
+
prefix=resolved_prefix,
|
|
156
|
+
binary=binary.expanduser() if binary else None,
|
|
157
|
+
description=description,
|
|
158
|
+
homepage=homepage,
|
|
159
|
+
)
|
|
160
|
+
print_result(paths.modulefile, paths.install_root, paths.bin_dir)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def require_path(value: Path | None, label: str, option: str) -> Path:
|
|
164
|
+
"""Return a configured path or raise a Click usage error."""
|
|
165
|
+
if value is None:
|
|
166
|
+
msg = (
|
|
167
|
+
f"Missing {label}. Provide {option}, set the matching MODULE_MANAGER_* "
|
|
168
|
+
"environment variable, or configure it in TOML."
|
|
169
|
+
)
|
|
170
|
+
raise click.UsageError(msg)
|
|
171
|
+
return value.expanduser()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def print_result(modulefile: Path, install_root: Path, bin_dir: Path) -> None:
|
|
175
|
+
"""Print the paths produced by a deployment command."""
|
|
176
|
+
click.echo(f"modulefile: {modulefile}")
|
|
177
|
+
click.echo(f"install root: {install_root}")
|
|
178
|
+
click.echo(f"bin dir: {bin_dir}")
|
module_manager/config.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Configuration loading for module-manager."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import tomllib
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG_PATH = Path("~/.config/module-manager/config.toml")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class AppConfig:
|
|
17
|
+
"""Resolved defaults loaded from files and environment variables."""
|
|
18
|
+
|
|
19
|
+
prefix: Path | None = None
|
|
20
|
+
module_root: Path | None = None
|
|
21
|
+
indexes: tuple[str, ...] = ()
|
|
22
|
+
find_links: tuple[str, ...] = ()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_config_path() -> Path:
|
|
26
|
+
"""Return the default per-user config path."""
|
|
27
|
+
return DEFAULT_CONFIG_PATH.expanduser()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_config(path: Path | None = None) -> AppConfig:
|
|
31
|
+
"""Load configuration from TOML and overlay environment variables."""
|
|
32
|
+
config_path = (path or default_config_path()).expanduser()
|
|
33
|
+
file_config = load_file_config(config_path)
|
|
34
|
+
return overlay_env_config(file_config)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_file_config(path: Path) -> AppConfig:
|
|
38
|
+
"""Load configuration defaults from a TOML file if it exists."""
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return AppConfig()
|
|
41
|
+
|
|
42
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
43
|
+
python_data = table(data.get("python"), "python")
|
|
44
|
+
return AppConfig(
|
|
45
|
+
prefix=optional_path(data.get("prefix"), "prefix"),
|
|
46
|
+
module_root=optional_path(data.get("module_root"), "module_root"),
|
|
47
|
+
indexes=string_tuple(python_data.get("indexes"), "python.indexes"),
|
|
48
|
+
find_links=string_tuple(python_data.get("find_links"), "python.find_links"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def overlay_env_config(config: AppConfig) -> AppConfig:
|
|
53
|
+
"""Apply MODULE_MANAGER_* environment variables to file config."""
|
|
54
|
+
return AppConfig(
|
|
55
|
+
prefix=env_path("MODULE_MANAGER_PREFIX") or config.prefix,
|
|
56
|
+
module_root=env_path("MODULE_MANAGER_MODULE_ROOT") or config.module_root,
|
|
57
|
+
indexes=env_tuple("MODULE_MANAGER_INDEXES") or env_tuple("MODULE_MANAGER_INDEX") or config.indexes,
|
|
58
|
+
find_links=env_tuple("MODULE_MANAGER_FIND_LINKS") or config.find_links,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def table(value: object, key: str) -> dict[str, Any]:
|
|
63
|
+
"""Return a TOML table or raise a useful type error."""
|
|
64
|
+
if value is None:
|
|
65
|
+
return {}
|
|
66
|
+
if isinstance(value, dict):
|
|
67
|
+
return value
|
|
68
|
+
msg = f"{key} must be a TOML table"
|
|
69
|
+
raise TypeError(msg)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def optional_path(value: object, key: str) -> Path | None:
|
|
73
|
+
"""Parse an optional TOML string as a path."""
|
|
74
|
+
if value is None:
|
|
75
|
+
return None
|
|
76
|
+
if isinstance(value, str):
|
|
77
|
+
return Path(value).expanduser()
|
|
78
|
+
msg = f"{key} must be a string"
|
|
79
|
+
raise TypeError(msg)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def string_tuple(value: object, key: str) -> tuple[str, ...]:
|
|
83
|
+
"""Parse an optional TOML string list."""
|
|
84
|
+
if value is None:
|
|
85
|
+
return ()
|
|
86
|
+
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
87
|
+
return tuple(value)
|
|
88
|
+
msg = f"{key} must be a list of strings"
|
|
89
|
+
raise TypeError(msg)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def env_path(name: str) -> Path | None:
|
|
93
|
+
"""Read a path from an environment variable."""
|
|
94
|
+
value = os.environ.get(name)
|
|
95
|
+
if not value:
|
|
96
|
+
return None
|
|
97
|
+
return Path(value).expanduser()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def env_tuple(name: str) -> tuple[str, ...]:
|
|
101
|
+
"""Read comma-separated string values from an environment variable."""
|
|
102
|
+
value = os.environ.get(name)
|
|
103
|
+
if not value:
|
|
104
|
+
return ()
|
|
105
|
+
return tuple(item.strip() for item in value.split(",") if item.strip())
|
module_manager/deploy.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Deployment routines for Python and Rust command-line tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .modulefile import ModuleSpec, render_modulefile
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MissingExecutableError(RuntimeError):
|
|
17
|
+
"""Raised when a required external executable is unavailable."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class DeploymentPaths:
|
|
22
|
+
"""Filesystem paths created or targeted by a deployment."""
|
|
23
|
+
|
|
24
|
+
install_root: Path
|
|
25
|
+
bin_dir: Path
|
|
26
|
+
modulefile: Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def deployment_paths(module_root: Path, prefix: Path, name: str, version: str) -> DeploymentPaths:
|
|
30
|
+
"""Compute versioned installation and modulefile paths for a tool."""
|
|
31
|
+
install_root = prefix / name / version
|
|
32
|
+
return DeploymentPaths(
|
|
33
|
+
install_root=install_root,
|
|
34
|
+
bin_dir=install_root / "bin",
|
|
35
|
+
modulefile=module_root / name / version,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def uv_install_command(
|
|
40
|
+
package: str,
|
|
41
|
+
python: str | None = None,
|
|
42
|
+
indexes: tuple[str, ...] = (),
|
|
43
|
+
find_links: tuple[str, ...] = (),
|
|
44
|
+
) -> list[str]:
|
|
45
|
+
"""Build the uv command used to install a Python CLI tool."""
|
|
46
|
+
command = ["uv", "tool", "install"]
|
|
47
|
+
if python:
|
|
48
|
+
command.extend(["--python", python])
|
|
49
|
+
for index in indexes:
|
|
50
|
+
command.extend(["--index", index])
|
|
51
|
+
for link in find_links:
|
|
52
|
+
command.extend(["--find-links", link])
|
|
53
|
+
command.append(package)
|
|
54
|
+
return command
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_text(path: Path, content: str) -> None:
|
|
58
|
+
"""Write UTF-8 text to a path, creating parent directories first."""
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
path.write_text(content, encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def require_executable(name: str) -> str:
|
|
64
|
+
"""Return an executable path or raise a clear runtime error."""
|
|
65
|
+
executable = shutil.which(name)
|
|
66
|
+
if executable is None:
|
|
67
|
+
msg = f"Required executable not found on PATH: {name}"
|
|
68
|
+
raise MissingExecutableError(msg)
|
|
69
|
+
return executable
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def deploy_python_tool(
|
|
73
|
+
*,
|
|
74
|
+
name: str,
|
|
75
|
+
version: str,
|
|
76
|
+
package: str,
|
|
77
|
+
module_root: Path,
|
|
78
|
+
prefix: Path,
|
|
79
|
+
description: str | None = None,
|
|
80
|
+
homepage: str | None = None,
|
|
81
|
+
python: str | None = None,
|
|
82
|
+
indexes: tuple[str, ...] = (),
|
|
83
|
+
find_links: tuple[str, ...] = (),
|
|
84
|
+
execute_install: bool = False,
|
|
85
|
+
) -> DeploymentPaths:
|
|
86
|
+
"""Deploy metadata for a Python tool and optionally install it with uv."""
|
|
87
|
+
paths = deployment_paths(module_root, prefix, name, version)
|
|
88
|
+
paths.bin_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
tool_dir = paths.install_root / "uv-tools"
|
|
90
|
+
command = uv_install_command(package, python, indexes, find_links)
|
|
91
|
+
|
|
92
|
+
if execute_install:
|
|
93
|
+
require_executable("uv")
|
|
94
|
+
env = os.environ.copy()
|
|
95
|
+
env["UV_TOOL_DIR"] = str(tool_dir)
|
|
96
|
+
env["UV_TOOL_BIN_DIR"] = str(paths.bin_dir)
|
|
97
|
+
subprocess.run(command, check=True, env=env)
|
|
98
|
+
|
|
99
|
+
install_hint = (
|
|
100
|
+
f"UV_TOOL_DIR={shlex.quote(str(tool_dir))} "
|
|
101
|
+
f"UV_TOOL_BIN_DIR={shlex.quote(str(paths.bin_dir))} "
|
|
102
|
+
f"{shlex.join(command)}"
|
|
103
|
+
)
|
|
104
|
+
modulefile = render_modulefile(
|
|
105
|
+
ModuleSpec(
|
|
106
|
+
name=name,
|
|
107
|
+
version=version,
|
|
108
|
+
root=paths.install_root,
|
|
109
|
+
bin_dir=paths.bin_dir,
|
|
110
|
+
description=description,
|
|
111
|
+
homepage=homepage,
|
|
112
|
+
install_hint=install_hint,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
write_text(paths.modulefile, modulefile)
|
|
116
|
+
return paths
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def deploy_rust_tool(
|
|
120
|
+
*,
|
|
121
|
+
name: str,
|
|
122
|
+
version: str,
|
|
123
|
+
module_root: Path,
|
|
124
|
+
prefix: Path,
|
|
125
|
+
binary: Path | None = None,
|
|
126
|
+
description: str | None = None,
|
|
127
|
+
homepage: str | None = None,
|
|
128
|
+
) -> DeploymentPaths:
|
|
129
|
+
"""Deploy metadata for a Rust tool and optionally copy its binary."""
|
|
130
|
+
paths = deployment_paths(module_root, prefix, name, version)
|
|
131
|
+
paths.bin_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
if binary:
|
|
134
|
+
target = paths.bin_dir / name
|
|
135
|
+
shutil.copy2(binary, target)
|
|
136
|
+
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
137
|
+
|
|
138
|
+
modulefile = render_modulefile(
|
|
139
|
+
ModuleSpec(
|
|
140
|
+
name=name,
|
|
141
|
+
version=version,
|
|
142
|
+
root=paths.install_root,
|
|
143
|
+
bin_dir=paths.bin_dir,
|
|
144
|
+
description=description,
|
|
145
|
+
homepage=homepage,
|
|
146
|
+
install_hint=f"copy binary to {paths.bin_dir / name}",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
write_text(paths.modulefile, modulefile)
|
|
150
|
+
return paths
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Rendering helpers for GNU/Tcl environment modulefiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def tcl_quote(value: str) -> str:
|
|
10
|
+
"""Quote a string for the small Tcl subset used in modulefiles."""
|
|
11
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
|
|
12
|
+
return f'"{escaped}"'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ModuleSpec:
|
|
17
|
+
"""Data required to render one versioned environment modulefile."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
version: str
|
|
21
|
+
root: Path
|
|
22
|
+
bin_dir: Path
|
|
23
|
+
description: str | None = None
|
|
24
|
+
family: str | None = None
|
|
25
|
+
homepage: str | None = None
|
|
26
|
+
install_hint: str | None = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def module_path(self) -> str:
|
|
30
|
+
"""Return the canonical module name/version path."""
|
|
31
|
+
return f"{self.name}/{self.version}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render_modulefile(spec: ModuleSpec) -> str:
|
|
35
|
+
"""Render a Tcl modulefile for the given module specification."""
|
|
36
|
+
description = spec.description or f"{spec.name} {spec.version}"
|
|
37
|
+
lines = [
|
|
38
|
+
"#%Module1.0",
|
|
39
|
+
f"## {spec.module_path}",
|
|
40
|
+
"",
|
|
41
|
+
"proc ModulesHelp { } {",
|
|
42
|
+
f" puts stderr {tcl_quote(description)}",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
if spec.homepage:
|
|
46
|
+
lines.append(f" puts stderr {tcl_quote('Homepage: ' + spec.homepage)}")
|
|
47
|
+
if spec.install_hint:
|
|
48
|
+
lines.append(f" puts stderr {tcl_quote('Install: ' + spec.install_hint)}")
|
|
49
|
+
|
|
50
|
+
lines.extend(
|
|
51
|
+
[
|
|
52
|
+
"}",
|
|
53
|
+
"",
|
|
54
|
+
f"module-whatis {tcl_quote(description)}",
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if spec.family:
|
|
59
|
+
lines.append(f"family {tcl_quote(spec.family)}")
|
|
60
|
+
|
|
61
|
+
lines.extend(
|
|
62
|
+
[
|
|
63
|
+
"",
|
|
64
|
+
f"set root {tcl_quote(str(spec.root))}",
|
|
65
|
+
f"set bindir {tcl_quote(str(spec.bin_dir))}",
|
|
66
|
+
"",
|
|
67
|
+
"prepend-path PATH $bindir",
|
|
68
|
+
f"setenv {spec.name.upper().replace('-', '_')}_ROOT $root",
|
|
69
|
+
"",
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
return "\n".join(lines)
|