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 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
+ [![PyPI](https://img.shields.io/pypi/v/urun-cli.svg)](https://pypi.org/project/urun-cli/)
28
+ [![Python](https://img.shields.io/pypi/pyversions/urun-cli.svg)](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,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.