maina 0.1.0__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.
maina/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ """Call non-Python CLIs from Python via a registry-first Tool runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
6
+ from importlib.metadata import version as _version
7
+
8
+ from maina.core.errors import (
9
+ AbstractToolError,
10
+ ArgError,
11
+ InstallError,
12
+ MainaError,
13
+ ManifestError,
14
+ ParseError,
15
+ RegisterError,
16
+ RuntimeProvisionError,
17
+ ToolExecError,
18
+ ToolExitError,
19
+ ToolOutputError,
20
+ ToolTimeoutError,
21
+ UnsupportedPlatformError,
22
+ )
23
+ from maina.core.parsers import FunctionParser, parser
24
+ from maina.core.protocols import (
25
+ InstallContext,
26
+ InstallerBackend,
27
+ LanguageRuntimeProvider,
28
+ Parser,
29
+ )
30
+ from maina.core.registry import ArgSpec, InstallSpec, Manifest, OutputSpec, Registry
31
+ from maina.core.result import ToolResult
32
+ from maina.runner import ToolRunner
33
+ from maina.runtime import Runtime, isolated, runtime, use
34
+ from maina.tool import MainaApp, Tool, register
35
+
36
+ try:
37
+ __version__ = _version("maina")
38
+ except _PackageNotFoundError: # pragma: no cover - source checkout without install
39
+ __version__ = "0.0.0+unknown"
40
+
41
+ __all__ = [
42
+ "AbstractToolError",
43
+ "ArgError",
44
+ "ArgSpec",
45
+ "FunctionParser",
46
+ "InstallContext",
47
+ "InstallError",
48
+ "InstallSpec",
49
+ "InstallerBackend",
50
+ "LanguageRuntimeProvider",
51
+ "MainaApp",
52
+ "MainaError",
53
+ "Manifest",
54
+ "ManifestError",
55
+ "OutputSpec",
56
+ "ParseError",
57
+ "Parser",
58
+ "RegisterError",
59
+ "Registry",
60
+ "Runtime",
61
+ "RuntimeProvisionError",
62
+ "Tool",
63
+ "ToolExecError",
64
+ "ToolExitError",
65
+ "ToolOutputError",
66
+ "ToolResult",
67
+ "ToolRunner",
68
+ "ToolTimeoutError",
69
+ "UnsupportedPlatformError",
70
+ "__version__",
71
+ "isolated",
72
+ "parser",
73
+ "register",
74
+ "runtime",
75
+ "use",
76
+ ]
@@ -0,0 +1,11 @@
1
+ """Shipped installer backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from maina.backends.github import GithubBackend
6
+ from maina.backends.npm import NpmBackend
7
+ from maina.backends.path import PathBackend
8
+ from maina.backends.pip import PipBackend
9
+ from maina.backends.script import ScriptBackend
10
+
11
+ __all__ = ["GithubBackend", "NpmBackend", "PathBackend", "PipBackend", "ScriptBackend"]
@@ -0,0 +1,163 @@
1
+ """Shared archive download/extract helpers for installer backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import tempfile
7
+ import time
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+ from urllib.parse import urljoin, urlparse
12
+
13
+ import requests
14
+
15
+ from maina.backends.unpack import UNPACK_ERRORS, ArchiveUnpacker
16
+ from maina.core.errors import InstallError
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterable
20
+
21
+ # Soft cap for streamed release assets (GitHub binaries, etc.).
22
+ DEFAULT_MAX_BYTES = 512 * 1024 * 1024
23
+ CHUNK_SIZE = 64 * 1024
24
+ # Wall-clock cap on one download; the per-read timeout alone lets a slow-drip server hold it open.
25
+ DOWNLOAD_MAX_SECONDS = 30 * 60
26
+ REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
27
+ MAX_REDIRECT_HOPS = 5
28
+
29
+
30
+ class ArchiveFetcher(ArchiveUnpacker):
31
+ """Download https assets and extract binaries into a destination directory.
32
+
33
+ When ``allowed_redirect_hosts`` is given, redirects are resolved hop by hop
34
+ and every hop host must match one of those names (exact or ``.suffix``);
35
+ this keeps a github download from being bounced to an arbitrary host. With
36
+ ``None`` (default) redirects are followed normally.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ session: requests.Session | None = None,
43
+ max_bytes: int = DEFAULT_MAX_BYTES,
44
+ allowed_redirect_hosts: Iterable[str] | None = None,
45
+ ) -> None:
46
+ self._session = session or requests.Session()
47
+ self.max_bytes = max_bytes
48
+ self._allowed_hosts = (
49
+ None if allowed_redirect_hosts is None else frozenset(allowed_redirect_hosts)
50
+ )
51
+
52
+ def close(self) -> None:
53
+ """Close the underlying :class:`requests.Session`."""
54
+ self._session.close()
55
+
56
+ def download_and_extract(
57
+ self,
58
+ url: str,
59
+ dest_dir: Path,
60
+ *,
61
+ binary: str,
62
+ asset_name: str,
63
+ sha256: str | None = None,
64
+ allow_any_executable: bool = False,
65
+ ) -> None:
66
+ """Download an asset and extract/copy the binary into dest_dir.
67
+
68
+ When *sha256* is given, the downloaded asset must match that hex digest
69
+ before it is extracted; a mismatch raises :class:`InstallError`.
70
+ """
71
+ try:
72
+ dest_dir.mkdir(parents=True, exist_ok=True)
73
+ with tempfile.TemporaryDirectory() as tmp:
74
+ tmp_path = Path(tmp)
75
+ # asset_name is validated at manifest load, but never let it escape.
76
+ archive = tmp_path / Path(asset_name).name
77
+ self.fetch_https(url, archive)
78
+ if sha256:
79
+ self._verify_sha256(archive, expected=sha256, url=url)
80
+ self.extract_archive(
81
+ archive,
82
+ tmp_path,
83
+ dest_dir,
84
+ binary=binary,
85
+ asset_name=asset_name,
86
+ allow_any_executable=allow_any_executable,
87
+ )
88
+ except UNPACK_ERRORS as exc:
89
+ raise InstallError(f"could not install {asset_name} from {url}: {exc}") from exc
90
+
91
+ @staticmethod
92
+ def _verify_sha256(archive: Path, *, expected: str, url: str) -> None:
93
+ """Raise unless *archive* hashes to *expected* (case-insensitive hex)."""
94
+ with archive.open("rb") as handle:
95
+ actual = hashlib.file_digest(handle, "sha256").hexdigest()
96
+ if actual.lower() != expected.strip().lower():
97
+ raise InstallError(
98
+ f"sha256 mismatch for {url}: expected {expected.strip().lower()}, got {actual}"
99
+ )
100
+
101
+ def fetch_https(self, url: str, dest: Path) -> None:
102
+ """Stream a URL that must use the https scheme, enforcing a size cap."""
103
+ if urlparse(url).scheme != "https" or not urlparse(url).netloc:
104
+ raise InstallError(f"refusing non-https download URL: {url}")
105
+ if self._allowed_hosts is None:
106
+ self._stream_get(url, dest, allow_redirects=True)
107
+ return
108
+ current = url
109
+ for _ in range(MAX_REDIRECT_HOPS):
110
+ self._require_allowed_host(current)
111
+ location = self._stream_get(current, dest, allow_redirects=False)
112
+ if location is None:
113
+ return
114
+ current = urljoin(current, location)
115
+ raise InstallError(f"too many redirects fetching {url}")
116
+
117
+ def _stream_get(self, url: str, dest: Path, *, allow_redirects: bool) -> str | None:
118
+ """GET *url*; write the body, or return a redirect Location to follow."""
119
+ try:
120
+ with self._session.get(
121
+ url, timeout=120, stream=True, allow_redirects=allow_redirects
122
+ ) as response:
123
+ if not allow_redirects and response.status_code in REDIRECT_STATUSES:
124
+ location = response.headers.get("Location")
125
+ if not location:
126
+ raise InstallError(f"redirect missing Location: {url}")
127
+ return location
128
+ response.raise_for_status()
129
+ self._write_capped_stream(response, dest, url=url)
130
+ except requests.RequestException as exc:
131
+ raise InstallError(f"download failed for {url}: {exc}") from exc
132
+ return None
133
+
134
+ def _require_allowed_host(self, url: str) -> None:
135
+ parsed = urlparse(url)
136
+ host = (parsed.hostname or "").lower()
137
+ listed = self._host_in_allowlist(host)
138
+ if parsed.scheme != "https" or parsed.username is not None or not host or not listed:
139
+ raise InstallError(f"refusing redirect off allowed hosts: {url}")
140
+
141
+ def _host_in_allowlist(self, host: str) -> bool:
142
+ allowed = self._allowed_hosts or frozenset()
143
+ return any(host == name or host.endswith(f".{name}") for name in allowed)
144
+
145
+ def _write_capped_stream(self, response: requests.Response, dest: Path, *, url: str) -> None:
146
+ """Write response body to dest, rejecting Content-Length or body over max_bytes."""
147
+ content_length = response.headers.get("Content-Length")
148
+ if content_length is not None:
149
+ with suppress(ValueError):
150
+ if int(content_length) > self.max_bytes:
151
+ raise InstallError(f"download exceeds size cap ({self.max_bytes} bytes): {url}")
152
+ total = 0
153
+ deadline = time.monotonic() + DOWNLOAD_MAX_SECONDS
154
+ with dest.open("wb") as out:
155
+ for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
156
+ if not chunk:
157
+ continue
158
+ if time.monotonic() > deadline:
159
+ raise InstallError(f"download took over {DOWNLOAD_MAX_SECONDS}s: {url}")
160
+ total += len(chunk)
161
+ if total > self.max_bytes:
162
+ raise InstallError(f"download exceeds size cap ({self.max_bytes} bytes): {url}")
163
+ out.write(chunk)
@@ -0,0 +1,113 @@
1
+ """GitHub release asset installer backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+ from urllib.parse import quote
7
+
8
+ from maina.backends.archive import ArchiveFetcher
9
+ from maina.core.errors import InstallError
10
+ from maina.core.platform import platform_key
11
+ from maina.core.protocols import InstallerBackend
12
+ from maina.core.registry.common import is_unsafe_segment
13
+
14
+ if TYPE_CHECKING:
15
+ from pathlib import Path
16
+
17
+ from maina.core.protocols import InstallContext
18
+ from maina.core.registry import Manifest
19
+
20
+
21
+ GITHUB_DOWNLOAD_HOSTS = frozenset(
22
+ {"github.com", "api.github.com", "githubusercontent.com", "codeload.github.com"}
23
+ )
24
+
25
+
26
+ class GithubBackend(InstallerBackend):
27
+ """Download a GitHub release asset into the tool cache (``backend: github``)."""
28
+
29
+ def __init__(self, fetcher: ArchiveFetcher | None = None) -> None:
30
+ self._fetcher = fetcher or ArchiveFetcher(allowed_redirect_hosts=GITHUB_DOWNLOAD_HOSTS)
31
+
32
+ @property
33
+ def name(self) -> str:
34
+ return "github"
35
+
36
+ def close(self) -> None:
37
+ """Close the shared HTTP session."""
38
+ self._fetcher.close()
39
+
40
+ def resolve(
41
+ self,
42
+ manifest: Manifest,
43
+ *,
44
+ version: str,
45
+ env: dict[str, str],
46
+ ctx: InstallContext,
47
+ ) -> tuple[str, dict[str, str]]:
48
+ exe = self._resolve_github(manifest, version, ctx)
49
+ return str(exe), ctx.prepend_path(env, exe.parent)
50
+
51
+ @staticmethod
52
+ def probe(manifest: Manifest, *, version: str, ctx: InstallContext) -> bool:
53
+ """True when the cached release binary already exists (no download)."""
54
+ binary = manifest.install.binary or manifest.cli
55
+ cached = ctx.cache_root / "tools" / manifest.cli / version / "bin" / binary
56
+ return cached.is_file()
57
+
58
+ def _resolve_github(self, manifest: Manifest, version: str, ctx: InstallContext) -> Path:
59
+ """Download a GitHub release asset into the tool cache if needed."""
60
+ repo, binary = self._install_parts(manifest)
61
+ key = platform_key()
62
+ asset = manifest.install.asset_for(key, manifest.version)
63
+ if not asset:
64
+ raise InstallError(f"{manifest.id}: no github asset for {key}")
65
+ if is_unsafe_segment(asset):
66
+ raise InstallError(
67
+ f"{manifest.id}: asset name {asset!r} for {key} is not a safe path segment "
68
+ "(check install.asset_template and os_map / arch_map)"
69
+ )
70
+
71
+ dest_dir = ctx.ensure_tool_bin_dir(manifest.cli, version)
72
+ dest = dest_dir / binary
73
+ if dest.is_file():
74
+ return dest
75
+
76
+ # ``version`` is the identity-keyed cache segment; the release tag is the
77
+ # manifest's own version (or an explicit ``install.tag``).
78
+ tag = manifest.install.tag or manifest.version
79
+ url = self._release_url(repo, str(asset), tag=str(tag) if tag else None)
80
+ self._fetcher.download_and_extract(
81
+ url,
82
+ dest_dir,
83
+ binary=binary,
84
+ asset_name=str(asset),
85
+ sha256=manifest.install.sha256.get(key),
86
+ allow_any_executable=manifest.install.allow_any_executable,
87
+ )
88
+ if not dest.is_file():
89
+ raise InstallError(
90
+ f"{manifest.id}: downloaded asset but {binary} missing in {dest_dir}"
91
+ )
92
+ return dest
93
+
94
+ @staticmethod
95
+ def _install_parts(manifest: Manifest) -> tuple[str, str]:
96
+ """Validate and return repo and binary name."""
97
+ install = manifest.install
98
+ repo = install.repo
99
+ binary = install.binary or manifest.cli
100
+ if not repo or not install.has_assets():
101
+ raise InstallError(
102
+ f"{manifest.id}: github install needs repo and assets (or asset_template)"
103
+ )
104
+ return repo, binary
105
+
106
+ @staticmethod
107
+ def _release_url(repo: str, asset: str, *, tag: str | None) -> str:
108
+ """Build a GitHub release download URL."""
109
+ asset_quoted = quote(asset, safe="")
110
+ tag_quoted = quote(tag or "", safe="")
111
+ latest = f"https://github.com/{repo}/releases/latest/download/{asset_quoted}"
112
+ pinned = f"https://github.com/{repo}/releases/download/{tag_quoted}/{asset_quoted}"
113
+ return pinned if tag and tag != "latest" else latest
maina/backends/npm.py ADDED
@@ -0,0 +1,186 @@
1
+ """npm package installer backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from typing import TYPE_CHECKING
8
+
9
+ from maina.core.errors import InstallError
10
+ from maina.core.protocols import InstallerBackend
11
+ from maina.core.subprocess_utils import (
12
+ INSTALL_OUTPUT_MAX_BYTES,
13
+ resolve_within,
14
+ run_or_wrap_capped,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+ from maina.core.protocols import InstallContext
21
+ from maina.core.registry import Manifest
22
+
23
+
24
+ class NpmBackend(InstallerBackend):
25
+ """Install an npm package into the tool cache (``install.backend: npm``)."""
26
+
27
+ @property
28
+ def name(self) -> str:
29
+ return "npm"
30
+
31
+ def resolve(
32
+ self,
33
+ manifest: Manifest,
34
+ *,
35
+ version: str,
36
+ env: dict[str, str],
37
+ ctx: InstallContext,
38
+ ) -> tuple[str, dict[str, str]]:
39
+ exe = self._resolve_npm(manifest, version, env, ctx)
40
+ return str(exe), ctx.prepend_path(env, exe.parent)
41
+
42
+ @staticmethod
43
+ def probe(manifest: Manifest, *, version: str, ctx: InstallContext) -> bool:
44
+ """True when the npm bin link already exists in the cache (no install)."""
45
+ link = (
46
+ ctx.cache_root
47
+ / "tools"
48
+ / manifest.cli
49
+ / version
50
+ / "npm"
51
+ / "node_modules"
52
+ / ".bin"
53
+ / manifest.cli
54
+ )
55
+ return link.exists()
56
+
57
+ def _resolve_npm(
58
+ self,
59
+ manifest: Manifest,
60
+ version: str,
61
+ env: dict[str, str],
62
+ ctx: InstallContext,
63
+ ) -> Path:
64
+ """Install an npm package into the tool cache and return its bin path.
65
+
66
+ *version* is the identity-keyed cache-dir segment; the npm dist-tag /
67
+ version comes from ``manifest.version``.
68
+ """
69
+ package = manifest.install.package or manifest.cli
70
+ self._ensure_node_on_path(manifest, env, ctx)
71
+ prefix = ctx.cache_root / "tools" / manifest.cli / version / "npm"
72
+ link = prefix / "node_modules" / ".bin" / manifest.cli
73
+ return (
74
+ link
75
+ if link.exists()
76
+ else self._install_package(
77
+ manifest.cli,
78
+ package,
79
+ manifest.version,
80
+ prefix,
81
+ link,
82
+ env,
83
+ allow_scripts=manifest.install.allow_scripts,
84
+ )
85
+ )
86
+
87
+ @staticmethod
88
+ def _ensure_node_on_path(
89
+ manifest: Manifest,
90
+ env: dict[str, str],
91
+ ctx: InstallContext,
92
+ ) -> None:
93
+ """Ensure Node is on *env* PATH without clobbering composed bins.
94
+
95
+ ``LanguageRuntimeProvider.env`` typically rebuilds PATH from
96
+ ``os.environ``, which would drop bins prepended by
97
+ ``Installer._compose_requires_env``. Prefer ``ensure`` +
98
+ :meth:`InstallContext.prepend_path` on the existing env.
99
+ """
100
+ node_version = manifest.requires.get("node") or "latest"
101
+ if shutil.which("node", path=env.get("PATH")):
102
+ return
103
+ # Hand mise the trimmed install env so its subprocesses honour env_allow.
104
+ bin_dir = ctx.language.ensure("node", node_version, base_env=env)
105
+ ctx.prepend_path(env, bin_dir)
106
+
107
+ @staticmethod
108
+ def _install_package(
109
+ cli: str,
110
+ package: str,
111
+ version: str | None,
112
+ prefix: Path,
113
+ link: Path,
114
+ env: dict[str, str],
115
+ *,
116
+ allow_scripts: bool = False,
117
+ ) -> Path:
118
+ """npm install a package into prefix and return its bin link."""
119
+ prefix.mkdir(parents=True, exist_ok=True)
120
+ # Anchor the local project so npm does not walk up to a parent
121
+ # package.json (or a global root) and resolve the bin to an outside
122
+ # install.
123
+ anchor = prefix / "package.json"
124
+ if not anchor.exists():
125
+ anchor.write_text(
126
+ json.dumps({"name": f"maina-tool-{cli}", "private": True}) + "\n",
127
+ encoding="utf-8",
128
+ )
129
+ pkg_spec = f"{package}@{version}" if version and version != "default" else package
130
+ npm = shutil.which("npm", path=env.get("PATH"))
131
+ if not npm:
132
+ raise InstallError("npm not found; ensure node runtime via mise")
133
+ NpmBackend._npm_install(npm, prefix, pkg_spec, env, allow_scripts=allow_scripts)
134
+ if not (link.is_file() or link.is_symlink()):
135
+ raise InstallError(f"npm install of {pkg_spec} did not produce {cli}")
136
+ resolve_within(
137
+ link,
138
+ prefix,
139
+ error_cls=InstallError,
140
+ message=(
141
+ f"npm resolved {cli} to {link.resolve()}, outside the tool cache "
142
+ f"({prefix}); refusing to link an out-of-cache install"
143
+ ),
144
+ )
145
+ return link
146
+
147
+ INSTALL_TIMEOUT = 600
148
+
149
+ @staticmethod
150
+ def _npm_install(
151
+ npm: str,
152
+ prefix: Path,
153
+ pkg_spec: str,
154
+ env: dict[str, str],
155
+ *,
156
+ allow_scripts: bool = False,
157
+ ) -> None:
158
+ """Run npm install --prefix for a package (local, no lockfile churn).
159
+
160
+ ``--ignore-scripts`` by default: a third-party package's install /
161
+ postinstall lifecycle scripts run with the composed environment, so a
162
+ manifest must opt in with ``install.allow_scripts: true``.
163
+ """
164
+ argv = [
165
+ npm,
166
+ "install",
167
+ "--prefix",
168
+ str(prefix),
169
+ "--no-fund",
170
+ "--no-audit",
171
+ "--no-package-lock",
172
+ ]
173
+ if not allow_scripts:
174
+ argv.append("--ignore-scripts")
175
+ argv.extend(["--", pkg_spec])
176
+ run_or_wrap_capped(
177
+ argv,
178
+ env=env,
179
+ timeout=NpmBackend.INSTALL_TIMEOUT,
180
+ max_bytes=INSTALL_OUTPUT_MAX_BYTES,
181
+ error_cls=InstallError,
182
+ on_timeout=(
183
+ lambda: f"npm install {pkg_spec} timed out after {NpmBackend.INSTALL_TIMEOUT}s"
184
+ ),
185
+ on_failure=lambda detail: f"npm install {pkg_spec} failed: {detail}",
186
+ )
maina/backends/path.py ADDED
@@ -0,0 +1,49 @@
1
+ """PATH-backed installer backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from typing import TYPE_CHECKING
7
+
8
+ from maina.core.errors import InstallError
9
+ from maina.core.protocols import InstallerBackend
10
+
11
+ if TYPE_CHECKING:
12
+ from maina.core.protocols import InstallContext
13
+ from maina.core.registry import Manifest
14
+
15
+
16
+ class PathBackend(InstallerBackend):
17
+ """Resolve a CLI from PATH (``install.backend: path``)."""
18
+
19
+ @property
20
+ def name(self) -> str:
21
+ return "path"
22
+
23
+ def resolve( # ruff:ignore[no-self-use] - must be an instance method, overrides InstallerBackend.resolve
24
+ self,
25
+ manifest: Manifest,
26
+ *,
27
+ version: str,
28
+ env: dict[str, str],
29
+ ctx: InstallContext,
30
+ ) -> tuple[str, dict[str, str]]:
31
+ del version, ctx
32
+ found = shutil.which(manifest.cli, path=env.get("PATH"))
33
+ if not found:
34
+ raise InstallError(
35
+ f"{manifest.cli!r} not found on PATH — install it, or add an install: block "
36
+ "(github, npm, pip, script) so maina can fetch it"
37
+ )
38
+ return found, env
39
+
40
+ @staticmethod
41
+ def probe(manifest: Manifest, *, version: str, ctx: InstallContext) -> bool:
42
+ """True when the CLI already resolves on PATH (no side effects).
43
+
44
+ Searches ``ctx.probe_path`` — the ambient PATH plus the bin dirs of any
45
+ already-installed ``requires:`` runtimes — so a tool provided by a pinned
46
+ runtime is not reported ``missing`` by a check that ``resolve`` passes.
47
+ """
48
+ del version
49
+ return shutil.which(manifest.cli, path=ctx.probe_path) is not None