urun-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- urun/__init__.py +22 -0
- urun/api.py +189 -0
- urun/cli.py +124 -0
- urun/deps.py +80 -0
- urun/discovery.py +170 -0
- urun/errors.py +10 -0
- urun/manifest.py +78 -0
- urun_cli-0.1.0.dist-info/METADATA +118 -0
- urun_cli-0.1.0.dist-info/RECORD +12 -0
- urun_cli-0.1.0.dist-info/WHEEL +4 -0
- urun_cli-0.1.0.dist-info/entry_points.txt +3 -0
- urun_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
urun/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _source_tree_version() -> str:
|
|
9
|
+
for parent in Path(__file__).resolve().parents:
|
|
10
|
+
pyproject = parent / "pyproject.toml"
|
|
11
|
+
if pyproject.is_file():
|
|
12
|
+
data = tomllib.loads(pyproject.read_text())
|
|
13
|
+
project_version = (data.get("project") or {}).get("version")
|
|
14
|
+
if isinstance(project_version, str) and project_version:
|
|
15
|
+
return project_version
|
|
16
|
+
return "0.0.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
__version__ = version("urun-cli")
|
|
21
|
+
except PackageNotFoundError: # pragma: no cover - source tree fallback
|
|
22
|
+
__version__ = _source_tree_version()
|
urun/api.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .errors import ApiError, UrunError
|
|
13
|
+
from .manifest import FileBlob, hash_file
|
|
14
|
+
|
|
15
|
+
DEFAULT_API_URL = "https://api.urun.sh/v1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiClient:
|
|
19
|
+
def __init__(self, base_url: str, api_key: str, timeout: float = 30.0, attempts: int = 3):
|
|
20
|
+
require_safe_url(base_url, "API URL")
|
|
21
|
+
self.base_url = base_url.rstrip("/")
|
|
22
|
+
self.api_key = api_key
|
|
23
|
+
self.timeout = timeout
|
|
24
|
+
self.attempts = attempts
|
|
25
|
+
|
|
26
|
+
def register_manifest(self, manifest: dict[str, Any]) -> dict[str, Any]:
|
|
27
|
+
return self._json("POST", "/register-manifest", manifest)
|
|
28
|
+
|
|
29
|
+
def finalize(self, manifest_hash: str) -> dict[str, Any]:
|
|
30
|
+
return self._json("POST", f"/finalize/{manifest_hash}", None)
|
|
31
|
+
|
|
32
|
+
def deployment_status(self, manifest_hash: str) -> dict[str, Any]:
|
|
33
|
+
return self._json("GET", f"/deployment-status/{manifest_hash}", None)
|
|
34
|
+
|
|
35
|
+
def _json(self, method: str, path: str, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
36
|
+
data = (
|
|
37
|
+
None
|
|
38
|
+
if body is None
|
|
39
|
+
else json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
40
|
+
)
|
|
41
|
+
payload = b""
|
|
42
|
+
for attempt in range(1, self.attempts + 1):
|
|
43
|
+
req = urllib.request.Request(
|
|
44
|
+
self.base_url + path,
|
|
45
|
+
data=data,
|
|
46
|
+
method=method,
|
|
47
|
+
headers={
|
|
48
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
49
|
+
"Accept": "application/json",
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"User-Agent": f"urun-cli/{__version__}",
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
56
|
+
payload = resp.read()
|
|
57
|
+
break
|
|
58
|
+
except urllib.error.HTTPError as exc:
|
|
59
|
+
payload = exc.read()
|
|
60
|
+
code = "http_error"
|
|
61
|
+
message = payload.decode("utf-8", "replace") or exc.reason
|
|
62
|
+
try:
|
|
63
|
+
parsed = json.loads(payload.decode("utf-8"))
|
|
64
|
+
err = parsed.get("error") or {}
|
|
65
|
+
code = err.get("code") or code
|
|
66
|
+
message = err.get("message") or message
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
if exc.code not in {429, 500, 502, 503, 504} or attempt == self.attempts:
|
|
70
|
+
raise ApiError(exc.code, code, message) from exc
|
|
71
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
72
|
+
if attempt == self.attempts:
|
|
73
|
+
reason = getattr(exc, "reason", exc)
|
|
74
|
+
raise UrunError(f"network error: {reason}") from exc
|
|
75
|
+
time.sleep(0.25 * attempt)
|
|
76
|
+
if not payload:
|
|
77
|
+
return {}
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(payload.decode("utf-8"))
|
|
80
|
+
except json.JSONDecodeError as exc:
|
|
81
|
+
raise UrunError("invalid JSON response from urun API") from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def upload_missing_blobs(
|
|
85
|
+
missing_blobs: list[dict[str, Any]], by_sha: dict[str, FileBlob], concurrency: int = 8
|
|
86
|
+
) -> None:
|
|
87
|
+
if not missing_blobs:
|
|
88
|
+
return
|
|
89
|
+
if concurrency < 1:
|
|
90
|
+
raise UrunError("upload concurrency must be at least 1")
|
|
91
|
+
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
92
|
+
futures = []
|
|
93
|
+
seen: set[str] = set()
|
|
94
|
+
for item in missing_blobs:
|
|
95
|
+
if not isinstance(item, dict):
|
|
96
|
+
raise UrunError("invalid missing_blobs item from server")
|
|
97
|
+
sha = item.get("sha256")
|
|
98
|
+
upload_url = item.get("upload_url")
|
|
99
|
+
if not isinstance(sha, str) or not isinstance(upload_url, str):
|
|
100
|
+
raise UrunError("invalid missing_blobs item from server")
|
|
101
|
+
if sha in seen:
|
|
102
|
+
continue
|
|
103
|
+
seen.add(sha)
|
|
104
|
+
if sha not in by_sha:
|
|
105
|
+
raise UrunError(f"server requested unknown blob: {sha}")
|
|
106
|
+
futures.append(executor.submit(upload_blob, upload_url, by_sha[sha]))
|
|
107
|
+
for fut in as_completed(futures):
|
|
108
|
+
fut.result()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def upload_blob(upload_url: str, blob: FileBlob, attempts: int = 3) -> None:
|
|
112
|
+
require_safe_url(upload_url, "upload URL")
|
|
113
|
+
current_sha, current_size = hash_file(blob.source)
|
|
114
|
+
if current_sha != blob.sha256 or current_size != blob.size:
|
|
115
|
+
raise UrunError(f"file changed after manifest generation: {blob.path}")
|
|
116
|
+
last_error: Exception | None = None
|
|
117
|
+
for attempt in range(1, attempts + 1):
|
|
118
|
+
try:
|
|
119
|
+
with blob.source.open("rb") as f:
|
|
120
|
+
req = urllib.request.Request(
|
|
121
|
+
upload_url,
|
|
122
|
+
data=f,
|
|
123
|
+
method="PUT",
|
|
124
|
+
headers={
|
|
125
|
+
"Content-Length": str(blob.size),
|
|
126
|
+
"If-None-Match": "*",
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
130
|
+
if resp.status >= 300:
|
|
131
|
+
raise UrunError(f"upload failed for {blob.path}: HTTP {resp.status}")
|
|
132
|
+
return
|
|
133
|
+
except urllib.error.HTTPError as exc:
|
|
134
|
+
if exc.code == 412:
|
|
135
|
+
# Another identical deploy may have won the content-addressed race.
|
|
136
|
+
return
|
|
137
|
+
if exc.code < 500 or attempt == attempts:
|
|
138
|
+
raise UrunError(
|
|
139
|
+
f"upload failed for {blob.path}: HTTP {exc.code} {exc.reason}"
|
|
140
|
+
) from exc
|
|
141
|
+
last_error = exc
|
|
142
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
143
|
+
if attempt == attempts:
|
|
144
|
+
reason = getattr(exc, "reason", exc)
|
|
145
|
+
raise UrunError(f"upload failed for {blob.path}: {reason}") from exc
|
|
146
|
+
last_error = exc
|
|
147
|
+
time.sleep(0.25 * attempt)
|
|
148
|
+
if last_error is not None:
|
|
149
|
+
raise UrunError(f"upload failed for {blob.path}: {last_error}") from last_error
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def require_safe_url(raw_url: str, label: str) -> None:
|
|
153
|
+
parsed = urllib.parse.urlparse(raw_url)
|
|
154
|
+
host = (parsed.hostname or "").lower()
|
|
155
|
+
if parsed.scheme == "https":
|
|
156
|
+
return
|
|
157
|
+
if parsed.scheme == "http" and host in {"127.0.0.1", "localhost", "::1"}:
|
|
158
|
+
return
|
|
159
|
+
raise UrunError(f"{label} must use HTTPS except for localhost development")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def poll_until_done(
|
|
163
|
+
client: ApiClient,
|
|
164
|
+
manifest_hash: str,
|
|
165
|
+
interval: float = 2.0,
|
|
166
|
+
timeout: float = 1800.0,
|
|
167
|
+
not_found_grace: float = 30.0,
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
deadline = time.time() + timeout
|
|
170
|
+
not_found_deadline = time.time() + min(not_found_grace, timeout)
|
|
171
|
+
last_state = "unknown"
|
|
172
|
+
while True:
|
|
173
|
+
try:
|
|
174
|
+
status = client.deployment_status(manifest_hash)
|
|
175
|
+
except ApiError as exc:
|
|
176
|
+
if exc.status == 404 and time.time() < not_found_deadline:
|
|
177
|
+
last_state = "not_found"
|
|
178
|
+
time.sleep(interval)
|
|
179
|
+
continue
|
|
180
|
+
raise
|
|
181
|
+
state = status.get("status")
|
|
182
|
+
last_state = str(state)
|
|
183
|
+
if state in {"ready", "failed"}:
|
|
184
|
+
return status
|
|
185
|
+
if time.time() >= deadline:
|
|
186
|
+
raise UrunError(
|
|
187
|
+
f"timed out waiting for deployment {manifest_hash} (last status: {last_state})"
|
|
188
|
+
)
|
|
189
|
+
time.sleep(interval)
|
urun/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .api import DEFAULT_API_URL, ApiClient, poll_until_done, upload_missing_blobs
|
|
13
|
+
from .deps import resolve_deps
|
|
14
|
+
from .discovery import derive_app_name, discover_main_files
|
|
15
|
+
from .errors import UrunError
|
|
16
|
+
from .manifest import manifest_hash, unique_blobs
|
|
17
|
+
|
|
18
|
+
API_KEY_RE = re.compile(r"^urun_[0-9a-f]{32}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
parser = build_parser()
|
|
23
|
+
args = parser.parse_args(argv)
|
|
24
|
+
try:
|
|
25
|
+
if args.command == "deploy":
|
|
26
|
+
return deploy(args)
|
|
27
|
+
parser.print_help()
|
|
28
|
+
return 2
|
|
29
|
+
except UrunError as exc:
|
|
30
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
except KeyboardInterrupt:
|
|
33
|
+
print("aborted", file=sys.stderr)
|
|
34
|
+
return 130
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
parser = argparse.ArgumentParser(prog="urun")
|
|
39
|
+
parser.add_argument("--version", action="version", version=f"urun {__version__}")
|
|
40
|
+
sub = parser.add_subparsers(dest="command")
|
|
41
|
+
deploy = sub.add_parser("deploy", help="Deploy a Python app to urun")
|
|
42
|
+
deploy.add_argument("entrypoint", nargs="?", help="Python app file, e.g. app.py")
|
|
43
|
+
deploy.add_argument("--name", help="App name (defaults from entrypoint file)")
|
|
44
|
+
deploy.add_argument(
|
|
45
|
+
"--api-url", default=os.getenv("URUN_API_URL", DEFAULT_API_URL), help="API base URL"
|
|
46
|
+
)
|
|
47
|
+
deploy.add_argument(
|
|
48
|
+
"--api-key", default=os.getenv("URUN_API_KEY"), help="API key (or URUN_API_KEY)"
|
|
49
|
+
)
|
|
50
|
+
deploy.add_argument(
|
|
51
|
+
"--no-wait", action="store_true", help="Finalize deployment but do not poll for readiness"
|
|
52
|
+
)
|
|
53
|
+
deploy.add_argument("--poll-interval", type=float, default=2.0)
|
|
54
|
+
deploy.add_argument("--timeout", type=float, default=1800.0)
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def deploy(args: argparse.Namespace) -> int:
|
|
59
|
+
if not args.api_key:
|
|
60
|
+
raise UrunError("missing API key; set URUN_API_KEY or pass --api-key")
|
|
61
|
+
if not API_KEY_RE.fullmatch(args.api_key):
|
|
62
|
+
raise UrunError("invalid API key format; expected urun_<32 lowercase hex chars>")
|
|
63
|
+
if args.poll_interval <= 0:
|
|
64
|
+
raise UrunError("--poll-interval must be greater than 0")
|
|
65
|
+
if args.timeout <= 0:
|
|
66
|
+
raise UrunError("--timeout must be greater than 0")
|
|
67
|
+
|
|
68
|
+
project_root = Path.cwd()
|
|
69
|
+
entrypoint, files = discover_main_files(args.entrypoint, project_root)
|
|
70
|
+
deps, deps_blob, python_version = resolve_deps(project_root)
|
|
71
|
+
|
|
72
|
+
app_name = args.name or derive_app_name(args.entrypoint)
|
|
73
|
+
all_blobs = files + ([deps_blob] if deps_blob is not None else [])
|
|
74
|
+
manifest = {
|
|
75
|
+
"version": 1,
|
|
76
|
+
"app_name": app_name,
|
|
77
|
+
"entrypoint": entrypoint,
|
|
78
|
+
"python_version": python_version,
|
|
79
|
+
"files": [b.manifest_entry() for b in sorted(files, key=lambda b: b.path)],
|
|
80
|
+
"deps": deps,
|
|
81
|
+
"created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
82
|
+
"client_version": f"urun-cli/{__version__}",
|
|
83
|
+
}
|
|
84
|
+
mh = manifest_hash(manifest)
|
|
85
|
+
|
|
86
|
+
print(f"Deploying {app_name} ({mh[:12]})")
|
|
87
|
+
print(f"Files: {len(files)} app, deps: {deps['kind']}")
|
|
88
|
+
|
|
89
|
+
client = ApiClient(args.api_url, args.api_key)
|
|
90
|
+
register = client.register_manifest(manifest)
|
|
91
|
+
server_hash = register.get("manifest_hash")
|
|
92
|
+
if server_hash and server_hash != mh:
|
|
93
|
+
raise UrunError(f"server manifest hash mismatch: local {mh}, server {server_hash}")
|
|
94
|
+
missing = register.get("missing_blobs", [])
|
|
95
|
+
print(f"Uploading {len(missing)} missing blob(s)")
|
|
96
|
+
upload_missing_blobs(missing, unique_blobs(all_blobs))
|
|
97
|
+
|
|
98
|
+
final = client.finalize(mh)
|
|
99
|
+
print(f"Deployment {final.get('status', 'queued')}: {mh}")
|
|
100
|
+
if args.no_wait:
|
|
101
|
+
status_url = register.get("status_url")
|
|
102
|
+
if status_url:
|
|
103
|
+
print(f"Status: {status_url}")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
status = poll_until_done(client, mh, args.poll_interval, args.timeout)
|
|
107
|
+
if status.get("status") == "failed":
|
|
108
|
+
err = status.get("error") or {}
|
|
109
|
+
code = err.get("code")
|
|
110
|
+
message = err.get("message")
|
|
111
|
+
detail = f"{code}: {message}" if code and message else (message or code or "unknown error")
|
|
112
|
+
raise UrunError(f"deployment failed: {detail}")
|
|
113
|
+
print_ready(status)
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def print_ready(status: dict[str, Any]) -> None:
|
|
118
|
+
print("Deployment ready")
|
|
119
|
+
if status.get("url"):
|
|
120
|
+
print(f"URL: {status['url']}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
raise SystemExit(main())
|
urun/deps.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import UrunError
|
|
9
|
+
from .manifest import FileBlob, file_blob
|
|
10
|
+
|
|
11
|
+
DEFAULT_PYTHON_VERSION = "3.12"
|
|
12
|
+
CREDENTIAL_URL_RE = re.compile(r"https?://[^\s/@:]+:[^\s/@]+@", re.IGNORECASE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_deps(project_root: Path) -> tuple[dict[str, Any], FileBlob | None, str]:
|
|
16
|
+
python_version = DEFAULT_PYTHON_VERSION
|
|
17
|
+
|
|
18
|
+
pyproject = project_root / "pyproject.toml"
|
|
19
|
+
if pyproject.is_file():
|
|
20
|
+
ensure_under_root(pyproject, project_root, "pyproject.toml")
|
|
21
|
+
reject_embedded_dependency_credentials(pyproject)
|
|
22
|
+
blob = file_blob(pyproject, project_root)
|
|
23
|
+
try:
|
|
24
|
+
data = tomllib.loads(pyproject.read_text())
|
|
25
|
+
except tomllib.TOMLDecodeError as exc:
|
|
26
|
+
raise UrunError(f"Invalid TOML in pyproject.toml: {exc}") from exc
|
|
27
|
+
requires = (data.get("project") or {}).get("requires-python")
|
|
28
|
+
if isinstance(requires, str):
|
|
29
|
+
python_version = python_from_requires(requires) or python_version
|
|
30
|
+
return (
|
|
31
|
+
{
|
|
32
|
+
"kind": "pyproject_toml",
|
|
33
|
+
"blob_sha256": blob.sha256,
|
|
34
|
+
"python_version": python_version,
|
|
35
|
+
},
|
|
36
|
+
blob,
|
|
37
|
+
python_version,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
req = project_root / "requirements.txt"
|
|
41
|
+
if req.is_file():
|
|
42
|
+
ensure_under_root(req, project_root, "requirements.txt")
|
|
43
|
+
reject_embedded_dependency_credentials(req)
|
|
44
|
+
blob = file_blob(req, project_root)
|
|
45
|
+
return (
|
|
46
|
+
{
|
|
47
|
+
"kind": "requirements_txt",
|
|
48
|
+
"blob_sha256": blob.sha256,
|
|
49
|
+
"python_version": python_version,
|
|
50
|
+
},
|
|
51
|
+
blob,
|
|
52
|
+
python_version,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return {"kind": "none", "python_version": python_version}, None, python_version
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def ensure_under_root(path: Path, project_root: Path, label: str) -> None:
|
|
59
|
+
try:
|
|
60
|
+
path.resolve().relative_to(project_root.resolve())
|
|
61
|
+
except ValueError as exc:
|
|
62
|
+
raise UrunError(f"{label} is outside the project root: {path}") from exc
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def reject_embedded_dependency_credentials(path: Path) -> None:
|
|
66
|
+
text = path.read_text(errors="ignore")
|
|
67
|
+
if CREDENTIAL_URL_RE.search(text):
|
|
68
|
+
raise UrunError(
|
|
69
|
+
f"dependency file contains an embedded credential URL: {path.name}; "
|
|
70
|
+
"use token-free package references before deploying"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def python_from_requires(spec: str) -> str | None:
|
|
75
|
+
# Conservative extraction for common specs such as >=3.11 or ==3.12.*.
|
|
76
|
+
versions = re.findall(r"(\d+)\.(\d+)", spec)
|
|
77
|
+
if not versions:
|
|
78
|
+
return None
|
|
79
|
+
major, minor = versions[0]
|
|
80
|
+
return f"{major}.{minor}"
|
urun/discovery.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import fnmatch
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import UrunError
|
|
8
|
+
from .manifest import FileBlob, file_blob
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def derive_app_name(entrypoint: str | None) -> str:
|
|
12
|
+
if not entrypoint:
|
|
13
|
+
raise UrunError("missing entrypoint")
|
|
14
|
+
p = Path(entrypoint)
|
|
15
|
+
if p.name == "__main__.py" and p.parent.name:
|
|
16
|
+
return p.parent.name
|
|
17
|
+
if p.parent != Path("."):
|
|
18
|
+
return p.parent.name
|
|
19
|
+
return p.stem
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_urunignore(root: Path) -> list[str]:
|
|
23
|
+
ignore = root / ".urunignore"
|
|
24
|
+
if not ignore.exists():
|
|
25
|
+
return []
|
|
26
|
+
return [
|
|
27
|
+
line.strip()
|
|
28
|
+
for line in ignore.read_text().splitlines()
|
|
29
|
+
if line.strip() and not line.lstrip().startswith("#")
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_ignored(path: Path, project_root: Path, patterns: list[str]) -> bool:
|
|
34
|
+
try:
|
|
35
|
+
rel = path.resolve().relative_to(project_root.resolve()).as_posix()
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
raise UrunError(f"path is outside the project root: {path}") from exc
|
|
38
|
+
parts = rel.split("/")
|
|
39
|
+
if any(part == "__pycache__" for part in parts):
|
|
40
|
+
return True
|
|
41
|
+
if any(part == ".git" for part in parts):
|
|
42
|
+
return True
|
|
43
|
+
if path.name.startswith(".") or any(part.startswith(".") for part in parts):
|
|
44
|
+
return True
|
|
45
|
+
if path.suffix == ".pyc":
|
|
46
|
+
return True
|
|
47
|
+
return any(
|
|
48
|
+
fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(path.name, pattern) for pattern in patterns
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def auto_include(entrypoint: Path, project_root: Path) -> list[FileBlob]:
|
|
53
|
+
files = discover_local_python_files(entrypoint, project_root, load_urunignore(project_root))
|
|
54
|
+
blobs: list[FileBlob] = []
|
|
55
|
+
for p in sorted(files):
|
|
56
|
+
try:
|
|
57
|
+
blobs.append(file_blob(p, project_root))
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise UrunError(f"path is outside the project root: {p}") from exc
|
|
60
|
+
return blobs
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def discover_local_python_files(
|
|
64
|
+
entrypoint: Path, project_root: Path, patterns: list[str]
|
|
65
|
+
) -> set[Path]:
|
|
66
|
+
if not entrypoint.exists() or not entrypoint.is_file():
|
|
67
|
+
raise UrunError(f"entrypoint not found: {entrypoint}")
|
|
68
|
+
if entrypoint.suffix != ".py":
|
|
69
|
+
raise UrunError("entrypoint must be a .py file")
|
|
70
|
+
|
|
71
|
+
root = project_root.resolve()
|
|
72
|
+
stack = [entrypoint.resolve()]
|
|
73
|
+
seen: set[Path] = set()
|
|
74
|
+
|
|
75
|
+
while stack:
|
|
76
|
+
current = stack.pop()
|
|
77
|
+
try:
|
|
78
|
+
current.relative_to(root)
|
|
79
|
+
except ValueError as exc:
|
|
80
|
+
raise UrunError(f"path is outside the project root: {current}") from exc
|
|
81
|
+
if current in seen:
|
|
82
|
+
continue
|
|
83
|
+
if is_ignored(current, project_root, patterns):
|
|
84
|
+
label = "entrypoint" if current == entrypoint.resolve() else "imported file"
|
|
85
|
+
raise UrunError(f"{label} is ignored by defaults or .urunignore: {current}")
|
|
86
|
+
seen.add(current)
|
|
87
|
+
stack.extend(p for p in referenced_local_files(current, project_root) if p not in seen)
|
|
88
|
+
|
|
89
|
+
return seen
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def referenced_local_files(source: Path, project_root: Path) -> set[Path]:
|
|
93
|
+
try:
|
|
94
|
+
tree = ast.parse(source.read_text(), filename=str(source))
|
|
95
|
+
except SyntaxError as exc:
|
|
96
|
+
raise UrunError(f"entrypoint has invalid Python syntax: {source}") from exc
|
|
97
|
+
|
|
98
|
+
candidates: set[Path] = set()
|
|
99
|
+
for node in ast.walk(tree):
|
|
100
|
+
if isinstance(node, ast.Import):
|
|
101
|
+
for alias in node.names:
|
|
102
|
+
candidates.update(resolve_absolute_import(alias.name, project_root))
|
|
103
|
+
elif isinstance(node, ast.ImportFrom):
|
|
104
|
+
if node.level:
|
|
105
|
+
candidates.update(resolve_relative_import(node, source, project_root))
|
|
106
|
+
elif node.module:
|
|
107
|
+
candidates.update(resolve_absolute_import(node.module, project_root))
|
|
108
|
+
for alias in node.names:
|
|
109
|
+
if alias.name != "*":
|
|
110
|
+
candidates.update(
|
|
111
|
+
resolve_absolute_import(f"{node.module}.{alias.name}", project_root)
|
|
112
|
+
)
|
|
113
|
+
return candidates
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve_absolute_import(name: str, project_root: Path) -> set[Path]:
|
|
117
|
+
parts = name.split(".")
|
|
118
|
+
return module_path_candidates(project_root, parts)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_relative_import(node: ast.ImportFrom, source: Path, project_root: Path) -> set[Path]:
|
|
122
|
+
base = source.parent
|
|
123
|
+
for _ in range(max(node.level - 1, 0)):
|
|
124
|
+
base = base.parent
|
|
125
|
+
parts = node.module.split(".") if node.module else []
|
|
126
|
+
candidates = module_path_candidates(base, parts)
|
|
127
|
+
for alias in node.names:
|
|
128
|
+
if alias.name != "*":
|
|
129
|
+
candidates.update(module_path_candidates(base, [*parts, alias.name]))
|
|
130
|
+
return {p for p in candidates if is_under_project(p, project_root)}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def module_path_candidates(base: Path, parts: list[str]) -> set[Path]:
|
|
134
|
+
if not parts:
|
|
135
|
+
return set()
|
|
136
|
+
path = base.joinpath(*parts)
|
|
137
|
+
candidates: set[Path] = set()
|
|
138
|
+
module_file = path.with_suffix(".py")
|
|
139
|
+
package_init = path / "__init__.py"
|
|
140
|
+
if module_file.is_file():
|
|
141
|
+
candidates.add(module_file.resolve())
|
|
142
|
+
if package_init.is_file():
|
|
143
|
+
candidates.add(package_init.resolve())
|
|
144
|
+
return candidates
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_under_project(path: Path, project_root: Path) -> bool:
|
|
148
|
+
try:
|
|
149
|
+
path.resolve().relative_to(project_root.resolve())
|
|
150
|
+
except ValueError:
|
|
151
|
+
return False
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def discover_main_files(
|
|
156
|
+
entrypoint_arg: str | None, project_root: Path
|
|
157
|
+
) -> tuple[str, list[FileBlob]]:
|
|
158
|
+
if not entrypoint_arg:
|
|
159
|
+
raise UrunError("provide an entrypoint file")
|
|
160
|
+
entrypoint_path = Path(entrypoint_arg)
|
|
161
|
+
if not entrypoint_path.is_absolute():
|
|
162
|
+
entrypoint_path = project_root / entrypoint_path
|
|
163
|
+
files = auto_include(entrypoint_path, project_root)
|
|
164
|
+
try:
|
|
165
|
+
manifest_entrypoint = (
|
|
166
|
+
entrypoint_path.resolve().relative_to(project_root.resolve()).as_posix()
|
|
167
|
+
)
|
|
168
|
+
except ValueError as exc:
|
|
169
|
+
raise UrunError(f"entrypoint is outside the project root: {entrypoint_path}") from exc
|
|
170
|
+
return manifest_entrypoint, files
|
urun/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class UrunError(Exception):
|
|
2
|
+
"""User-facing CLI error."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ApiError(UrunError):
|
|
6
|
+
def __init__(self, status: int, code: str, message: str):
|
|
7
|
+
super().__init__(f"{status} {code}: {message}")
|
|
8
|
+
self.status = status
|
|
9
|
+
self.code = code
|
|
10
|
+
self.message = message
|
urun/manifest.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class FileBlob:
|
|
13
|
+
path: str
|
|
14
|
+
source: Path
|
|
15
|
+
sha256: str
|
|
16
|
+
size: int
|
|
17
|
+
mode: int
|
|
18
|
+
|
|
19
|
+
def manifest_entry(self) -> dict[str, Any]:
|
|
20
|
+
return {
|
|
21
|
+
"path": self.path,
|
|
22
|
+
"sha256": self.sha256,
|
|
23
|
+
"size": self.size,
|
|
24
|
+
"mode": self.mode,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def posix_rel(path: Path, root: Path) -> str:
|
|
29
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalized_mode(path: Path) -> int:
|
|
33
|
+
st_mode = path.stat().st_mode
|
|
34
|
+
return 0o100755 if (st_mode & 0o111) else 0o100644
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def hash_file(path: Path, chunk_size: int = 1024 * 1024) -> tuple[str, int]:
|
|
38
|
+
h = hashlib.sha256()
|
|
39
|
+
size = 0
|
|
40
|
+
with path.open("rb") as f:
|
|
41
|
+
while True:
|
|
42
|
+
chunk = f.read(chunk_size)
|
|
43
|
+
if not chunk:
|
|
44
|
+
break
|
|
45
|
+
size += len(chunk)
|
|
46
|
+
h.update(chunk)
|
|
47
|
+
return h.hexdigest(), size
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def file_blob(path: Path, root: Path, manifest_path: str | None = None) -> FileBlob:
|
|
51
|
+
sha, size = hash_file(path)
|
|
52
|
+
return FileBlob(
|
|
53
|
+
path=manifest_path or posix_rel(path, root),
|
|
54
|
+
source=path.resolve(),
|
|
55
|
+
sha256=sha,
|
|
56
|
+
size=size,
|
|
57
|
+
mode=normalized_mode(path),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def canonical_json(data: dict[str, Any]) -> bytes:
|
|
62
|
+
"""Canonical JSON: sorted keys, compact separators, UTF-8, no trailing whitespace."""
|
|
63
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
64
|
+
"utf-8"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def manifest_hash(manifest_without_hash: dict[str, Any]) -> str:
|
|
69
|
+
data = dict(manifest_without_hash)
|
|
70
|
+
data.pop("manifest_hash", None)
|
|
71
|
+
return hashlib.sha256(canonical_json(data)).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def unique_blobs(files: Iterable[FileBlob]) -> dict[str, FileBlob]:
|
|
75
|
+
out: dict[str, FileBlob] = {}
|
|
76
|
+
for blob in files:
|
|
77
|
+
out.setdefault(blob.sha256, blob)
|
|
78
|
+
return out
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: urun-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: End-user CLI for deploying apps to urun
|
|
5
|
+
Project-URL: Homepage, https://urun.sh
|
|
6
|
+
Project-URL: Repository, https://github.com/urun-sh/urun-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/urun-sh/urun-cli/issues
|
|
8
|
+
Author: urun
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,deploy,urun
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# urun CLI
|
|
24
|
+
|
|
25
|
+
Deploy Python apps to urun from your terminal.
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/urun-cli/)
|
|
28
|
+
[](https://pypi.org/project/urun-cli/)
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv tool install urun-cli
|
|
34
|
+
# or
|
|
35
|
+
pip install urun-cli
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The package installs the `urun` command:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
urun --version
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For one-off `uvx` usage:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uvx --from urun-cli urun --version
|
|
48
|
+
# or the package-matching command alias
|
|
49
|
+
uvx urun-cli --version
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
Set your API key:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
export URUN_API_KEY=urun_<32hex>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Create `app.py`:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
print("hello from urun")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Deploy it:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
urun deploy app.py
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The API key identifies your account on the server side. There is no separate org
|
|
73
|
+
ID, login step, or local project setup for the v1 CLI.
|
|
74
|
+
|
|
75
|
+
## What gets deployed
|
|
76
|
+
|
|
77
|
+
`urun deploy` creates a source manifest from your Python entrypoint:
|
|
78
|
+
|
|
79
|
+
| Entrypoint | Included source |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `urun deploy app.py` | `app.py` and local Python files it imports |
|
|
82
|
+
|
|
83
|
+
Dependency declarations are included when present. `pyproject.toml` is preferred;
|
|
84
|
+
`requirements.txt` is used when no `pyproject.toml` exists.
|
|
85
|
+
|
|
86
|
+
Generated/cache content such as `.git`, dotfiles, `__pycache__`, and `.pyc`
|
|
87
|
+
files is excluded. Add `.urunignore` to exclude additional paths.
|
|
88
|
+
|
|
89
|
+
Non-Python assets such as templates, static files, and data files are not
|
|
90
|
+
auto-included yet.
|
|
91
|
+
|
|
92
|
+
## Common options
|
|
93
|
+
|
|
94
|
+
| Option | Description |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `--name` | Override the derived app name. |
|
|
97
|
+
| `--api-url` | Override the API URL; defaults to `https://api.urun.sh/v1`. |
|
|
98
|
+
| `--api-key` | API key; prefer `URUN_API_KEY` to avoid shell history leaks. |
|
|
99
|
+
| `--no-wait` | Finalize but do not poll for readiness. |
|
|
100
|
+
| `--poll-interval`, `--timeout` | Control readiness polling. |
|
|
101
|
+
|
|
102
|
+
## Troubleshooting
|
|
103
|
+
|
|
104
|
+
| Error | Fix |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `missing API key` | Set `URUN_API_KEY` or pass `--api-key`. |
|
|
107
|
+
| `invalid API key format` | Use `urun_<32 lowercase hex chars>`. |
|
|
108
|
+
| `entrypoint not found` | Run from the project root or pass the entrypoint path. |
|
|
109
|
+
| `path is outside the project root` | Move the file under the project before deploying. |
|
|
110
|
+
| Expected files are missing | Import local Python files from `app.py`; non-Python assets are not auto-included yet. |
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
Contributing and test instructions are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
urun/__init__.py,sha256=wUUhbNiFISZ2cpU7w-Z1RiUjfPDNtEEUyechv55oYyY,718
|
|
2
|
+
urun/api.py,sha256=NOti71DwAvV-SPvcDoQgt4Y0w4yIQX2QzYWjsRi3o4U,7468
|
|
3
|
+
urun/cli.py,sha256=No847NdPfstW4SKmfMh4HtvIa--W1cCmCW3fzRfcoAY,4560
|
|
4
|
+
urun/deps.py,sha256=Md4ndQgjVO0lOHK8Fw8h21KQXpn3MRggcPFWfrNVAHk,2716
|
|
5
|
+
urun/discovery.py,sha256=gQevD-PSmdJwhT0essmITp-Mf0GSwG9ph-PZlQAdJZQ,5994
|
|
6
|
+
urun/errors.py,sha256=xd1UrFrqn5rQXcUX02_vfzHwHgpDA0RbwrbM_pnhaZY,293
|
|
7
|
+
urun/manifest.py,sha256=YLpryYCFSu-w8yONj3oohBHpLNP-dPRYAny1JAe9pfI,2043
|
|
8
|
+
urun_cli-0.1.0.dist-info/METADATA,sha256=8pbYsDMLHIAQxZGHj8a3H4We4xKakJt4zvdEDJCMmWQ,3215
|
|
9
|
+
urun_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
urun_cli-0.1.0.dist-info/entry_points.txt,sha256=Hm1XEwW2k-vTyABcFkd8FevCaQHPz9CTOHxvXkiMiOs,64
|
|
11
|
+
urun_cli-0.1.0.dist-info/licenses/LICENSE,sha256=e54RugXp4xrrinIGvXfGB3UBq__CoP32EEGPaju_G8I,1061
|
|
12
|
+
urun_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 urun
|
|
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.
|