maina 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. maina-0.1.0/LICENSE +21 -0
  2. maina-0.1.0/PKG-INFO +129 -0
  3. maina-0.1.0/README.md +97 -0
  4. maina-0.1.0/pyproject.toml +93 -0
  5. maina-0.1.0/pyproject.toml.orig +85 -0
  6. maina-0.1.0/src/maina/__init__.py +76 -0
  7. maina-0.1.0/src/maina/backends/__init__.py +11 -0
  8. maina-0.1.0/src/maina/backends/archive.py +163 -0
  9. maina-0.1.0/src/maina/backends/github.py +113 -0
  10. maina-0.1.0/src/maina/backends/npm.py +186 -0
  11. maina-0.1.0/src/maina/backends/path.py +49 -0
  12. maina-0.1.0/src/maina/backends/pip.py +206 -0
  13. maina-0.1.0/src/maina/backends/script.py +110 -0
  14. maina-0.1.0/src/maina/backends/unpack.py +302 -0
  15. maina-0.1.0/src/maina/catalog_validate.py +110 -0
  16. maina-0.1.0/src/maina/cli.py +498 -0
  17. maina-0.1.0/src/maina/clitokens.py +336 -0
  18. maina-0.1.0/src/maina/core/__init__.py +3 -0
  19. maina-0.1.0/src/maina/core/argparser.py +340 -0
  20. maina-0.1.0/src/maina/core/envfilter.py +63 -0
  21. maina-0.1.0/src/maina/core/errors.py +127 -0
  22. maina-0.1.0/src/maina/core/parsers.py +275 -0
  23. maina-0.1.0/src/maina/core/platform.py +74 -0
  24. maina-0.1.0/src/maina/core/procgroup.py +87 -0
  25. maina-0.1.0/src/maina/core/protocols.py +113 -0
  26. maina-0.1.0/src/maina/core/registry/__init__.py +52 -0
  27. maina-0.1.0/src/maina/core/registry/common.py +138 -0
  28. maina-0.1.0/src/maina/core/registry/ids.py +124 -0
  29. maina-0.1.0/src/maina/core/registry/install.py +289 -0
  30. maina-0.1.0/src/maina/core/registry/jsonschema.py +167 -0
  31. maina-0.1.0/src/maina/core/registry/keys.py +101 -0
  32. maina-0.1.0/src/maina/core/registry/loader.py +485 -0
  33. maina-0.1.0/src/maina/core/registry/merge.py +99 -0
  34. maina-0.1.0/src/maina/core/registry/requires.py +132 -0
  35. maina-0.1.0/src/maina/core/registry/specs.py +437 -0
  36. maina-0.1.0/src/maina/core/registry/validate.py +165 -0
  37. maina-0.1.0/src/maina/core/reportfile.py +106 -0
  38. maina-0.1.0/src/maina/core/result.py +80 -0
  39. maina-0.1.0/src/maina/core/subprocess_utils.py +165 -0
  40. maina-0.1.0/src/maina/installer.py +465 -0
  41. maina-0.1.0/src/maina/mise.py +233 -0
  42. maina-0.1.0/src/maina/py.typed +0 -0
  43. maina-0.1.0/src/maina/runner.py +365 -0
  44. maina-0.1.0/src/maina/runtime.py +388 -0
  45. maina-0.1.0/src/maina/tool.py +368 -0
maina-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vishal Mishra
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.
maina-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: maina
3
+ Version: 0.1.0
4
+ Summary: Use non-Python CLIs from Python with native-like APIs
5
+ Keywords: cli,subprocess,tools,registry,wrapper,mise
6
+ Author: Vishal Mishra
7
+ Author-email: Vishal Mishra <vishal.k.mishra2@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Dist: pyyaml>=6.0
23
+ Requires-Dist: requests>=2.32
24
+ Requires-Dist: typer>=0.12
25
+ Requires-Python: >=3.11.4
26
+ Project-URL: Homepage, https://inquilabee.github.io/maina/
27
+ Project-URL: Documentation, https://inquilabee.github.io/maina/
28
+ Project-URL: Repository, https://github.com/inquilabee/maina
29
+ Project-URL: Changelog, https://github.com/inquilabee/maina/blob/main/CHANGELOG.md
30
+ Project-URL: Issues, https://github.com/inquilabee/maina/issues
31
+ Description-Content-Type: text/markdown
32
+
33
+ # ![maina](https://raw.githubusercontent.com/inquilabee/maina/main/docs/images/maina-mark.svg) maina
34
+
35
+ [![CI](https://github.com/inquilabee/maina/actions/workflows/ci.yml/badge.svg)](https://github.com/inquilabee/maina/actions/workflows/ci.yml)
36
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/inquilabee/maina/blob/main/LICENSE)
37
+ [![Python 3.11.4+](https://img.shields.io/badge/python-3.11.4%2B-blue.svg)](https://github.com/inquilabee/maina/blob/main/pyproject.toml)
38
+
39
+ Call non-Python CLIs from Python. Describe a tool once in a YAML manifest;
40
+ `Tool(name)` installs or finds the executable, maps keyword arguments to argv,
41
+ runs the process, and returns a structured result — no hand-written install,
42
+ argv, or environment plumbing.
43
+
44
+ maina ships **the engine and the manifest contract, not a tool catalog**: you
45
+ register your own manifests (or your app does).
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install maina # or: uv add maina
51
+ ```
52
+
53
+ Python 3.11.4 or newer, on Linux or macOS.
54
+
55
+ ## A first tool
56
+
57
+ `my_tools/greet.yaml` — wrap the `echo` already on your `PATH`:
58
+
59
+ ```yaml
60
+ cli: echo
61
+ install: {backend: path}
62
+ args:
63
+ message: {positional: true, required: true}
64
+ ```
65
+
66
+ ```python
67
+ from pathlib import Path
68
+
69
+ import maina
70
+
71
+ app = maina.register(maina.Runtime.preferred(), registry_dirs=[Path("my_tools")])
72
+ result = app.tool("greet")(message="hello from maina")
73
+ print(result.ok, result.exit_code, repr(result.raw))
74
+ # True 0 'hello from maina\n'
75
+ ```
76
+
77
+ Swap `backend: path` for `github`, `npm`, `pip`, or `script` and maina downloads
78
+ and caches the tool for you (digest-pinned if you give it a `sha256`). The
79
+ [quickstart](https://inquilabee.github.io/maina/user/quickstart/) walks through a
80
+ real `shellcheck` manifest with a JSON parser.
81
+
82
+ ## What you get
83
+
84
+ - **Five install backends** — an executable already on `PATH`, GitHub release
85
+ assets, npm packages, pip packages (one venv per tool), and repo-local scripts —
86
+ with `mise`-provisioned language runtimes when a manifest pins one.
87
+ - **Manifest to argv** — flags, positionals, repeats, defaults, subcommand
88
+ families, and `extends` inheritance. Commands are argv lists, never a shell string.
89
+ - **Structured results** — raw stdout, a parsed value (`json` and friends, or your
90
+ own parser), exit code, stderr, timing. `raise_for_status()` when you want exceptions.
91
+ - **Safe-by-default runs** — per-call `timeout`, bounded output, `env_allow` to keep
92
+ secrets out of third-party binaries, verified archive extraction.
93
+ - **A CLI** — `maina list | describe | validate | prepare | run`.
94
+ - **Typed** — ships `py.typed`; errors derive from `MainaError`.
95
+
96
+ ## Platform support and stability
97
+
98
+ Linux and macOS are supported. Windows is not: the install lock is a no-op there and
99
+ archive extraction assumes Unix semantics.
100
+
101
+ maina is **Beta (0.x)**. `Tool`, the manifest schema, `ToolResult`, and the exception
102
+ types are the stable core. `MainaApp`, `register`, the process-session helpers
103
+ (`runtime`, `use`, `isolated`), and the backend/runtime protocols may change before 1.0.
104
+
105
+ ## Documentation
106
+
107
+ The [user guide](https://inquilabee.github.io/maina/) covers the
108
+ [quickstart](https://inquilabee.github.io/maina/user/quickstart/),
109
+ [manifest schema](https://inquilabee.github.io/maina/user/manifests/),
110
+ [install backends](https://inquilabee.github.io/maina/user/backends/),
111
+ [parsers](https://inquilabee.github.io/maina/user/parsers/),
112
+ [extending maina](https://inquilabee.github.io/maina/user/extending/) and the
113
+ [CLI](https://inquilabee.github.io/maina/user/cli/).
114
+ Worked catalogs live in [`examples/`](https://github.com/inquilabee/maina/tree/main/examples),
115
+ including 30 real CLIs in [`examples/wild_catalog`](https://github.com/inquilabee/maina/tree/main/examples/wild_catalog).
116
+
117
+ ## Security
118
+
119
+ A registered registry directory is trusted code — it can ship a `parsers/*.py` that
120
+ maina imports and runs in-process, and `Runtime.preferred()` honours `$MAINA_REGISTRY`
121
+ unless you pass `use_env=False`. Read the
122
+ [security model](https://inquilabee.github.io/maina/user/security/) before pointing
123
+ maina at a catalog you did not write, and report vulnerabilities as described in the
124
+ [security policy](https://github.com/inquilabee/maina/blob/main/SECURITY.md).
125
+
126
+ ## Contributing
127
+
128
+ See [contributing guide](https://github.com/inquilabee/maina/blob/main/CONTRIBUTING.md).
129
+ MIT licensed.
maina-0.1.0/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # ![maina](https://raw.githubusercontent.com/inquilabee/maina/main/docs/images/maina-mark.svg) maina
2
+
3
+ [![CI](https://github.com/inquilabee/maina/actions/workflows/ci.yml/badge.svg)](https://github.com/inquilabee/maina/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/inquilabee/maina/blob/main/LICENSE)
5
+ [![Python 3.11.4+](https://img.shields.io/badge/python-3.11.4%2B-blue.svg)](https://github.com/inquilabee/maina/blob/main/pyproject.toml)
6
+
7
+ Call non-Python CLIs from Python. Describe a tool once in a YAML manifest;
8
+ `Tool(name)` installs or finds the executable, maps keyword arguments to argv,
9
+ runs the process, and returns a structured result — no hand-written install,
10
+ argv, or environment plumbing.
11
+
12
+ maina ships **the engine and the manifest contract, not a tool catalog**: you
13
+ register your own manifests (or your app does).
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install maina # or: uv add maina
19
+ ```
20
+
21
+ Python 3.11.4 or newer, on Linux or macOS.
22
+
23
+ ## A first tool
24
+
25
+ `my_tools/greet.yaml` — wrap the `echo` already on your `PATH`:
26
+
27
+ ```yaml
28
+ cli: echo
29
+ install: {backend: path}
30
+ args:
31
+ message: {positional: true, required: true}
32
+ ```
33
+
34
+ ```python
35
+ from pathlib import Path
36
+
37
+ import maina
38
+
39
+ app = maina.register(maina.Runtime.preferred(), registry_dirs=[Path("my_tools")])
40
+ result = app.tool("greet")(message="hello from maina")
41
+ print(result.ok, result.exit_code, repr(result.raw))
42
+ # True 0 'hello from maina\n'
43
+ ```
44
+
45
+ Swap `backend: path` for `github`, `npm`, `pip`, or `script` and maina downloads
46
+ and caches the tool for you (digest-pinned if you give it a `sha256`). The
47
+ [quickstart](https://inquilabee.github.io/maina/user/quickstart/) walks through a
48
+ real `shellcheck` manifest with a JSON parser.
49
+
50
+ ## What you get
51
+
52
+ - **Five install backends** — an executable already on `PATH`, GitHub release
53
+ assets, npm packages, pip packages (one venv per tool), and repo-local scripts —
54
+ with `mise`-provisioned language runtimes when a manifest pins one.
55
+ - **Manifest to argv** — flags, positionals, repeats, defaults, subcommand
56
+ families, and `extends` inheritance. Commands are argv lists, never a shell string.
57
+ - **Structured results** — raw stdout, a parsed value (`json` and friends, or your
58
+ own parser), exit code, stderr, timing. `raise_for_status()` when you want exceptions.
59
+ - **Safe-by-default runs** — per-call `timeout`, bounded output, `env_allow` to keep
60
+ secrets out of third-party binaries, verified archive extraction.
61
+ - **A CLI** — `maina list | describe | validate | prepare | run`.
62
+ - **Typed** — ships `py.typed`; errors derive from `MainaError`.
63
+
64
+ ## Platform support and stability
65
+
66
+ Linux and macOS are supported. Windows is not: the install lock is a no-op there and
67
+ archive extraction assumes Unix semantics.
68
+
69
+ maina is **Beta (0.x)**. `Tool`, the manifest schema, `ToolResult`, and the exception
70
+ types are the stable core. `MainaApp`, `register`, the process-session helpers
71
+ (`runtime`, `use`, `isolated`), and the backend/runtime protocols may change before 1.0.
72
+
73
+ ## Documentation
74
+
75
+ The [user guide](https://inquilabee.github.io/maina/) covers the
76
+ [quickstart](https://inquilabee.github.io/maina/user/quickstart/),
77
+ [manifest schema](https://inquilabee.github.io/maina/user/manifests/),
78
+ [install backends](https://inquilabee.github.io/maina/user/backends/),
79
+ [parsers](https://inquilabee.github.io/maina/user/parsers/),
80
+ [extending maina](https://inquilabee.github.io/maina/user/extending/) and the
81
+ [CLI](https://inquilabee.github.io/maina/user/cli/).
82
+ Worked catalogs live in [`examples/`](https://github.com/inquilabee/maina/tree/main/examples),
83
+ including 30 real CLIs in [`examples/wild_catalog`](https://github.com/inquilabee/maina/tree/main/examples/wild_catalog).
84
+
85
+ ## Security
86
+
87
+ A registered registry directory is trusted code — it can ship a `parsers/*.py` that
88
+ maina imports and runs in-process, and `Runtime.preferred()` honours `$MAINA_REGISTRY`
89
+ unless you pass `use_env=False`. Read the
90
+ [security model](https://inquilabee.github.io/maina/user/security/) before pointing
91
+ maina at a catalog you did not write, and report vulnerabilities as described in the
92
+ [security policy](https://github.com/inquilabee/maina/blob/main/SECURITY.md).
93
+
94
+ ## Contributing
95
+
96
+ See [contributing guide](https://github.com/inquilabee/maina/blob/main/CONTRIBUTING.md).
97
+ MIT licensed.
@@ -0,0 +1,93 @@
1
+ [project]
2
+ name = "maina"
3
+ version = "0.1.0"
4
+ description = "Use non-Python CLIs from Python with native-like APIs"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ keywords = [
9
+ "cli",
10
+ "subprocess",
11
+ "tools",
12
+ "registry",
13
+ "wrapper",
14
+ "mise",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Software Development :: Build Tools",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Typing :: Typed",
29
+ ]
30
+ requires-python = ">=3.11.4"
31
+ dependencies = [
32
+ "pyyaml>=6.0",
33
+ "requests>=2.32",
34
+ "typer>=0.12",
35
+ ]
36
+
37
+ [[project.authors]]
38
+ name = "Vishal Mishra"
39
+ email = "vishal.k.mishra2@gmail.com"
40
+
41
+ [project.urls]
42
+ Homepage = "https://inquilabee.github.io/maina/"
43
+ Documentation = "https://inquilabee.github.io/maina/"
44
+ Repository = "https://github.com/inquilabee/maina"
45
+ Changelog = "https://github.com/inquilabee/maina/blob/main/CHANGELOG.md"
46
+ Issues = "https://github.com/inquilabee/maina/issues"
47
+
48
+ [project.scripts]
49
+ maina = "maina.cli:main"
50
+
51
+ [dependency-groups]
52
+ dev = [
53
+ "mkdocs-material>=9.5",
54
+ "pre-commit>=4.0",
55
+ "pytest>=8.0",
56
+ "ruff>=0.9",
57
+ "shipgate>=0.1.0",
58
+ ]
59
+
60
+ [build-system]
61
+ requires = ["uv_build>=0.11.19,<0.12.0"]
62
+ build-backend = "uv_build"
63
+
64
+ [tool.uv.build-backend]
65
+ module-name = "maina"
66
+ module-root = "src"
67
+
68
+ [tool.deptry]
69
+ known_first_party = ["maina"]
70
+
71
+ [tool.ruff]
72
+ line-length = 100
73
+ target-version = "py311"
74
+ src = [
75
+ "src",
76
+ "tests",
77
+ ]
78
+
79
+ [tool.ruff.format]
80
+ exclude = ["*.md"]
81
+
82
+ [tool.ruff.lint]
83
+ select = [
84
+ "E",
85
+ "F",
86
+ "I",
87
+ "B",
88
+ ]
89
+
90
+ [tool.pytest.ini_options]
91
+ testpaths = ["tests"]
92
+ addopts = "-m 'not integration'"
93
+ markers = ["integration: hits network, mise, npm, or other external services"]
@@ -0,0 +1,85 @@
1
+ [project]
2
+ name = "maina"
3
+ version = "0.1.0"
4
+ description = "Use non-Python CLIs from Python with native-like APIs"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Vishal Mishra", email = "vishal.k.mishra2@gmail.com" }
10
+ ]
11
+ keywords = ["cli", "subprocess", "tools", "registry", "wrapper", "mise"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: MacOS",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Software Development :: Build Tools",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Typing :: Typed",
25
+ ]
26
+ requires-python = ">=3.11.4"
27
+ dependencies = [
28
+ "pyyaml>=6.0",
29
+ "requests>=2.32",
30
+ "typer>=0.12",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://inquilabee.github.io/maina/"
35
+ Documentation = "https://inquilabee.github.io/maina/"
36
+ Repository = "https://github.com/inquilabee/maina"
37
+ Changelog = "https://github.com/inquilabee/maina/blob/main/CHANGELOG.md"
38
+ Issues = "https://github.com/inquilabee/maina/issues"
39
+
40
+ [project.scripts]
41
+ maina = "maina.cli:main"
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "mkdocs-material>=9.5",
46
+ "pre-commit>=4.0",
47
+ "pytest>=8.0",
48
+ "ruff>=0.9",
49
+ "shipgate>=0.1.0",
50
+ ]
51
+
52
+ [build-system]
53
+ requires = ["uv_build>=0.11.19,<0.12.0"]
54
+ build-backend = "uv_build"
55
+
56
+ [tool.uv.build-backend]
57
+ module-name = "maina"
58
+ module-root = "src"
59
+
60
+ [tool.deptry]
61
+ # First-party packages (not reported as missing third-party deps).
62
+ known_first_party = ["maina"]
63
+
64
+ # [tool.deptry.per_rule_ignores]
65
+ # DEP002 = ["optional-extra-package"]
66
+
67
+ [tool.ruff]
68
+ line-length = 100
69
+ target-version = "py311"
70
+ src = ["src", "tests"]
71
+
72
+ [tool.ruff.format]
73
+ # Docs code blocks keep their hand-aligned comments.
74
+ exclude = ["*.md"]
75
+
76
+ [tool.ruff.lint]
77
+ select = ["E", "F", "I", "B"]
78
+
79
+ [tool.pytest.ini_options]
80
+ testpaths = ["tests"]
81
+ # Hermetic by default; run the network/mise/npm suite with: pytest -m integration
82
+ addopts = "-m 'not integration'"
83
+ markers = [
84
+ "integration: hits network, mise, npm, or other external services",
85
+ ]
@@ -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)