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.
- maina-0.1.0/LICENSE +21 -0
- maina-0.1.0/PKG-INFO +129 -0
- maina-0.1.0/README.md +97 -0
- maina-0.1.0/pyproject.toml +93 -0
- maina-0.1.0/pyproject.toml.orig +85 -0
- maina-0.1.0/src/maina/__init__.py +76 -0
- maina-0.1.0/src/maina/backends/__init__.py +11 -0
- maina-0.1.0/src/maina/backends/archive.py +163 -0
- maina-0.1.0/src/maina/backends/github.py +113 -0
- maina-0.1.0/src/maina/backends/npm.py +186 -0
- maina-0.1.0/src/maina/backends/path.py +49 -0
- maina-0.1.0/src/maina/backends/pip.py +206 -0
- maina-0.1.0/src/maina/backends/script.py +110 -0
- maina-0.1.0/src/maina/backends/unpack.py +302 -0
- maina-0.1.0/src/maina/catalog_validate.py +110 -0
- maina-0.1.0/src/maina/cli.py +498 -0
- maina-0.1.0/src/maina/clitokens.py +336 -0
- maina-0.1.0/src/maina/core/__init__.py +3 -0
- maina-0.1.0/src/maina/core/argparser.py +340 -0
- maina-0.1.0/src/maina/core/envfilter.py +63 -0
- maina-0.1.0/src/maina/core/errors.py +127 -0
- maina-0.1.0/src/maina/core/parsers.py +275 -0
- maina-0.1.0/src/maina/core/platform.py +74 -0
- maina-0.1.0/src/maina/core/procgroup.py +87 -0
- maina-0.1.0/src/maina/core/protocols.py +113 -0
- maina-0.1.0/src/maina/core/registry/__init__.py +52 -0
- maina-0.1.0/src/maina/core/registry/common.py +138 -0
- maina-0.1.0/src/maina/core/registry/ids.py +124 -0
- maina-0.1.0/src/maina/core/registry/install.py +289 -0
- maina-0.1.0/src/maina/core/registry/jsonschema.py +167 -0
- maina-0.1.0/src/maina/core/registry/keys.py +101 -0
- maina-0.1.0/src/maina/core/registry/loader.py +485 -0
- maina-0.1.0/src/maina/core/registry/merge.py +99 -0
- maina-0.1.0/src/maina/core/registry/requires.py +132 -0
- maina-0.1.0/src/maina/core/registry/specs.py +437 -0
- maina-0.1.0/src/maina/core/registry/validate.py +165 -0
- maina-0.1.0/src/maina/core/reportfile.py +106 -0
- maina-0.1.0/src/maina/core/result.py +80 -0
- maina-0.1.0/src/maina/core/subprocess_utils.py +165 -0
- maina-0.1.0/src/maina/installer.py +465 -0
- maina-0.1.0/src/maina/mise.py +233 -0
- maina-0.1.0/src/maina/py.typed +0 -0
- maina-0.1.0/src/maina/runner.py +365 -0
- maina-0.1.0/src/maina/runtime.py +388 -0
- 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
|
|
34
|
+
|
|
35
|
+
[](https://github.com/inquilabee/maina/actions/workflows/ci.yml)
|
|
36
|
+
[](https://github.com/inquilabee/maina/blob/main/LICENSE)
|
|
37
|
+
[](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
|
|
2
|
+
|
|
3
|
+
[](https://github.com/inquilabee/maina/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/inquilabee/maina/blob/main/LICENSE)
|
|
5
|
+
[](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)
|