urun-cli 0.1.1__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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
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,148 @@
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 .config import load_config
14
+ from .deps import resolve_deps
15
+ from .discovery import derive_app_name, discover_main_files, discover_mounts
16
+ from .errors import UrunError
17
+ from .manifest import manifest_hash, unique_blobs
18
+
19
+ API_KEY_RE = re.compile(r"^urun_[0-9a-f]{32}$")
20
+ ORG_ID_RE = re.compile(r"^org_[A-Za-z0-9_-]+$")
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> int:
24
+ parser = build_parser()
25
+ args = parser.parse_args(argv)
26
+ try:
27
+ if args.command == "deploy":
28
+ return deploy(args)
29
+ parser.print_help()
30
+ return 2
31
+ except UrunError as exc:
32
+ print(f"error: {exc}", file=sys.stderr)
33
+ return 1
34
+ except KeyboardInterrupt:
35
+ print("aborted", file=sys.stderr)
36
+ return 130
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ parser = argparse.ArgumentParser(prog="urun")
41
+ parser.add_argument("--version", action="version", version=f"urun {__version__}")
42
+ sub = parser.add_subparsers(dest="command")
43
+ deploy = sub.add_parser("deploy", help="Deploy a Python app to urun")
44
+ target = deploy.add_mutually_exclusive_group()
45
+ target.add_argument("entrypoint", nargs="?", help="Python file entrypoint, e.g. app.py")
46
+ target.add_argument("-m", "--module", help="Python module entrypoint, e.g. myapp.main")
47
+ deploy.add_argument("--name", help="App name (defaults from entrypoint/module)")
48
+ deploy.add_argument("--config", type=Path, help="Path to urun.toml")
49
+ deploy.add_argument(
50
+ "--api-url", default=os.getenv("URUN_API_URL", DEFAULT_API_URL), help="API base URL"
51
+ )
52
+ deploy.add_argument(
53
+ "--api-key", default=os.getenv("URUN_API_KEY"), help="API key (or URUN_API_KEY)"
54
+ )
55
+ deploy.add_argument(
56
+ "--org-id", default=os.getenv("URUN_ORG_ID"), help="Org id (or URUN_ORG_ID)"
57
+ )
58
+ deploy.add_argument(
59
+ "--no-wait", action="store_true", help="Finalize deployment but do not poll for readiness"
60
+ )
61
+ deploy.add_argument("--poll-interval", type=float, default=2.0)
62
+ deploy.add_argument("--timeout", type=float, default=1800.0)
63
+ return parser
64
+
65
+
66
+ def deploy(args: argparse.Namespace) -> int:
67
+ if not args.api_key:
68
+ raise UrunError("missing API key; set URUN_API_KEY or pass --api-key")
69
+ if not API_KEY_RE.fullmatch(args.api_key):
70
+ raise UrunError("invalid API key format; expected urun_<32 lowercase hex chars>")
71
+ if not args.org_id:
72
+ raise UrunError("missing org id; set URUN_ORG_ID or pass --org-id")
73
+ if not ORG_ID_RE.fullmatch(args.org_id):
74
+ raise UrunError("invalid org id format; expected org_<id>")
75
+ if args.poll_interval <= 0:
76
+ raise UrunError("--poll-interval must be greater than 0")
77
+ if args.timeout <= 0:
78
+ raise UrunError("--timeout must be greater than 0")
79
+
80
+ project_root = args.config.resolve().parent if args.config else Path.cwd()
81
+ config = load_config(args.config)
82
+ entrypoint, files = discover_main_files(config, args.entrypoint, args.module, project_root)
83
+ mount_entries, mount_blobs = discover_mounts(config.mounts, project_root)
84
+ deps, deps_blob, python_version = resolve_deps(config, project_root)
85
+
86
+ app_name = (
87
+ args.name
88
+ or config.app.name
89
+ or derive_app_name(
90
+ args.entrypoint or config.app.entrypoint, args.module or config.app.module
91
+ )
92
+ )
93
+ all_blobs = files + mount_blobs + ([deps_blob] if deps_blob is not None else [])
94
+ manifest = {
95
+ "version": 1,
96
+ "org_id": args.org_id,
97
+ "app_name": app_name,
98
+ "entrypoint": entrypoint,
99
+ "python_version": python_version,
100
+ "files": [b.manifest_entry() for b in sorted(files, key=lambda b: b.path)],
101
+ "mounts": mount_entries,
102
+ "deps": deps,
103
+ "created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
104
+ "client_version": f"urun-cli/{__version__}",
105
+ }
106
+ mh = manifest_hash(manifest)
107
+
108
+ print(f"Deploying {app_name} ({mh[:12]})")
109
+ print(f"Files: {len(files)} app, {len(mount_blobs)} mounted, deps: {deps['kind']}")
110
+
111
+ client = ApiClient(args.api_url, args.api_key)
112
+ register = client.register_manifest(manifest)
113
+ server_hash = register.get("manifest_hash")
114
+ if server_hash and server_hash != mh:
115
+ raise UrunError(f"server manifest hash mismatch: local {mh}, server {server_hash}")
116
+ missing = register.get("missing_blobs", [])
117
+ print(f"Uploading {len(missing)} missing blob(s)")
118
+ upload_missing_blobs(missing, unique_blobs(all_blobs))
119
+
120
+ final = client.finalize(mh)
121
+ print(f"Deployment {final.get('status', 'queued')}: {mh}")
122
+ if args.no_wait:
123
+ status_url = register.get("status_url")
124
+ if status_url:
125
+ print(f"Status: {status_url}")
126
+ return 0
127
+
128
+ status = poll_until_done(client, mh, args.poll_interval, args.timeout)
129
+ if status.get("status") == "failed":
130
+ err = status.get("error") or {}
131
+ code = err.get("code")
132
+ message = err.get("message")
133
+ detail = f"{code}: {message}" if code and message else (message or code or "unknown error")
134
+ raise UrunError(f"deployment failed: {detail}")
135
+ print_ready(status)
136
+ return 0
137
+
138
+
139
+ def print_ready(status: dict[str, Any]) -> None:
140
+ print("Deployment ready")
141
+ if status.get("zerofs_path"):
142
+ print(f"ZeroFS: {status['zerofs_path']}")
143
+ if status.get("url"):
144
+ print(f"URL: {status['url']}")
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())
urun/config.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import tomllib
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Any
8
+
9
+ from .errors import UrunError
10
+
11
+
12
+ @dataclass
13
+ class AppConfig:
14
+ name: str | None = None
15
+ entrypoint: str | None = None
16
+ module: str | None = None
17
+ python_version: str | None = None
18
+
19
+
20
+ @dataclass
21
+ class MountConfig:
22
+ local_path: str | None = None
23
+ local_file: str | None = None
24
+ python_source: str | None = None
25
+ remote_path: str | None = None
26
+
27
+
28
+ @dataclass
29
+ class DepsConfig:
30
+ requirements: str | None = None
31
+
32
+
33
+ @dataclass
34
+ class UrunConfig:
35
+ app: AppConfig = field(default_factory=AppConfig)
36
+ mounts: list[MountConfig] = field(default_factory=list)
37
+ deps: DepsConfig = field(default_factory=DepsConfig)
38
+
39
+
40
+ def load_config(path: Path | None) -> UrunConfig:
41
+ if path is None:
42
+ path = Path("urun.toml")
43
+ if not path.exists():
44
+ return UrunConfig()
45
+ try:
46
+ data = tomllib.loads(path.read_text())
47
+ except tomllib.TOMLDecodeError as exc:
48
+ raise UrunError(f"Invalid TOML in {path}: {exc}") from exc
49
+ return parse_config(data, path)
50
+
51
+
52
+ def parse_config(data: dict[str, Any], path: Path = Path("urun.toml")) -> UrunConfig:
53
+ app_data = data.get("app", {}) or {}
54
+ if not isinstance(app_data, dict):
55
+ raise UrunError(f"{path}: [app] must be a table")
56
+ app = AppConfig(
57
+ name=_str(app_data, "name", path),
58
+ entrypoint=_str(app_data, "entrypoint", path),
59
+ module=_str(app_data, "module", path),
60
+ python_version=_str(app_data, "python_version", path),
61
+ )
62
+
63
+ mounts: list[MountConfig] = []
64
+ for idx, mount_data in enumerate(data.get("mounts", []) or []):
65
+ if not isinstance(mount_data, dict):
66
+ raise UrunError(f"{path}: [[mounts]] item {idx} must be a table")
67
+ sources = [k for k in ("local_path", "local_file", "python_source") if mount_data.get(k)]
68
+ if len(sources) != 1:
69
+ raise UrunError(f"{path}: [[mounts]] item {idx} must set exactly one source")
70
+ remote_path = _str(mount_data, "remote_path", path)
71
+ if not remote_path:
72
+ raise UrunError(f"{path}: [[mounts]] item {idx} must set remote_path")
73
+ validate_remote_path(remote_path, path, idx)
74
+ mounts.append(
75
+ MountConfig(
76
+ local_path=_str(mount_data, "local_path", path),
77
+ local_file=_str(mount_data, "local_file", path),
78
+ python_source=_str(mount_data, "python_source", path),
79
+ remote_path=remote_path,
80
+ )
81
+ )
82
+
83
+ deps_data = data.get("deps", {}) or {}
84
+ if not isinstance(deps_data, dict):
85
+ raise UrunError(f"{path}: [deps] must be a table")
86
+ return UrunConfig(
87
+ app=app, mounts=mounts, deps=DepsConfig(requirements=_str(deps_data, "requirements", path))
88
+ )
89
+
90
+
91
+ def validate_remote_path(remote_path: str, path: Path, idx: int) -> None:
92
+ parts = PurePosixPath(remote_path).parts
93
+ invalid = (
94
+ not remote_path.startswith("/")
95
+ or remote_path == "/"
96
+ or "\\" in remote_path
97
+ or "//" in remote_path
98
+ or any(part in {".", ".."} for part in parts)
99
+ or any(part in {".", ".."} for part in remote_path.split("/"))
100
+ or bool(re.search(r"[\x00-\x1f\x7f]", remote_path))
101
+ or len(remote_path) > 512
102
+ )
103
+ if invalid:
104
+ raise UrunError(
105
+ f"{path}: [[mounts]] item {idx} remote_path must be an absolute, normalized POSIX path"
106
+ )
107
+
108
+
109
+ def _str(data: dict[str, Any], key: str, path: Path) -> str | None:
110
+ value = data.get(key)
111
+ if value is None:
112
+ return None
113
+ if not isinstance(value, str):
114
+ raise UrunError(f"{path}: {key} must be a string")
115
+ return value
urun/deps.py ADDED
@@ -0,0 +1,92 @@
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 .config import UrunConfig
9
+ from .discovery import ensure_under_root
10
+ from .errors import UrunError
11
+ from .manifest import FileBlob, file_blob
12
+
13
+ DEFAULT_PYTHON_VERSION = "3.12"
14
+ CREDENTIAL_URL_RE = re.compile(r"https?://[^\s/@:]+:[^\s/@]+@", re.IGNORECASE)
15
+
16
+
17
+ def resolve_deps(
18
+ config: UrunConfig, project_root: Path
19
+ ) -> tuple[dict[str, Any], FileBlob | None, str]:
20
+ python_version = config.app.python_version or DEFAULT_PYTHON_VERSION
21
+ if config.deps.requirements:
22
+ path = ensure_under_root(
23
+ project_root / config.deps.requirements, project_root, "requirements file"
24
+ )
25
+ if not path.is_file():
26
+ raise UrunError(f"requirements file not found: {path}")
27
+ reject_embedded_dependency_credentials(path)
28
+ blob = file_blob(path, project_root)
29
+ return (
30
+ {
31
+ "kind": "requirements_txt",
32
+ "blob_sha256": blob.sha256,
33
+ "python_version": python_version,
34
+ },
35
+ blob,
36
+ python_version,
37
+ )
38
+
39
+ pyproject = project_root / "pyproject.toml"
40
+ if pyproject.is_file():
41
+ reject_embedded_dependency_credentials(pyproject)
42
+ blob = file_blob(pyproject, project_root)
43
+ try:
44
+ data = tomllib.loads(pyproject.read_text())
45
+ except tomllib.TOMLDecodeError as exc:
46
+ raise UrunError(f"Invalid TOML in pyproject.toml: {exc}") from exc
47
+ requires = (data.get("project") or {}).get("requires-python")
48
+ if not config.app.python_version and isinstance(requires, str):
49
+ python_version = python_from_requires(requires) or python_version
50
+ return (
51
+ {
52
+ "kind": "pyproject_toml",
53
+ "blob_sha256": blob.sha256,
54
+ "python_version": python_version,
55
+ },
56
+ blob,
57
+ python_version,
58
+ )
59
+
60
+ req = project_root / "requirements.txt"
61
+ if req.is_file():
62
+ reject_embedded_dependency_credentials(req)
63
+ blob = file_blob(req, project_root)
64
+ return (
65
+ {
66
+ "kind": "requirements_txt",
67
+ "blob_sha256": blob.sha256,
68
+ "python_version": python_version,
69
+ },
70
+ blob,
71
+ python_version,
72
+ )
73
+
74
+ return {"kind": "none", "python_version": python_version}, None, python_version
75
+
76
+
77
+ def reject_embedded_dependency_credentials(path: Path) -> None:
78
+ text = path.read_text(errors="ignore")
79
+ if CREDENTIAL_URL_RE.search(text):
80
+ raise UrunError(
81
+ f"dependency file contains an embedded credential URL: {path.name}; "
82
+ "use token-free package references before deploying"
83
+ )
84
+
85
+
86
+ def python_from_requires(spec: str) -> str | None:
87
+ # Conservative extraction for common specs such as >=3.11 or ==3.12.*.
88
+ versions = re.findall(r"(\d+)\.(\d+)", spec)
89
+ if not versions:
90
+ return None
91
+ major, minor = versions[0]
92
+ return f"{major}.{minor}"
urun/discovery.py ADDED
@@ -0,0 +1,204 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import importlib.util
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .config import MountConfig, UrunConfig
9
+ from .errors import UrunError
10
+ from .manifest import FileBlob, file_blob
11
+
12
+ MAX_AUTO_INCLUDE_BYTES = 50 * 1024 * 1024
13
+ DEFAULT_PYTHON_VERSION = "3.12"
14
+
15
+
16
+ def derive_app_name(entrypoint: str | None, module: str | None) -> str:
17
+ if module:
18
+ return module.split(".")[0]
19
+ if not entrypoint:
20
+ raise UrunError("missing entrypoint")
21
+ p = Path(entrypoint)
22
+ if p.name == "__main__.py" and p.parent.name:
23
+ return p.parent.name
24
+ if p.parent != Path("."):
25
+ return p.parent.name
26
+ return p.stem
27
+
28
+
29
+ def package_root_for(entrypoint: Path) -> Path | None:
30
+ current = entrypoint.resolve().parent
31
+ if not (current / "__init__.py").exists():
32
+ return None
33
+ root = current
34
+ while (root.parent / "__init__.py").exists():
35
+ root = root.parent
36
+ return root
37
+
38
+
39
+ def load_urunignore(root: Path) -> list[str]:
40
+ ignore = root / ".urunignore"
41
+ if not ignore.exists():
42
+ return []
43
+ return [
44
+ line.strip()
45
+ for line in ignore.read_text().splitlines()
46
+ if line.strip() and not line.lstrip().startswith("#")
47
+ ]
48
+
49
+
50
+ def is_ignored(path: Path, project_root: Path, patterns: list[str]) -> bool:
51
+ try:
52
+ rel = path.resolve().relative_to(project_root.resolve()).as_posix()
53
+ except ValueError as exc:
54
+ raise UrunError(f"path is outside the project root: {path}") from exc
55
+ parts = rel.split("/")
56
+ if any(part == "__pycache__" for part in parts):
57
+ return True
58
+ if any(part == ".git" for part in parts):
59
+ return True
60
+ if path.name.startswith(".") or any(part.startswith(".") for part in parts):
61
+ return True
62
+ if path.suffix == ".pyc":
63
+ return True
64
+ return any(
65
+ fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(path.name, pattern) for pattern in patterns
66
+ )
67
+
68
+
69
+ def auto_include(entrypoint: Path, project_root: Path) -> list[FileBlob]:
70
+ if not entrypoint.exists() or not entrypoint.is_file():
71
+ raise UrunError(f"entrypoint not found: {entrypoint}")
72
+ if entrypoint.suffix != ".py":
73
+ raise UrunError("entrypoint must be a .py file")
74
+ patterns = load_urunignore(project_root)
75
+ pkg = package_root_for(entrypoint)
76
+ files: list[Path]
77
+ if pkg:
78
+ files = sorted(
79
+ p
80
+ for p in pkg.rglob("*.py")
81
+ if p.is_file() and not is_ignored(p, project_root, patterns)
82
+ )
83
+ total = sum(p.stat().st_size for p in files)
84
+ if total > MAX_AUTO_INCLUDE_BYTES:
85
+ raise UrunError(
86
+ f"auto-included package {pkg} is {total} bytes, above 50 MB; "
87
+ "use urun.toml to configure explicit mounts"
88
+ )
89
+ else:
90
+ if is_ignored(entrypoint, project_root, patterns):
91
+ raise UrunError(f"entrypoint is ignored by defaults or .urunignore: {entrypoint}")
92
+ files = [entrypoint]
93
+ blobs: list[FileBlob] = []
94
+ for p in files:
95
+ try:
96
+ blobs.append(file_blob(p, project_root))
97
+ except ValueError as exc:
98
+ raise UrunError(f"path is outside the project root: {p}") from exc
99
+ return blobs
100
+
101
+
102
+ def discover_main_files(
103
+ config: UrunConfig, entrypoint_arg: str | None, module_arg: str | None, project_root: Path
104
+ ) -> tuple[str, list[FileBlob]]:
105
+ ensure_project_on_path(project_root)
106
+ module = module_arg or config.app.module
107
+ entrypoint = entrypoint_arg or config.app.entrypoint
108
+ if module:
109
+ spec = importlib.util.find_spec(module)
110
+ if spec is None or spec.origin is None:
111
+ raise UrunError(f"module not found: {module}")
112
+ entrypoint_path = ensure_under_root(Path(spec.origin), project_root, "module")
113
+ elif entrypoint:
114
+ entrypoint_path = Path(entrypoint)
115
+ else:
116
+ raise UrunError("provide an entrypoint, -m module, or [app].entrypoint in urun.toml")
117
+ if not entrypoint_path.is_absolute():
118
+ entrypoint_path = project_root / entrypoint_path
119
+ files = auto_include(entrypoint_path, project_root)
120
+ try:
121
+ manifest_entrypoint = (
122
+ module
123
+ if module
124
+ else entrypoint_path.resolve().relative_to(project_root.resolve()).as_posix()
125
+ )
126
+ except ValueError as exc:
127
+ raise UrunError(f"entrypoint is outside the project root: {entrypoint_path}") from exc
128
+ return manifest_entrypoint, files
129
+
130
+
131
+ def discover_mounts(
132
+ mounts: list[MountConfig], project_root: Path
133
+ ) -> tuple[list[dict], list[FileBlob]]:
134
+ ensure_project_on_path(project_root)
135
+ manifest_mounts: list[dict] = []
136
+ all_blobs: list[FileBlob] = []
137
+ patterns = load_urunignore(project_root)
138
+ seen_remote_paths: set[str] = set()
139
+ for mount in mounts:
140
+ if mount.remote_path in seen_remote_paths:
141
+ raise UrunError(f"duplicate mount remote_path: {mount.remote_path}")
142
+ seen_remote_paths.add(str(mount.remote_path))
143
+ source = _mount_source(mount, project_root)
144
+ assert mount.remote_path is not None
145
+ if mount.local_file:
146
+ if not source.is_file():
147
+ raise UrunError(f"mount file not found: {source}")
148
+ blob = file_blob(source, source.parent, source.name)
149
+ entries = [blob]
150
+ else:
151
+ if not source.is_dir():
152
+ raise UrunError(f"mount directory not found: {source}")
153
+ paths = sorted(
154
+ p
155
+ for p in source.rglob("*")
156
+ if p.is_file() and not is_ignored(p, project_root, patterns)
157
+ )
158
+ entries = [file_blob(p, source) for p in paths]
159
+ all_blobs.extend(entries)
160
+ manifest_mounts.append(
161
+ {
162
+ "remote_path": mount.remote_path,
163
+ "files": [b.manifest_entry() for b in sorted(entries, key=lambda b: b.path)],
164
+ }
165
+ )
166
+ return manifest_mounts, all_blobs
167
+
168
+
169
+ def ensure_project_on_path(project_root: Path) -> None:
170
+ candidates = [project_root.resolve()]
171
+ src = project_root / "src"
172
+ if src.is_dir():
173
+ candidates.append(src.resolve())
174
+ for candidate in reversed(candidates):
175
+ path = str(candidate)
176
+ if path not in sys.path:
177
+ sys.path.insert(0, path)
178
+
179
+
180
+ def ensure_under_root(path: Path, project_root: Path, label: str = "path") -> Path:
181
+ resolved = path.resolve()
182
+ try:
183
+ resolved.relative_to(project_root.resolve())
184
+ except ValueError as exc:
185
+ raise UrunError(f"{label} is outside the project root: {path}") from exc
186
+ return resolved
187
+
188
+
189
+ def _mount_source(mount: MountConfig, project_root: Path) -> Path:
190
+ if mount.local_path:
191
+ return ensure_under_root(project_root / mount.local_path, project_root, "mount path")
192
+ if mount.local_file:
193
+ return ensure_under_root(project_root / mount.local_file, project_root, "mount file")
194
+ if mount.python_source:
195
+ spec = importlib.util.find_spec(mount.python_source)
196
+ if spec is None:
197
+ raise UrunError(f"python_source not found: {mount.python_source}")
198
+ if spec.submodule_search_locations:
199
+ return ensure_under_root(
200
+ Path(next(iter(spec.submodule_search_locations))), project_root, "python_source"
201
+ )
202
+ if spec.origin:
203
+ return ensure_under_root(Path(spec.origin), project_root, "python_source")
204
+ raise UrunError("invalid mount source")
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,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: urun-cli
3
+ Version: 0.1.1
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
+ End-user command line client for deploying Python apps to urun.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ uv tool install urun-cli
31
+ # or
32
+ pip install urun-cli
33
+ ```
34
+
35
+ The installed executable is still `urun`:
36
+
37
+ ```bash
38
+ urun --version
39
+ ```
40
+
41
+ PyPI does not allow the distribution name `urun`, so ephemeral uv runs need the
42
+ package name explicitly:
43
+
44
+ ```bash
45
+ uvx --from urun-cli urun --version
46
+ # or use the package-matching alias
47
+ uvx urun-cli --version
48
+ ```
49
+
50
+ ## Deploy
51
+
52
+ ```bash
53
+ export URUN_API_KEY=urun_<32hex>
54
+ export URUN_ORG_ID=org_... # bootstrap-era requirement for v1 manifests
55
+ urun deploy app.py
56
+ ```
57
+
58
+ ## Development with uv / Nix
59
+
60
+ This repo includes `flake.nix` and `.envrc` for a reproducible dev shell with uv,
61
+ Python 3.12, Dagger, actionlint, and pre-commit.
62
+
63
+ ```bash
64
+ direnv allow # optional, if you use direnv/nix
65
+ uv sync
66
+ uv run pytest
67
+ uv run urun --version
68
+ uv build
69
+ ```
70
+
71
+ CI uses the same centralized Dagger pre-commit pipeline pattern as `urun`:
72
+ `precommit.yaml` checks out `urun-sh/dagger-pipeline` and runs `recipes.precommit`.
73
+ Normal `ci` remains self-contained and runs on public/fork PRs.
74
+
75
+ Optional local MinIO integration test:
76
+
77
+ ```bash
78
+ docker compose -f compose.minio.yml up -d
79
+ URUN_TEST_MINIO_ENDPOINT=127.0.0.1:9000 \
80
+ URUN_TEST_MINIO_ACCESS_KEY=minioadmin \
81
+ URUN_TEST_MINIO_SECRET_KEY=minioadmin \
82
+ uv run pytest -q -m minio
83
+ ```
84
+
85
+ See `SPEC_TRACKING.md` for spec coverage and remaining gaps.
86
+
87
+ ## Configuration
88
+
89
+ Configuration is read from `urun.toml` when present. CLI flags override config values.
90
+
91
+ ```toml
92
+ [app]
93
+ name = "my-app"
94
+ entrypoint = "src/myapp/main.py"
95
+ python_version = "3.12"
96
+
97
+ [[mounts]]
98
+ local_path = "./assets"
99
+ remote_path = "/app/assets"
100
+
101
+ [deps]
102
+ requirements = "requirements.txt"
103
+ ```
104
+
105
+ Common options:
106
+
107
+ | Option | Description |
108
+ | --- | --- |
109
+ | `-m, --module` | Deploy a Python module entrypoint, e.g. `myapp.main`. |
110
+ | `--name` | Override the derived app name. |
111
+ | `--config` | Use a specific `urun.toml`; relative paths resolve from its directory. |
112
+ | `--api-url` | Override the API URL. |
113
+ | `--api-key` | API key; prefer `URUN_API_KEY` for shell history safety. |
114
+ | `--org-id` | Bootstrap-era org ID required in v1 manifests. |
115
+ | `--no-wait` | Finalize but do not poll for readiness. |
116
+ | `--poll-interval`, `--timeout` | Control readiness polling. |
117
+
118
+ ## Troubleshooting
119
+
120
+ | Error | Fix |
121
+ | --- | --- |
122
+ | `missing API key` | Set `URUN_API_KEY` or pass `--api-key`. |
123
+ | `invalid API key format` | Use `urun_<32 lowercase hex chars>`. |
124
+ | `missing org id` | Set `URUN_ORG_ID` while v1 bootstrap is manual. |
125
+ | `entrypoint not found` | Run from the project root or pass `--config`. |
126
+ | `path is outside the project root` | Move the file under the project or adjust `urun.toml`. |
127
+
128
+ ## License
129
+
130
+ MIT. Local MinIO is used only as an optional S3-compatible test service.
@@ -0,0 +1,13 @@
1
+ urun/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ urun/api.py,sha256=NOti71DwAvV-SPvcDoQgt4Y0w4yIQX2QzYWjsRi3o4U,7468
3
+ urun/cli.py,sha256=3OUW2DX1mZ1Cv6UIUEfSZGdSJ-8XijLoWGxAWGZiBk0,5727
4
+ urun/config.py,sha256=uT-1NDs7ZB1rx15X-_n6XPrxvNF10yDjIQZRlhQXIcA,3769
5
+ urun/deps.py,sha256=ZI7T1OlerKfYGdFNqVrsCF69B0GVCBydFt76XrYx7VI,3088
6
+ urun/discovery.py,sha256=0xDgyjgh66aEK93kraZIm8x8r6naTYbn1GVAhZRQnzw,7446
7
+ urun/errors.py,sha256=xd1UrFrqn5rQXcUX02_vfzHwHgpDA0RbwrbM_pnhaZY,293
8
+ urun/manifest.py,sha256=YLpryYCFSu-w8yONj3oohBHpLNP-dPRYAny1JAe9pfI,2043
9
+ urun_cli-0.1.1.dist-info/METADATA,sha256=Gw_DfOtO3-QhXdhcPbP2weDPZYrM1W2jx9QW0jDcPzk,3568
10
+ urun_cli-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ urun_cli-0.1.1.dist-info/entry_points.txt,sha256=Hm1XEwW2k-vTyABcFkd8FevCaQHPz9CTOHxvXkiMiOs,64
12
+ urun_cli-0.1.1.dist-info/licenses/LICENSE,sha256=e54RugXp4xrrinIGvXfGB3UBq__CoP32EEGPaju_G8I,1061
13
+ urun_cli-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ urun = urun.cli:main
3
+ urun-cli = urun.cli:main
@@ -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.