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.
@@ -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
+ ![tests](https://github.com/ejb90/module-manager/actions/workflows/test.yml/badge.svg)
36
+ [![Coverage](https://codecov.io/gh/ejb90/module-manager/branch/main/graph/badge.svg)](https://codecov.io/gh/ejb90/module-manager)
37
+ [![License](https://img.shields.io/github/license/ejb90/module-manager)](LICENSE)
38
+ ![Ruff](https://img.shields.io/badge/code%20style-ruff-261230)
39
+ [![Documentation](https://readthedocs.org/projects/module-manager/badge/?version=latest)](https://module-manager.readthedocs.io/en/latest/?badge=latest)
40
+ [![PyPI](https://img.shields.io/pypi/v/env-module-manager)](https://pypi.org/project/env-module-manager/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/env-module-manager)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ module-manager = module_manager.cli:main
@@ -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.
@@ -0,0 +1,3 @@
1
+ """Deployment helpers for HPC environment module files."""
2
+
3
+ __version__ = "0.1.0"
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}")
@@ -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())
@@ -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)