paxlet 0.1.1__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.
- paxlet-0.1.1/PKG-INFO +139 -0
- paxlet-0.1.1/README.md +129 -0
- paxlet-0.1.1/paxlet/__init__.py +3 -0
- paxlet-0.1.1/paxlet/__main__.py +3 -0
- paxlet-0.1.1/paxlet/cli.py +202 -0
- paxlet-0.1.1/paxlet/errors.py +14 -0
- paxlet-0.1.1/paxlet/manifest.py +207 -0
- paxlet-0.1.1/paxlet/packing.py +30 -0
- paxlet-0.1.1/paxlet/receipt.py +46 -0
- paxlet-0.1.1/paxlet/resolver.py +64 -0
- paxlet-0.1.1/paxlet/runtime.py +107 -0
- paxlet-0.1.1/paxlet/schema.py +43 -0
- paxlet-0.1.1/paxlet.egg-info/PKG-INFO +139 -0
- paxlet-0.1.1/paxlet.egg-info/SOURCES.txt +18 -0
- paxlet-0.1.1/paxlet.egg-info/dependency_links.txt +1 -0
- paxlet-0.1.1/paxlet.egg-info/entry_points.txt +2 -0
- paxlet-0.1.1/paxlet.egg-info/top_level.txt +1 -0
- paxlet-0.1.1/pyproject.toml +32 -0
- paxlet-0.1.1/setup.cfg +4 -0
- paxlet-0.1.1/tests/test_core.py +53 -0
paxlet-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paxlet
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Small, addressable packages of resources and actions for connected systems.
|
|
5
|
+
Author: Paxlet contributors
|
|
6
|
+
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# paxlet
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## AI Cost Tracking
|
|
15
|
+
|
|
16
|
+
   
|
|
17
|
+
  
|
|
18
|
+
|
|
19
|
+
- 🤖 **LLM usage:** $0.0466 (1 commits)
|
|
20
|
+
- 👤 **Human dev:** ~$100 (1.0h @ $100/h, 30min dedup)
|
|
21
|
+
|
|
22
|
+
Generated on 2026-09-22 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
**Build small. Connect everything.**
|
|
29
|
+
|
|
30
|
+
Paxlet Core is a deliberately small contract for packaging **resources and actions** as addressable capabilities.
|
|
31
|
+
|
|
32
|
+
A Paxlet has a stable identity, a version, clear inputs/outputs, explicit dependencies and requested permissions. A node can resolve it, verify it, run it and produce a receipt.
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
URN identity
|
|
36
|
+
│
|
|
37
|
+
▼
|
|
38
|
+
┌──────────── Paxlet ────────────┐
|
|
39
|
+
│ resources │
|
|
40
|
+
│ actions │
|
|
41
|
+
│ dependencies │
|
|
42
|
+
│ permissions │
|
|
43
|
+
│ provenance │
|
|
44
|
+
└───────────────────────────────┘
|
|
45
|
+
│
|
|
46
|
+
▼
|
|
47
|
+
URI locations → Node → Receipt
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Start in five minutes
|
|
51
|
+
|
|
52
|
+
Requires Python 3.11+ and no third-party runtime dependencies. You can run it directly after cloning:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
./bin/paxlet init /tmp/my-first
|
|
56
|
+
./bin/paxlet run /tmp/my-first hello --input '{"name":"Ada"}'
|
|
57
|
+
|
|
58
|
+
./bin/paxlet verify examples/hello
|
|
59
|
+
./bin/paxlet run examples/hello hello --input '{"name":"Ada"}'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Optional developer install:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python -m venv .venv
|
|
66
|
+
. .venv/bin/activate
|
|
67
|
+
pip install -e .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or address the same capability by identity:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
./bin/paxlet resolve urn:paxlet:example:hello
|
|
74
|
+
./bin/paxlet run urn:paxlet:example:hello hello --input '{"name":"Ada"}'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Try a science-oriented example:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
./bin/paxlet run examples/science-fasta-gc analyze --input '{}'
|
|
81
|
+
./bin/paxlet run examples/statistics-mean mean --input '{"values":[1,2,4,8]}'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## CLI
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
paxlet init DIRECTORY
|
|
88
|
+
paxlet verify PATH|URN
|
|
89
|
+
paxlet inspect PATH|URN
|
|
90
|
+
paxlet resolve URN
|
|
91
|
+
paxlet run PATH|URN ACTION --input JSON
|
|
92
|
+
paxlet pack PATH|URN -o package.paxlet.zip
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Why Core is small
|
|
96
|
+
|
|
97
|
+
Paxlet is not an AI-agent framework. It is the portable unit that larger systems can use.
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
Paxlet Core package + identity + contract
|
|
101
|
+
Paxlet Node resolution + grants + execution + receipts
|
|
102
|
+
Paxlet Network registries + peers + distribution
|
|
103
|
+
Taskand planning + orchestration + adaptive-system showcase
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Taskand can demonstrate planners, processes, federation, mesh, Digital Twin, MCP, governance and recovery without making those features mandatory for every Paxlet implementation.
|
|
107
|
+
|
|
108
|
+
## Repository map
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
SPEC.md normative Core 0.1 draft
|
|
112
|
+
schemas/ manifest + receipt schemas
|
|
113
|
+
paxlet/ tiny Python reference runtime
|
|
114
|
+
examples/ five understandable capabilities
|
|
115
|
+
registry/local.json local URN → URI resolver example
|
|
116
|
+
conformance/ implementation compatibility runner
|
|
117
|
+
docs/ addressing, security, receipts, layers
|
|
118
|
+
integrations/taskand/ how Taskand fits as the showcase
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Conformance
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
python -m unittest discover -s tests -v
|
|
125
|
+
python conformance/run.py
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Important security note
|
|
129
|
+
|
|
130
|
+
The reference runtime is intentionally educational. It validates package paths and exposes secrets only after explicit user grant, but it **does not enforce filesystem/network permissions with an OS sandbox**. Production nodes should use containers, VMs or OS policy for enforcement. See `docs/security.md`.
|
|
131
|
+
|
|
132
|
+
## Status
|
|
133
|
+
|
|
134
|
+
Core 0.1 is a draft intended to make the idea testable and implementable by independent runtimes. See `ROADMAP.md`.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
Licensed under Apache-2.0.
|
paxlet-0.1.1/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# paxlet
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## AI Cost Tracking
|
|
5
|
+
|
|
6
|
+
   
|
|
7
|
+
  
|
|
8
|
+
|
|
9
|
+
- 🤖 **LLM usage:** $0.0466 (1 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$100 (1.0h @ $100/h, 30min dedup)
|
|
11
|
+
|
|
12
|
+
Generated on 2026-09-22 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
**Build small. Connect everything.**
|
|
19
|
+
|
|
20
|
+
Paxlet Core is a deliberately small contract for packaging **resources and actions** as addressable capabilities.
|
|
21
|
+
|
|
22
|
+
A Paxlet has a stable identity, a version, clear inputs/outputs, explicit dependencies and requested permissions. A node can resolve it, verify it, run it and produce a receipt.
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
URN identity
|
|
26
|
+
│
|
|
27
|
+
▼
|
|
28
|
+
┌──────────── Paxlet ────────────┐
|
|
29
|
+
│ resources │
|
|
30
|
+
│ actions │
|
|
31
|
+
│ dependencies │
|
|
32
|
+
│ permissions │
|
|
33
|
+
│ provenance │
|
|
34
|
+
└───────────────────────────────┘
|
|
35
|
+
│
|
|
36
|
+
▼
|
|
37
|
+
URI locations → Node → Receipt
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Start in five minutes
|
|
41
|
+
|
|
42
|
+
Requires Python 3.11+ and no third-party runtime dependencies. You can run it directly after cloning:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
./bin/paxlet init /tmp/my-first
|
|
46
|
+
./bin/paxlet run /tmp/my-first hello --input '{"name":"Ada"}'
|
|
47
|
+
|
|
48
|
+
./bin/paxlet verify examples/hello
|
|
49
|
+
./bin/paxlet run examples/hello hello --input '{"name":"Ada"}'
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Optional developer install:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python -m venv .venv
|
|
56
|
+
. .venv/bin/activate
|
|
57
|
+
pip install -e .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or address the same capability by identity:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
./bin/paxlet resolve urn:paxlet:example:hello
|
|
64
|
+
./bin/paxlet run urn:paxlet:example:hello hello --input '{"name":"Ada"}'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Try a science-oriented example:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
./bin/paxlet run examples/science-fasta-gc analyze --input '{}'
|
|
71
|
+
./bin/paxlet run examples/statistics-mean mean --input '{"values":[1,2,4,8]}'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## CLI
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
paxlet init DIRECTORY
|
|
78
|
+
paxlet verify PATH|URN
|
|
79
|
+
paxlet inspect PATH|URN
|
|
80
|
+
paxlet resolve URN
|
|
81
|
+
paxlet run PATH|URN ACTION --input JSON
|
|
82
|
+
paxlet pack PATH|URN -o package.paxlet.zip
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Why Core is small
|
|
86
|
+
|
|
87
|
+
Paxlet is not an AI-agent framework. It is the portable unit that larger systems can use.
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
Paxlet Core package + identity + contract
|
|
91
|
+
Paxlet Node resolution + grants + execution + receipts
|
|
92
|
+
Paxlet Network registries + peers + distribution
|
|
93
|
+
Taskand planning + orchestration + adaptive-system showcase
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Taskand can demonstrate planners, processes, federation, mesh, Digital Twin, MCP, governance and recovery without making those features mandatory for every Paxlet implementation.
|
|
97
|
+
|
|
98
|
+
## Repository map
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
SPEC.md normative Core 0.1 draft
|
|
102
|
+
schemas/ manifest + receipt schemas
|
|
103
|
+
paxlet/ tiny Python reference runtime
|
|
104
|
+
examples/ five understandable capabilities
|
|
105
|
+
registry/local.json local URN → URI resolver example
|
|
106
|
+
conformance/ implementation compatibility runner
|
|
107
|
+
docs/ addressing, security, receipts, layers
|
|
108
|
+
integrations/taskand/ how Taskand fits as the showcase
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Conformance
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
python -m unittest discover -s tests -v
|
|
115
|
+
python conformance/run.py
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Important security note
|
|
119
|
+
|
|
120
|
+
The reference runtime is intentionally educational. It validates package paths and exposes secrets only after explicit user grant, but it **does not enforce filesystem/network permissions with an OS sandbox**. Production nodes should use containers, VMs or OS policy for enforcement. See `docs/security.md`.
|
|
121
|
+
|
|
122
|
+
## Status
|
|
123
|
+
|
|
124
|
+
Core 0.1 is a draft intended to make the idea testable and implementable by independent runtimes. See `ROADMAP.md`.
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
Licensed under Apache-2.0.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .errors import PaxletError
|
|
10
|
+
from .manifest import load_manifest, package_digest, validate_manifest
|
|
11
|
+
from .packing import pack as pack_paxlet
|
|
12
|
+
from .resolver import resolve, resolve_to_path
|
|
13
|
+
from .runtime import run_action
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _target(value: str, registry: str | None) -> Path:
|
|
17
|
+
if value.startswith("urn:paxlet:"):
|
|
18
|
+
return resolve_to_path(value, registry)
|
|
19
|
+
return Path(value)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _json(value) -> None:
|
|
23
|
+
print(json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def command_init(args) -> int:
|
|
28
|
+
target = Path(args.directory).expanduser().resolve()
|
|
29
|
+
if target.exists() and any(target.iterdir()) and not args.force:
|
|
30
|
+
raise PaxletError(f"directory is not empty: {target}; use --force to add starter files")
|
|
31
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
name = args.name or target.name
|
|
33
|
+
slug = "".join(ch.lower() if ch.isalnum() else "-" for ch in name).strip("-")
|
|
34
|
+
while "--" in slug:
|
|
35
|
+
slug = slug.replace("--", "-")
|
|
36
|
+
urn = args.urn or f"urn:paxlet:local:{slug}"
|
|
37
|
+
manifest = {
|
|
38
|
+
"paxlet": "0.1",
|
|
39
|
+
"identity": {"urn": urn, "name": name, "version": "0.1.0"},
|
|
40
|
+
"bindings": [],
|
|
41
|
+
"actions": {
|
|
42
|
+
"hello": {
|
|
43
|
+
"description": "Your first Paxlet action.",
|
|
44
|
+
"runtime": {"type": "python", "entry": "hello.py"},
|
|
45
|
+
"input": {"type": "object", "properties": {"name": {"type": "string"}}},
|
|
46
|
+
"output": {"type": "object", "required": ["message"], "properties": {"message": {"type": "string"}}}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"resources": [],
|
|
50
|
+
"requires": [],
|
|
51
|
+
"permissions": {"filesystem": {"read": [], "write": []}, "network": [], "secrets": []},
|
|
52
|
+
"provenance": {"created_by": "paxlet init"}
|
|
53
|
+
}
|
|
54
|
+
manifest_file = target / "paxlet.json"
|
|
55
|
+
entry_file = target / "hello.py"
|
|
56
|
+
if not args.force and (manifest_file.exists() or entry_file.exists()):
|
|
57
|
+
raise PaxletError("starter files already exist; use --force to overwrite them")
|
|
58
|
+
manifest_file.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
59
|
+
entry_file.write_text(
|
|
60
|
+
'import json, sys\n'
|
|
61
|
+
'payload = json.load(sys.stdin)\n'
|
|
62
|
+
'name = payload.get("name", "world")\n'
|
|
63
|
+
'print(json.dumps({"message": f"Hello, {name}!"}))\n',
|
|
64
|
+
encoding="utf-8",
|
|
65
|
+
)
|
|
66
|
+
print(target)
|
|
67
|
+
print(f"next: paxlet run {target} hello --input '{{\"name\":\"Ada\"}}'")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
def command_verify(args) -> int:
|
|
71
|
+
path = _target(args.target, args.registry)
|
|
72
|
+
manifest_file, manifest = load_manifest(path)
|
|
73
|
+
result = validate_manifest(manifest_file.parent, manifest)
|
|
74
|
+
payload = {"ok": result.ok, "errors": result.errors, "warnings": result.warnings}
|
|
75
|
+
if result.ok:
|
|
76
|
+
payload["identity"] = manifest["identity"]
|
|
77
|
+
payload["package_digest"] = package_digest(manifest_file.parent, manifest)
|
|
78
|
+
if args.json:
|
|
79
|
+
_json(payload)
|
|
80
|
+
else:
|
|
81
|
+
print("✓ valid Paxlet" if result.ok else "✗ invalid Paxlet")
|
|
82
|
+
for warning in result.warnings:
|
|
83
|
+
print(f"! {warning}")
|
|
84
|
+
for error in result.errors:
|
|
85
|
+
print(f"- {error}")
|
|
86
|
+
if result.ok:
|
|
87
|
+
print(f" {manifest['identity']['urn']} @ {manifest['identity']['version']}")
|
|
88
|
+
print(f" {payload['package_digest']}")
|
|
89
|
+
return 0 if result.ok else 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def command_inspect(args) -> int:
|
|
93
|
+
path = _target(args.target, args.registry)
|
|
94
|
+
manifest_file, manifest = load_manifest(path)
|
|
95
|
+
result = validate_manifest(manifest_file.parent, manifest)
|
|
96
|
+
payload = {
|
|
97
|
+
"valid": result.ok,
|
|
98
|
+
"identity": manifest.get("identity"),
|
|
99
|
+
"bindings": manifest.get("bindings", []),
|
|
100
|
+
"actions": sorted(manifest.get("actions", {}).keys()),
|
|
101
|
+
"resources": manifest.get("resources", []),
|
|
102
|
+
"requires": manifest.get("requires", []),
|
|
103
|
+
"permissions": manifest.get("permissions", {}),
|
|
104
|
+
"package_digest": package_digest(manifest_file.parent, manifest) if result.ok else None,
|
|
105
|
+
"warnings": result.warnings,
|
|
106
|
+
"errors": result.errors,
|
|
107
|
+
}
|
|
108
|
+
_json(payload)
|
|
109
|
+
return 0 if result.ok else 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def command_run(args) -> int:
|
|
113
|
+
path = _target(args.target, args.registry)
|
|
114
|
+
try:
|
|
115
|
+
payload = json.loads(args.input)
|
|
116
|
+
except json.JSONDecodeError as exc:
|
|
117
|
+
raise PaxletError(f"--input must be valid JSON: {exc}") from exc
|
|
118
|
+
output, receipt, receipt_path = run_action(
|
|
119
|
+
path,
|
|
120
|
+
args.action,
|
|
121
|
+
payload,
|
|
122
|
+
allow_secrets=args.allow_secret,
|
|
123
|
+
write_receipts=not args.no_receipt,
|
|
124
|
+
)
|
|
125
|
+
_json(output)
|
|
126
|
+
if receipt_path:
|
|
127
|
+
print(f"receipt: {receipt_path}", file=sys.stderr)
|
|
128
|
+
if args.show_receipt:
|
|
129
|
+
print(json.dumps(receipt, indent=2, ensure_ascii=False, sort_keys=True), file=sys.stderr)
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def command_resolve(args) -> int:
|
|
134
|
+
locations = resolve(args.urn, args.registry)
|
|
135
|
+
_json({"urn": args.urn, "locations": locations})
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def command_pack(args) -> int:
|
|
140
|
+
output = args.output or (Path(args.target).resolve().name + ".paxlet.zip")
|
|
141
|
+
path = pack_paxlet(_target(args.target, args.registry), output)
|
|
142
|
+
print(path)
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parser() -> argparse.ArgumentParser:
|
|
147
|
+
p = argparse.ArgumentParser(prog="paxlet", description="Paxlet Core 0.1 reference CLI")
|
|
148
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
149
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
150
|
+
|
|
151
|
+
init = sub.add_parser("init", help="create a minimal runnable Paxlet")
|
|
152
|
+
init.add_argument("directory")
|
|
153
|
+
init.add_argument("--name")
|
|
154
|
+
init.add_argument("--urn")
|
|
155
|
+
init.add_argument("--force", action="store_true")
|
|
156
|
+
init.set_defaults(func=command_init)
|
|
157
|
+
|
|
158
|
+
verify = sub.add_parser("verify", help="validate a Paxlet manifest and package")
|
|
159
|
+
verify.add_argument("target")
|
|
160
|
+
verify.add_argument("--registry")
|
|
161
|
+
verify.add_argument("--json", action="store_true")
|
|
162
|
+
verify.set_defaults(func=command_verify)
|
|
163
|
+
|
|
164
|
+
inspect = sub.add_parser("inspect", help="show the public contract of a Paxlet")
|
|
165
|
+
inspect.add_argument("target")
|
|
166
|
+
inspect.add_argument("--registry")
|
|
167
|
+
inspect.set_defaults(func=command_inspect)
|
|
168
|
+
|
|
169
|
+
run = sub.add_parser("run", help="execute one action using JSON stdin/stdout protocol")
|
|
170
|
+
run.add_argument("target")
|
|
171
|
+
run.add_argument("action")
|
|
172
|
+
run.add_argument("--input", default="{}")
|
|
173
|
+
run.add_argument("--registry")
|
|
174
|
+
run.add_argument("--allow-secret", action="append", default=[])
|
|
175
|
+
run.add_argument("--no-receipt", action="store_true")
|
|
176
|
+
run.add_argument("--show-receipt", action="store_true")
|
|
177
|
+
run.set_defaults(func=command_run)
|
|
178
|
+
|
|
179
|
+
resolve_cmd = sub.add_parser("resolve", help="resolve a stable Paxlet URN to locations")
|
|
180
|
+
resolve_cmd.add_argument("urn")
|
|
181
|
+
resolve_cmd.add_argument("--registry")
|
|
182
|
+
resolve_cmd.set_defaults(func=command_resolve)
|
|
183
|
+
|
|
184
|
+
pack_cmd = sub.add_parser("pack", help="create a portable .paxlet.zip archive")
|
|
185
|
+
pack_cmd.add_argument("target")
|
|
186
|
+
pack_cmd.add_argument("-o", "--output")
|
|
187
|
+
pack_cmd.add_argument("--registry")
|
|
188
|
+
pack_cmd.set_defaults(func=command_pack)
|
|
189
|
+
return p
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv: list[str] | None = None) -> int:
|
|
193
|
+
args = parser().parse_args(argv)
|
|
194
|
+
try:
|
|
195
|
+
return args.func(args)
|
|
196
|
+
except PaxletError as exc:
|
|
197
|
+
print(f"paxlet: {exc}", file=sys.stderr)
|
|
198
|
+
return 2
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class PaxletError(Exception):
|
|
2
|
+
"""Base error for the reference runtime."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ManifestError(PaxletError):
|
|
6
|
+
"""Manifest is missing or invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResolutionError(PaxletError):
|
|
10
|
+
"""An identity cannot be resolved to a usable location."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RuntimeError(PaxletError):
|
|
14
|
+
"""An action could not be executed."""
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import ManifestError
|
|
10
|
+
|
|
11
|
+
MANIFEST_NAME = "paxlet.json"
|
|
12
|
+
CORE_VERSION = "0.1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ValidationResult:
|
|
17
|
+
ok: bool
|
|
18
|
+
errors: list[str]
|
|
19
|
+
warnings: list[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def canonical_json(value: Any) -> bytes:
|
|
23
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def manifest_path(path: str | Path) -> Path:
|
|
27
|
+
candidate = Path(path).expanduser()
|
|
28
|
+
if candidate.is_dir():
|
|
29
|
+
candidate = candidate / MANIFEST_NAME
|
|
30
|
+
return candidate.resolve()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_manifest(path: str | Path) -> tuple[Path, dict[str, Any]]:
|
|
34
|
+
target = manifest_path(path)
|
|
35
|
+
if not target.exists():
|
|
36
|
+
raise ManifestError(f"manifest not found: {target}")
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(target.read_text(encoding="utf-8"))
|
|
39
|
+
except json.JSONDecodeError as exc:
|
|
40
|
+
raise ManifestError(f"invalid JSON in {target}: {exc}") from exc
|
|
41
|
+
if not isinstance(data, dict):
|
|
42
|
+
raise ManifestError("manifest root must be an object")
|
|
43
|
+
return target, data
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def safe_path(root: Path, relative: str) -> Path:
|
|
47
|
+
if not isinstance(relative, str) or not relative or relative.startswith(("/", "\\")):
|
|
48
|
+
raise ManifestError(f"path must be a non-empty relative path: {relative!r}")
|
|
49
|
+
resolved_root = root.resolve()
|
|
50
|
+
resolved = (resolved_root / relative).resolve()
|
|
51
|
+
try:
|
|
52
|
+
resolved.relative_to(resolved_root)
|
|
53
|
+
except ValueError as exc:
|
|
54
|
+
raise ManifestError(f"path escapes package root: {relative}") from exc
|
|
55
|
+
return resolved
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_schema_fragment(fragment: Any, label: str, errors: list[str]) -> None:
|
|
59
|
+
if fragment is None:
|
|
60
|
+
return
|
|
61
|
+
if not isinstance(fragment, dict):
|
|
62
|
+
errors.append(f"{label} must be an object")
|
|
63
|
+
return
|
|
64
|
+
if "type" in fragment and fragment["type"] not in {"object", "array", "string", "number", "integer", "boolean", "null"}:
|
|
65
|
+
errors.append(f"{label}.type is not a supported JSON type")
|
|
66
|
+
if fragment.get("type") == "object" and "properties" in fragment and not isinstance(fragment["properties"], dict):
|
|
67
|
+
errors.append(f"{label}.properties must be an object")
|
|
68
|
+
if "required" in fragment and not (isinstance(fragment["required"], list) and all(isinstance(v, str) for v in fragment["required"])):
|
|
69
|
+
errors.append(f"{label}.required must be an array of strings")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_manifest(package_dir: Path, data: dict[str, Any], check_files: bool = True) -> ValidationResult:
|
|
73
|
+
errors: list[str] = []
|
|
74
|
+
warnings: list[str] = []
|
|
75
|
+
|
|
76
|
+
if data.get("paxlet") != CORE_VERSION:
|
|
77
|
+
errors.append(f"paxlet must be {CORE_VERSION!r}")
|
|
78
|
+
|
|
79
|
+
identity = data.get("identity")
|
|
80
|
+
if not isinstance(identity, dict):
|
|
81
|
+
errors.append("identity must be an object")
|
|
82
|
+
else:
|
|
83
|
+
urn = identity.get("urn")
|
|
84
|
+
if not isinstance(urn, str) or not urn.startswith("urn:paxlet:"):
|
|
85
|
+
errors.append("identity.urn must start with 'urn:paxlet:'")
|
|
86
|
+
for field in ("name", "version"):
|
|
87
|
+
if not isinstance(identity.get(field), str) or not identity[field].strip():
|
|
88
|
+
errors.append(f"identity.{field} must be a non-empty string")
|
|
89
|
+
|
|
90
|
+
bindings = data.get("bindings", [])
|
|
91
|
+
if not isinstance(bindings, list) or not all(isinstance(v, str) and ":" in v for v in bindings):
|
|
92
|
+
errors.append("bindings must be an array of URI strings")
|
|
93
|
+
|
|
94
|
+
actions = data.get("actions", {})
|
|
95
|
+
resources = data.get("resources", [])
|
|
96
|
+
if not isinstance(actions, dict):
|
|
97
|
+
errors.append("actions must be an object")
|
|
98
|
+
actions = {}
|
|
99
|
+
if not isinstance(resources, list) or not all(isinstance(v, str) for v in resources):
|
|
100
|
+
errors.append("resources must be an array of relative paths")
|
|
101
|
+
resources = []
|
|
102
|
+
if not actions and not resources:
|
|
103
|
+
errors.append("a Paxlet must contain at least one action or resource")
|
|
104
|
+
|
|
105
|
+
for name, action in actions.items():
|
|
106
|
+
label = f"actions.{name}"
|
|
107
|
+
if not isinstance(name, str) or not name.strip():
|
|
108
|
+
errors.append("action names must be non-empty strings")
|
|
109
|
+
continue
|
|
110
|
+
if not isinstance(action, dict):
|
|
111
|
+
errors.append(f"{label} must be an object")
|
|
112
|
+
continue
|
|
113
|
+
runtime = action.get("runtime")
|
|
114
|
+
if not isinstance(runtime, dict):
|
|
115
|
+
errors.append(f"{label}.runtime must be an object")
|
|
116
|
+
continue
|
|
117
|
+
runtime_type = runtime.get("type")
|
|
118
|
+
if runtime_type not in {"python", "command"}:
|
|
119
|
+
errors.append(f"{label}.runtime.type must be 'python' or 'command'")
|
|
120
|
+
if runtime_type == "python":
|
|
121
|
+
entry = runtime.get("entry")
|
|
122
|
+
if not isinstance(entry, str):
|
|
123
|
+
errors.append(f"{label}.runtime.entry must be a relative path")
|
|
124
|
+
else:
|
|
125
|
+
try:
|
|
126
|
+
path = safe_path(package_dir, entry)
|
|
127
|
+
if check_files and not path.is_file():
|
|
128
|
+
errors.append(f"{label}.runtime.entry not found: {entry}")
|
|
129
|
+
except ManifestError as exc:
|
|
130
|
+
errors.append(str(exc))
|
|
131
|
+
if runtime_type == "command":
|
|
132
|
+
argv = runtime.get("argv")
|
|
133
|
+
if not isinstance(argv, list) or not argv or not all(isinstance(v, str) for v in argv):
|
|
134
|
+
errors.append(f"{label}.runtime.argv must be a non-empty string array")
|
|
135
|
+
_validate_schema_fragment(action.get("input"), f"{label}.input", errors)
|
|
136
|
+
_validate_schema_fragment(action.get("output"), f"{label}.output", errors)
|
|
137
|
+
|
|
138
|
+
for resource in resources:
|
|
139
|
+
try:
|
|
140
|
+
path = safe_path(package_dir, resource)
|
|
141
|
+
if check_files and not path.exists():
|
|
142
|
+
errors.append(f"resource not found: {resource}")
|
|
143
|
+
except ManifestError as exc:
|
|
144
|
+
errors.append(str(exc))
|
|
145
|
+
|
|
146
|
+
requires = data.get("requires", [])
|
|
147
|
+
if not isinstance(requires, list) or not all(isinstance(v, str) and v.startswith("urn:paxlet:") for v in requires):
|
|
148
|
+
errors.append("requires must be an array of Paxlet URNs")
|
|
149
|
+
|
|
150
|
+
permissions = data.get("permissions", {})
|
|
151
|
+
if not isinstance(permissions, dict):
|
|
152
|
+
errors.append("permissions must be an object")
|
|
153
|
+
else:
|
|
154
|
+
fs = permissions.get("filesystem", {})
|
|
155
|
+
if not isinstance(fs, dict):
|
|
156
|
+
errors.append("permissions.filesystem must be an object")
|
|
157
|
+
else:
|
|
158
|
+
for mode in ("read", "write"):
|
|
159
|
+
vals = fs.get(mode, [])
|
|
160
|
+
if not isinstance(vals, list) or not all(isinstance(v, str) for v in vals):
|
|
161
|
+
errors.append(f"permissions.filesystem.{mode} must be an array of strings")
|
|
162
|
+
for key in ("network", "secrets"):
|
|
163
|
+
vals = permissions.get(key, [])
|
|
164
|
+
if not isinstance(vals, list) or not all(isinstance(v, str) for v in vals):
|
|
165
|
+
errors.append(f"permissions.{key} must be an array of strings")
|
|
166
|
+
|
|
167
|
+
provenance = data.get("provenance", {})
|
|
168
|
+
if provenance is not None and not isinstance(provenance, dict):
|
|
169
|
+
errors.append("provenance must be an object")
|
|
170
|
+
|
|
171
|
+
if permissions.get("network"):
|
|
172
|
+
warnings.append("reference runtime declares network permissions but does not enforce a network sandbox")
|
|
173
|
+
if isinstance(permissions.get("filesystem"), dict) and permissions["filesystem"].get("write"):
|
|
174
|
+
warnings.append("reference runtime declares filesystem write permissions but does not enforce an OS sandbox")
|
|
175
|
+
|
|
176
|
+
return ValidationResult(ok=not errors, errors=errors, warnings=warnings)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def collect_package_files(package_dir: Path, data: dict[str, Any]) -> list[Path]:
|
|
180
|
+
root = package_dir.resolve()
|
|
181
|
+
files: set[Path] = {root / MANIFEST_NAME}
|
|
182
|
+
for action in data.get("actions", {}).values():
|
|
183
|
+
runtime = action.get("runtime", {}) if isinstance(action, dict) else {}
|
|
184
|
+
if runtime.get("type") == "python" and isinstance(runtime.get("entry"), str):
|
|
185
|
+
files.add(safe_path(root, runtime["entry"]))
|
|
186
|
+
for relative in data.get("resources", []):
|
|
187
|
+
path = safe_path(root, relative)
|
|
188
|
+
if path.is_dir():
|
|
189
|
+
for child in path.rglob("*"):
|
|
190
|
+
if child.is_file():
|
|
191
|
+
files.add(child.resolve())
|
|
192
|
+
else:
|
|
193
|
+
files.add(path)
|
|
194
|
+
return sorted(files, key=lambda p: p.relative_to(root).as_posix())
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def package_digest(package_dir: Path, data: dict[str, Any]) -> str:
|
|
198
|
+
root = package_dir.resolve()
|
|
199
|
+
digest = hashlib.sha256()
|
|
200
|
+
for path in collect_package_files(root, data):
|
|
201
|
+
relative = path.relative_to(root).as_posix().encode("utf-8")
|
|
202
|
+
digest.update(len(relative).to_bytes(4, "big"))
|
|
203
|
+
digest.update(relative)
|
|
204
|
+
content = path.read_bytes()
|
|
205
|
+
digest.update(len(content).to_bytes(8, "big"))
|
|
206
|
+
digest.update(content)
|
|
207
|
+
return "sha256:" + digest.hexdigest()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import zipfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import ManifestError
|
|
8
|
+
from .manifest import collect_package_files, load_manifest, package_digest, validate_manifest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pack(path: str | Path, output: str | Path) -> Path:
|
|
12
|
+
manifest_file, manifest = load_manifest(path)
|
|
13
|
+
root = manifest_file.parent
|
|
14
|
+
result = validate_manifest(root, manifest)
|
|
15
|
+
if not result.ok:
|
|
16
|
+
raise ManifestError("cannot pack invalid Paxlet: " + "; ".join(result.errors))
|
|
17
|
+
out = Path(output).expanduser().resolve()
|
|
18
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
metadata = {
|
|
20
|
+
"format": "paxlet-archive/0.1",
|
|
21
|
+
"identity": manifest["identity"],
|
|
22
|
+
"package_digest": package_digest(root, manifest),
|
|
23
|
+
}
|
|
24
|
+
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
25
|
+
for file in collect_package_files(root, manifest):
|
|
26
|
+
zf.write(file, file.relative_to(root).as_posix())
|
|
27
|
+
info = zipfile.ZipInfo("PAXLET-METADATA.json", date_time=(1980, 1, 1, 0, 0, 0))
|
|
28
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
29
|
+
zf.writestr(info, json.dumps(metadata, indent=2, sort_keys=True) + "\n")
|
|
30
|
+
return out
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import socket
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .manifest import canonical_json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def utc_now() -> str:
|
|
14
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def value_digest(value: Any) -> str:
|
|
18
|
+
return "sha256:" + hashlib.sha256(canonical_json(value)).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def new_receipt(*, manifest: dict[str, Any], package_digest: str, action: str, payload: Any, started: str, finished: str, exit_code: int, output: Any, secret_names: list[str]) -> dict[str, Any]:
|
|
22
|
+
identity = manifest["identity"]
|
|
23
|
+
return {
|
|
24
|
+
"receipt": "paxlet/0.1",
|
|
25
|
+
"identity": {"urn": identity["urn"], "version": identity["version"]},
|
|
26
|
+
"package_digest": package_digest,
|
|
27
|
+
"action": action,
|
|
28
|
+
"input_digest": value_digest(payload),
|
|
29
|
+
"output_digest": value_digest(output) if exit_code == 0 else None,
|
|
30
|
+
"runtime": "paxlet-python-reference/0.1.0",
|
|
31
|
+
"node": socket.gethostname(),
|
|
32
|
+
"started_at": started,
|
|
33
|
+
"finished_at": finished,
|
|
34
|
+
"exit_code": exit_code,
|
|
35
|
+
"granted_secret_names": sorted(secret_names),
|
|
36
|
+
"output": output,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_receipt(package_dir: Path, receipt: dict[str, Any]) -> Path:
|
|
41
|
+
directory = package_dir / ".paxlet" / "receipts"
|
|
42
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
safe_time = receipt["finished_at"].replace(":", "-").replace(".", "-")
|
|
44
|
+
path = directory / f"{safe_time}-{receipt['action']}.json"
|
|
45
|
+
path.write_text(json.dumps(receipt, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
46
|
+
return path
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import unquote, urlparse
|
|
6
|
+
|
|
7
|
+
from .errors import ResolutionError
|
|
8
|
+
|
|
9
|
+
DEFAULT_REGISTRY = Path(__file__).resolve().parents[1] / "registry" / "local.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_registry(path: str | Path | None = None) -> tuple[Path, dict]:
|
|
13
|
+
target = Path(path).expanduser().resolve() if path else DEFAULT_REGISTRY
|
|
14
|
+
if not target.exists():
|
|
15
|
+
raise ResolutionError(f"registry not found: {target}")
|
|
16
|
+
try:
|
|
17
|
+
data = json.loads(target.read_text(encoding="utf-8"))
|
|
18
|
+
except json.JSONDecodeError as exc:
|
|
19
|
+
raise ResolutionError(f"invalid registry JSON: {exc}") from exc
|
|
20
|
+
if data.get("registry") != "paxlet/0.1" or not isinstance(data.get("entries"), dict):
|
|
21
|
+
raise ResolutionError("unsupported registry format")
|
|
22
|
+
return target, data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve(urn: str, registry_path: str | Path | None = None) -> list[dict]:
|
|
26
|
+
target, data = load_registry(registry_path)
|
|
27
|
+
entries = data["entries"].get(urn, [])
|
|
28
|
+
if not entries:
|
|
29
|
+
raise ResolutionError(f"URN not found: {urn}")
|
|
30
|
+
out: list[dict] = []
|
|
31
|
+
for item in entries:
|
|
32
|
+
if not isinstance(item, dict) or not isinstance(item.get("uri"), str):
|
|
33
|
+
continue
|
|
34
|
+
uri = item["uri"]
|
|
35
|
+
parsed = urlparse(uri)
|
|
36
|
+
if parsed.scheme == "file" and not parsed.netloc:
|
|
37
|
+
raw_path = unquote(parsed.path)
|
|
38
|
+
p = Path(raw_path)
|
|
39
|
+
if not p.is_absolute():
|
|
40
|
+
p = (target.parent / p).resolve()
|
|
41
|
+
uri = p.as_uri()
|
|
42
|
+
out.append({**item, "uri": uri})
|
|
43
|
+
if not out:
|
|
44
|
+
raise ResolutionError(f"URN has no usable locations: {urn}")
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def uri_to_path(uri: str) -> Path:
|
|
49
|
+
parsed = urlparse(uri)
|
|
50
|
+
if parsed.scheme != "file":
|
|
51
|
+
raise ResolutionError(f"reference runtime can execute only file: locations, got {parsed.scheme or 'no scheme'}")
|
|
52
|
+
if parsed.netloc not in ("", "localhost"):
|
|
53
|
+
raise ResolutionError("remote file:// hosts are not supported")
|
|
54
|
+
return Path(unquote(parsed.path)).resolve()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_to_path(urn: str, registry_path: str | Path | None = None) -> Path:
|
|
58
|
+
locations = resolve(urn, registry_path)
|
|
59
|
+
for item in locations:
|
|
60
|
+
try:
|
|
61
|
+
return uri_to_path(item["uri"])
|
|
62
|
+
except ResolutionError:
|
|
63
|
+
continue
|
|
64
|
+
raise ResolutionError(f"no executable file: location for {urn}")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import RuntimeError
|
|
11
|
+
from .manifest import load_manifest, package_digest, validate_manifest
|
|
12
|
+
from .receipt import new_receipt, utc_now, write_receipt
|
|
13
|
+
from .schema import validate_value
|
|
14
|
+
|
|
15
|
+
SAFE_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "TMP", "TEMP", "SystemRoot", "WINDIR"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _environment(manifest: dict[str, Any], package_dir: Path, action: str, allow_secrets: list[str]) -> tuple[dict[str, str], list[str]]:
|
|
19
|
+
env = {key: os.environ[key] for key in SAFE_ENV_KEYS if key in os.environ}
|
|
20
|
+
declared = set(manifest.get("permissions", {}).get("secrets", []))
|
|
21
|
+
granted: list[str] = []
|
|
22
|
+
for name in allow_secrets:
|
|
23
|
+
if name not in declared:
|
|
24
|
+
raise RuntimeError(f"secret {name!r} is not declared by the Paxlet")
|
|
25
|
+
if name not in os.environ:
|
|
26
|
+
raise RuntimeError(f"secret {name!r} is not present in the current environment")
|
|
27
|
+
env[name] = os.environ[name]
|
|
28
|
+
granted.append(name)
|
|
29
|
+
identity = manifest["identity"]
|
|
30
|
+
env.update({
|
|
31
|
+
"PAXLET_URN": identity["urn"],
|
|
32
|
+
"PAXLET_VERSION": identity["version"],
|
|
33
|
+
"PAXLET_ACTION": action,
|
|
34
|
+
"PAXLET_ROOT": str(package_dir),
|
|
35
|
+
})
|
|
36
|
+
return env, granted
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_action(path: str | Path, action_name: str, payload: Any, *, allow_secrets: list[str] | None = None, write_receipts: bool = True) -> tuple[Any, dict[str, Any], Path | None]:
|
|
40
|
+
manifest_file, manifest = load_manifest(path)
|
|
41
|
+
package_dir = manifest_file.parent
|
|
42
|
+
result = validate_manifest(package_dir, manifest)
|
|
43
|
+
if not result.ok:
|
|
44
|
+
raise RuntimeError("invalid Paxlet: " + "; ".join(result.errors))
|
|
45
|
+
|
|
46
|
+
action = manifest.get("actions", {}).get(action_name)
|
|
47
|
+
if not isinstance(action, dict):
|
|
48
|
+
raise RuntimeError(f"action not found: {action_name}")
|
|
49
|
+
validate_value(payload, action.get("input"), "input")
|
|
50
|
+
|
|
51
|
+
runtime = action["runtime"]
|
|
52
|
+
if runtime["type"] == "python":
|
|
53
|
+
command = [sys.executable, runtime["entry"]]
|
|
54
|
+
else:
|
|
55
|
+
command = runtime["argv"]
|
|
56
|
+
|
|
57
|
+
env, granted = _environment(manifest, package_dir, action_name, allow_secrets or [])
|
|
58
|
+
started = utc_now()
|
|
59
|
+
completed = subprocess.run(
|
|
60
|
+
command,
|
|
61
|
+
cwd=package_dir,
|
|
62
|
+
input=json.dumps(payload, ensure_ascii=False),
|
|
63
|
+
text=True,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
env=env,
|
|
66
|
+
check=False,
|
|
67
|
+
)
|
|
68
|
+
finished = utc_now()
|
|
69
|
+
|
|
70
|
+
if completed.returncode != 0:
|
|
71
|
+
failure = {
|
|
72
|
+
"error": "action_failed",
|
|
73
|
+
"message": completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}",
|
|
74
|
+
}
|
|
75
|
+
receipt = new_receipt(
|
|
76
|
+
manifest=manifest,
|
|
77
|
+
package_digest=package_digest(package_dir, manifest),
|
|
78
|
+
action=action_name,
|
|
79
|
+
payload=payload,
|
|
80
|
+
started=started,
|
|
81
|
+
finished=finished,
|
|
82
|
+
exit_code=completed.returncode,
|
|
83
|
+
output=failure,
|
|
84
|
+
secret_names=granted,
|
|
85
|
+
)
|
|
86
|
+
receipt_path = write_receipt(package_dir, receipt) if write_receipts else None
|
|
87
|
+
raise RuntimeError(f"action failed ({completed.returncode}): {failure['message']}; receipt={receipt_path}")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
output = json.loads(completed.stdout)
|
|
91
|
+
except json.JSONDecodeError as exc:
|
|
92
|
+
raise RuntimeError(f"action must write exactly one JSON value to stdout; got: {completed.stdout[:200]!r}") from exc
|
|
93
|
+
validate_value(output, action.get("output"), "output")
|
|
94
|
+
|
|
95
|
+
receipt = new_receipt(
|
|
96
|
+
manifest=manifest,
|
|
97
|
+
package_digest=package_digest(package_dir, manifest),
|
|
98
|
+
action=action_name,
|
|
99
|
+
payload=payload,
|
|
100
|
+
started=started,
|
|
101
|
+
finished=finished,
|
|
102
|
+
exit_code=0,
|
|
103
|
+
output=output,
|
|
104
|
+
secret_names=granted,
|
|
105
|
+
)
|
|
106
|
+
receipt_path = write_receipt(package_dir, receipt) if write_receipts else None
|
|
107
|
+
return output, receipt, receipt_path
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .errors import RuntimeError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _matches_type(value: Any, expected: str) -> bool:
|
|
9
|
+
if expected == "object":
|
|
10
|
+
return isinstance(value, dict)
|
|
11
|
+
if expected == "array":
|
|
12
|
+
return isinstance(value, list)
|
|
13
|
+
if expected == "string":
|
|
14
|
+
return isinstance(value, str)
|
|
15
|
+
if expected == "number":
|
|
16
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
17
|
+
if expected == "integer":
|
|
18
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
19
|
+
if expected == "boolean":
|
|
20
|
+
return isinstance(value, bool)
|
|
21
|
+
if expected == "null":
|
|
22
|
+
return value is None
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_value(value: Any, schema: dict[str, Any] | None, label: str = "value") -> None:
|
|
27
|
+
"""Validate the intentionally small JSON-Schema subset in Core 0.1."""
|
|
28
|
+
if not schema:
|
|
29
|
+
return
|
|
30
|
+
expected = schema.get("type")
|
|
31
|
+
if expected and not _matches_type(value, expected):
|
|
32
|
+
raise RuntimeError(f"{label} must be {expected}")
|
|
33
|
+
if expected == "object" and isinstance(value, dict):
|
|
34
|
+
required = schema.get("required", [])
|
|
35
|
+
for key in required:
|
|
36
|
+
if key not in value:
|
|
37
|
+
raise RuntimeError(f"{label}.{key} is required")
|
|
38
|
+
for key, child_schema in schema.get("properties", {}).items():
|
|
39
|
+
if key in value and isinstance(child_schema, dict):
|
|
40
|
+
validate_value(value[key], child_schema, f"{label}.{key}")
|
|
41
|
+
if expected == "array" and isinstance(value, list) and isinstance(schema.get("items"), dict):
|
|
42
|
+
for index, item in enumerate(value):
|
|
43
|
+
validate_value(item, schema["items"], f"{label}[{index}]")
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paxlet
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Small, addressable packages of resources and actions for connected systems.
|
|
5
|
+
Author: Paxlet contributors
|
|
6
|
+
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# paxlet
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## AI Cost Tracking
|
|
15
|
+
|
|
16
|
+
   
|
|
17
|
+
  
|
|
18
|
+
|
|
19
|
+
- 🤖 **LLM usage:** $0.0466 (1 commits)
|
|
20
|
+
- 👤 **Human dev:** ~$100 (1.0h @ $100/h, 30min dedup)
|
|
21
|
+
|
|
22
|
+
Generated on 2026-09-22 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
**Build small. Connect everything.**
|
|
29
|
+
|
|
30
|
+
Paxlet Core is a deliberately small contract for packaging **resources and actions** as addressable capabilities.
|
|
31
|
+
|
|
32
|
+
A Paxlet has a stable identity, a version, clear inputs/outputs, explicit dependencies and requested permissions. A node can resolve it, verify it, run it and produce a receipt.
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
URN identity
|
|
36
|
+
│
|
|
37
|
+
▼
|
|
38
|
+
┌──────────── Paxlet ────────────┐
|
|
39
|
+
│ resources │
|
|
40
|
+
│ actions │
|
|
41
|
+
│ dependencies │
|
|
42
|
+
│ permissions │
|
|
43
|
+
│ provenance │
|
|
44
|
+
└───────────────────────────────┘
|
|
45
|
+
│
|
|
46
|
+
▼
|
|
47
|
+
URI locations → Node → Receipt
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Start in five minutes
|
|
51
|
+
|
|
52
|
+
Requires Python 3.11+ and no third-party runtime dependencies. You can run it directly after cloning:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
./bin/paxlet init /tmp/my-first
|
|
56
|
+
./bin/paxlet run /tmp/my-first hello --input '{"name":"Ada"}'
|
|
57
|
+
|
|
58
|
+
./bin/paxlet verify examples/hello
|
|
59
|
+
./bin/paxlet run examples/hello hello --input '{"name":"Ada"}'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Optional developer install:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python -m venv .venv
|
|
66
|
+
. .venv/bin/activate
|
|
67
|
+
pip install -e .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or address the same capability by identity:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
./bin/paxlet resolve urn:paxlet:example:hello
|
|
74
|
+
./bin/paxlet run urn:paxlet:example:hello hello --input '{"name":"Ada"}'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Try a science-oriented example:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
./bin/paxlet run examples/science-fasta-gc analyze --input '{}'
|
|
81
|
+
./bin/paxlet run examples/statistics-mean mean --input '{"values":[1,2,4,8]}'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## CLI
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
paxlet init DIRECTORY
|
|
88
|
+
paxlet verify PATH|URN
|
|
89
|
+
paxlet inspect PATH|URN
|
|
90
|
+
paxlet resolve URN
|
|
91
|
+
paxlet run PATH|URN ACTION --input JSON
|
|
92
|
+
paxlet pack PATH|URN -o package.paxlet.zip
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Why Core is small
|
|
96
|
+
|
|
97
|
+
Paxlet is not an AI-agent framework. It is the portable unit that larger systems can use.
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
Paxlet Core package + identity + contract
|
|
101
|
+
Paxlet Node resolution + grants + execution + receipts
|
|
102
|
+
Paxlet Network registries + peers + distribution
|
|
103
|
+
Taskand planning + orchestration + adaptive-system showcase
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Taskand can demonstrate planners, processes, federation, mesh, Digital Twin, MCP, governance and recovery without making those features mandatory for every Paxlet implementation.
|
|
107
|
+
|
|
108
|
+
## Repository map
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
SPEC.md normative Core 0.1 draft
|
|
112
|
+
schemas/ manifest + receipt schemas
|
|
113
|
+
paxlet/ tiny Python reference runtime
|
|
114
|
+
examples/ five understandable capabilities
|
|
115
|
+
registry/local.json local URN → URI resolver example
|
|
116
|
+
conformance/ implementation compatibility runner
|
|
117
|
+
docs/ addressing, security, receipts, layers
|
|
118
|
+
integrations/taskand/ how Taskand fits as the showcase
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Conformance
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
python -m unittest discover -s tests -v
|
|
125
|
+
python conformance/run.py
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Important security note
|
|
129
|
+
|
|
130
|
+
The reference runtime is intentionally educational. It validates package paths and exposes secrets only after explicit user grant, but it **does not enforce filesystem/network permissions with an OS sandbox**. Production nodes should use containers, VMs or OS policy for enforcement. See `docs/security.md`.
|
|
131
|
+
|
|
132
|
+
## Status
|
|
133
|
+
|
|
134
|
+
Core 0.1 is a draft intended to make the idea testable and implementable by independent runtimes. See `ROADMAP.md`.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
Licensed under Apache-2.0.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
paxlet/__init__.py
|
|
4
|
+
paxlet/__main__.py
|
|
5
|
+
paxlet/cli.py
|
|
6
|
+
paxlet/errors.py
|
|
7
|
+
paxlet/manifest.py
|
|
8
|
+
paxlet/packing.py
|
|
9
|
+
paxlet/receipt.py
|
|
10
|
+
paxlet/resolver.py
|
|
11
|
+
paxlet/runtime.py
|
|
12
|
+
paxlet/schema.py
|
|
13
|
+
paxlet.egg-info/PKG-INFO
|
|
14
|
+
paxlet.egg-info/SOURCES.txt
|
|
15
|
+
paxlet.egg-info/dependency_links.txt
|
|
16
|
+
paxlet.egg-info/entry_points.txt
|
|
17
|
+
paxlet.egg-info/top_level.txt
|
|
18
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
paxlet
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "paxlet"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Small, addressable packages of resources and actions for connected systems."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{name = "Paxlet contributors"}, {name = "Tom Sapletta",email = "tom@sapletta.com"}]
|
|
12
|
+
dependencies = []
|
|
13
|
+
license = "Apache-2.0"
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
paxlet = "paxlet.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["paxlet"]
|
|
20
|
+
|
|
21
|
+
[tool.costs]
|
|
22
|
+
# AI Cost tracking configuration
|
|
23
|
+
badge = true
|
|
24
|
+
update_readme = true
|
|
25
|
+
readme_path = "README.md"
|
|
26
|
+
default_model = "openrouter/qwen/qwen3-coder-next"
|
|
27
|
+
analysis_mode = "byok"
|
|
28
|
+
full_history = true
|
|
29
|
+
max_commits = 500
|
|
30
|
+
|
|
31
|
+
# Cost thresholds for badge colors (USD)
|
|
32
|
+
badge_color_thresholds = { low = 1.0, medium = 5.0, high = 10.0, critical = 50.0 }
|
paxlet-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from paxlet.errors import ManifestError
|
|
9
|
+
from paxlet.manifest import load_manifest, validate_manifest
|
|
10
|
+
from paxlet.packing import pack
|
|
11
|
+
from paxlet.resolver import resolve_to_path
|
|
12
|
+
from paxlet.runtime import run_action
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CoreTests(unittest.TestCase):
|
|
18
|
+
def test_init_creates_runnable_package(self):
|
|
19
|
+
with tempfile.TemporaryDirectory() as td:
|
|
20
|
+
target = Path(td) / "first"
|
|
21
|
+
created = subprocess.run([sys.executable, "-m", "paxlet.cli", "init", str(target)], cwd=ROOT, text=True, capture_output=True)
|
|
22
|
+
self.assertEqual(created.returncode, 0, created.stderr)
|
|
23
|
+
output, _, _ = run_action(target, "hello", {"name": "Ada"}, write_receipts=False)
|
|
24
|
+
self.assertEqual(output["message"], "Hello, Ada!")
|
|
25
|
+
|
|
26
|
+
def test_hello_validates(self):
|
|
27
|
+
mf, data = load_manifest(ROOT / "examples/hello")
|
|
28
|
+
self.assertTrue(validate_manifest(mf.parent, data).ok)
|
|
29
|
+
|
|
30
|
+
def test_path_escape_rejected(self):
|
|
31
|
+
mf, data = load_manifest(ROOT / "tests/fixtures/invalid-escape")
|
|
32
|
+
result = validate_manifest(mf.parent, data)
|
|
33
|
+
self.assertFalse(result.ok)
|
|
34
|
+
self.assertTrue(any("escapes package root" in e for e in result.errors))
|
|
35
|
+
|
|
36
|
+
def test_run_hello_and_receipt(self):
|
|
37
|
+
output, receipt, _ = run_action(ROOT / "examples/hello", "hello", {"name": "Ada"}, write_receipts=False)
|
|
38
|
+
self.assertEqual(output, {"message": "Hello, Ada!"})
|
|
39
|
+
self.assertEqual(receipt["identity"]["urn"], "urn:paxlet:example:hello")
|
|
40
|
+
self.assertTrue(receipt["package_digest"].startswith("sha256:"))
|
|
41
|
+
|
|
42
|
+
def test_resolver(self):
|
|
43
|
+
path = resolve_to_path("urn:paxlet:science:fasta-gc", ROOT / "registry/local.json")
|
|
44
|
+
self.assertEqual(path, (ROOT / "examples/science-fasta-gc").resolve())
|
|
45
|
+
|
|
46
|
+
def test_pack(self):
|
|
47
|
+
with tempfile.TemporaryDirectory() as td:
|
|
48
|
+
out = pack(ROOT / "examples/hello", Path(td) / "hello.paxlet.zip")
|
|
49
|
+
self.assertTrue(out.exists())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
unittest.main()
|