urun-cli 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,3 +12,7 @@ htmlcov/
12
12
  dist/
13
13
  build/
14
14
  *.egg-info/
15
+
16
+ # Local development environment
17
+ .devcontainer.env
18
+ .cache/
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - Bumped past 0.1.1/0.1.2 because both filenames were occupied by previously-deleted PyPI artifacts.
6
+ - Drop the org-id requirement from `urun deploy`; authentication now relies solely on the API key.
7
+ - Add `urun login`, `urun run`, and `urun org`/`urun auth` subcommands.
8
+
9
+ ## 0.1.0
10
+
11
+ - Initial public `urun deploy` CLI.
12
+ - Deploy from a Python app file with local Python imports included automatically.
13
+ - Source manifest generation, dependency declaration upload, and API deploy flow.
14
+ - Public docs for the config-free v1 CLI surface.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: End-user CLI for deploying apps to urun
5
5
  Project-URL: Homepage, https://urun.sh
6
6
  Project-URL: Repository, https://github.com/urun-sh/urun-cli
@@ -51,34 +51,47 @@ uvx urun-cli --version
51
51
 
52
52
  ## Quick start
53
53
 
54
- Set your API key:
54
+ Today, an operator manually vends an org-scoped deploy API key. Save it locally
55
+ with `urun login`:
55
56
 
56
57
  ```bash
57
- export URUN_API_KEY=urun_<32hex>
58
+ urun login --api-key urun_<32hex>
58
59
  ```
59
60
 
60
- Create a small Python app:
61
+ `urun login` verifies the key with the urun API and stores credentials for later
62
+ commands. The future browser-based login flow is not available in this CLI
63
+ release.
64
+
65
+ For CI or one-off commands, you can still use the environment variable:
61
66
 
62
67
  ```bash
63
- cat > app.py <<'PY'
64
- print("hello from urun")
65
- PY
68
+ export URUN_API_KEY=urun_<32hex>
66
69
  ```
67
70
 
68
- Deploy it:
71
+ Create `app.py`:
69
72
 
70
- ```bash
71
- urun deploy app.py
73
+ ```python
74
+ import urun
75
+ from urun import App
76
+
77
+ app = App("hello-h100")
78
+
79
+
80
+ @app.function(gpus="h100:1")
81
+ def hello(ctx: urun.Context):
82
+ print(f"running on {ctx.device}")
83
+ return {"device": str(ctx.device)}
72
84
  ```
73
85
 
74
- You can also deploy a Python module:
86
+ Run it:
75
87
 
76
88
  ```bash
77
- urun deploy -m my_package.main
89
+ urun run app.py
78
90
  ```
79
91
 
80
- The API key identifies your account on the server side. There is no separate org
81
- ID, login step, or local project setup for the v1 CLI.
92
+ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
93
+ `deploy` remains available as the lower-level command while the full
94
+ deploy/run/monitor workflow is being built.
82
95
 
83
96
  ## What gets deployed
84
97
 
@@ -86,11 +99,11 @@ ID, login step, or local project setup for the v1 CLI.
86
99
 
87
100
  | Entrypoint | Included source |
88
101
  | --- | --- |
89
- | `urun deploy app.py` | `app.py` only |
90
- | `urun deploy -m my_package.main` | Python files in the containing package |
102
+ | `urun deploy app.py` | `app.py` and local Python files it imports |
91
103
 
92
- Dependency declarations are included when present. `pyproject.toml` is preferred;
93
- `requirements.txt` is used when no `pyproject.toml` exists.
104
+ Dependencies are declared in your urun app code. Project-level files such as
105
+ `pyproject.toml` and `requirements.txt` are not uploaded as dependency
106
+ declarations by the CLI.
94
107
 
95
108
  Generated/cache content such as `.git`, dotfiles, `__pycache__`, and `.pyc`
96
109
  files is excluded. Add `.urunignore` to exclude additional paths.
@@ -100,12 +113,13 @@ auto-included yet.
100
113
 
101
114
  ## Common options
102
115
 
116
+ Shared by `run` and `deploy`:
117
+
103
118
  | Option | Description |
104
119
  | --- | --- |
105
- | `-m, --module` | Deploy a Python module entrypoint, e.g. `my_package.main`. |
106
120
  | `--name` | Override the derived app name. |
107
- | `--api-url` | Override the API URL; defaults to `https://api.urun.sh/v1`. |
108
- | `--api-key` | API key; prefer `URUN_API_KEY` to avoid shell history leaks. |
121
+ | `--api-url` | Override the API URL; defaults to `URUN_API_URL`, saved login credentials, or `https://api.urun.sh/v1`. |
122
+ | `--api-key` | Deploy API key; defaults to `URUN_API_KEY` or saved login credentials. |
109
123
  | `--no-wait` | Finalize but do not poll for readiness. |
110
124
  | `--poll-interval`, `--timeout` | Control readiness polling. |
111
125
 
@@ -113,11 +127,11 @@ auto-included yet.
113
127
 
114
128
  | Error | Fix |
115
129
  | --- | --- |
116
- | `missing API key` | Set `URUN_API_KEY` or pass `--api-key`. |
130
+ | `missing API key` | Run `urun login`, set `URUN_API_KEY`, or pass `--api-key`. |
117
131
  | `invalid API key format` | Use `urun_<32 lowercase hex chars>`. |
118
132
  | `entrypoint not found` | Run from the project root or pass the entrypoint path. |
119
133
  | `path is outside the project root` | Move the file under the project before deploying. |
120
- | Expected files are missing | Use a module entrypoint for packages; non-Python assets are not auto-included yet. |
134
+ | Expected files are missing | Import local Python files from `app.py`; non-Python assets are not auto-included yet. |
121
135
 
122
136
  ## Development
123
137
 
@@ -126,3 +140,15 @@ Contributing and test instructions are in [CONTRIBUTING.md](CONTRIBUTING.md).
126
140
  ## License
127
141
 
128
142
  MIT.
143
+
144
+ ## Development environment
145
+
146
+ This repo has a Nix/direnv/devcontainer baseline:
147
+
148
+ ```bash
149
+ direnv allow
150
+ just sync
151
+ just check
152
+ ```
153
+
154
+ Use VS Code Dev Containers to open the repository with the same toolchain in a container. Copy `devcontainer.env.example` to `.devcontainer.env` if you need to pass local git identity or other non-secret development settings into the container.
@@ -0,0 +1,132 @@
1
+ # urun CLI
2
+
3
+ Deploy Python apps to urun from your terminal.
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/urun-cli.svg)](https://pypi.org/project/urun-cli/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/urun-cli.svg)](https://pypi.org/project/urun-cli/)
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ uv tool install urun-cli
12
+ # or
13
+ pip install urun-cli
14
+ ```
15
+
16
+ The package installs the `urun` command:
17
+
18
+ ```bash
19
+ urun --version
20
+ ```
21
+
22
+ For one-off `uvx` usage:
23
+
24
+ ```bash
25
+ uvx --from urun-cli urun --version
26
+ # or the package-matching command alias
27
+ uvx urun-cli --version
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ Today, an operator manually vends an org-scoped deploy API key. Save it locally
33
+ with `urun login`:
34
+
35
+ ```bash
36
+ urun login --api-key urun_<32hex>
37
+ ```
38
+
39
+ `urun login` verifies the key with the urun API and stores credentials for later
40
+ commands. The future browser-based login flow is not available in this CLI
41
+ release.
42
+
43
+ For CI or one-off commands, you can still use the environment variable:
44
+
45
+ ```bash
46
+ export URUN_API_KEY=urun_<32hex>
47
+ ```
48
+
49
+ Create `app.py`:
50
+
51
+ ```python
52
+ import urun
53
+ from urun import App
54
+
55
+ app = App("hello-h100")
56
+
57
+
58
+ @app.function(gpus="h100:1")
59
+ def hello(ctx: urun.Context):
60
+ print(f"running on {ctx.device}")
61
+ return {"device": str(ctx.device)}
62
+ ```
63
+
64
+ Run it:
65
+
66
+ ```bash
67
+ urun run app.py
68
+ ```
69
+
70
+ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
71
+ `deploy` remains available as the lower-level command while the full
72
+ deploy/run/monitor workflow is being built.
73
+
74
+ ## What gets deployed
75
+
76
+ `urun deploy` creates a source manifest from your Python entrypoint:
77
+
78
+ | Entrypoint | Included source |
79
+ | --- | --- |
80
+ | `urun deploy app.py` | `app.py` and local Python files it imports |
81
+
82
+ Dependencies are declared in your urun app code. Project-level files such as
83
+ `pyproject.toml` and `requirements.txt` are not uploaded as dependency
84
+ declarations by the CLI.
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
+ Shared by `run` and `deploy`:
95
+
96
+ | Option | Description |
97
+ | --- | --- |
98
+ | `--name` | Override the derived app name. |
99
+ | `--api-url` | Override the API URL; defaults to `URUN_API_URL`, saved login credentials, or `https://api.urun.sh/v1`. |
100
+ | `--api-key` | Deploy API key; defaults to `URUN_API_KEY` or saved login credentials. |
101
+ | `--no-wait` | Finalize but do not poll for readiness. |
102
+ | `--poll-interval`, `--timeout` | Control readiness polling. |
103
+
104
+ ## Troubleshooting
105
+
106
+ | Error | Fix |
107
+ | --- | --- |
108
+ | `missing API key` | Run `urun login`, set `URUN_API_KEY`, or pass `--api-key`. |
109
+ | `invalid API key format` | Use `urun_<32 lowercase hex chars>`. |
110
+ | `entrypoint not found` | Run from the project root or pass the entrypoint path. |
111
+ | `path is outside the project root` | Move the file under the project before deploying. |
112
+ | Expected files are missing | Import local Python files from `app.py`; non-Python assets are not auto-included yet. |
113
+
114
+ ## Development
115
+
116
+ Contributing and test instructions are in [CONTRIBUTING.md](CONTRIBUTING.md).
117
+
118
+ ## License
119
+
120
+ MIT.
121
+
122
+ ## Development environment
123
+
124
+ This repo has a Nix/direnv/devcontainer baseline:
125
+
126
+ ```bash
127
+ direnv allow
128
+ just sync
129
+ just check
130
+ ```
131
+
132
+ Use VS Code Dev Containers to open the repository with the same toolchain in a container. Copy `devcontainer.env.example` to `.devcontainer.env` if you need to pass local git identity or other non-secret development settings into the container.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "urun-cli"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "End-user CLI for deploying apps to urun"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -32,6 +32,9 @@ class ApiClient:
32
32
  def deployment_status(self, manifest_hash: str) -> dict[str, Any]:
33
33
  return self._json("GET", f"/deployment-status/{manifest_hash}", None)
34
34
 
35
+ def org_config(self) -> dict[str, Any]:
36
+ return self._json("GET", "/org-config", None)
37
+
35
38
  def _json(self, method: str, path: str, body: dict[str, Any] | None) -> dict[str, Any]:
36
39
  data = (
37
40
  None
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import os
6
+ import re
7
+ import sys
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import __version__
13
+ from .api import DEFAULT_API_URL, ApiClient, poll_until_done, upload_missing_blobs
14
+ from .config import load_credentials, save_credentials
15
+ from .deps import resolve_deps
16
+ from .discovery import derive_app_name, discover_main_files
17
+ from .errors import UrunError
18
+ from .manifest import manifest_hash, unique_blobs
19
+
20
+ API_KEY_RE = re.compile(r"^urun_[0-9a-f]{32}$")
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 in {"deploy", "run"}:
28
+ return deploy(args)
29
+ if args.command == "login":
30
+ return login(args)
31
+ parser.print_help()
32
+ return 2
33
+ except UrunError as exc:
34
+ print(f"error: {exc}", file=sys.stderr)
35
+ return 1
36
+ except KeyboardInterrupt:
37
+ print("aborted", file=sys.stderr)
38
+ return 130
39
+
40
+
41
+ def build_parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(prog="urun")
43
+ parser.add_argument("--version", action="version", version=f"urun {__version__}")
44
+ sub = parser.add_subparsers(dest="command")
45
+
46
+ login_parser = sub.add_parser(
47
+ "login",
48
+ help="Save manually vended deploy credentials",
49
+ description=(
50
+ "Save a manually vended URUN deploy API key. "
51
+ "Browser-based login is planned but not available in this CLI release."
52
+ ),
53
+ )
54
+ add_login_args(login_parser)
55
+
56
+ deploy = sub.add_parser("deploy", help="Deploy a Python app to urun")
57
+ add_deploy_args(deploy)
58
+
59
+ run = sub.add_parser(
60
+ "run",
61
+ help="Run a Python app on urun",
62
+ description=(
63
+ "Run a Python app on urun. In this release, run uses the deploy pipeline "
64
+ "while the full deploy/run/monitor workflow is still being built."
65
+ ),
66
+ )
67
+ add_deploy_args(run)
68
+ return parser
69
+
70
+
71
+ def add_login_args(parser: argparse.ArgumentParser) -> None:
72
+ parser.add_argument(
73
+ "--api-url",
74
+ help=f"API base URL; defaults to URUN_API_URL or {DEFAULT_API_URL}",
75
+ )
76
+ parser.add_argument(
77
+ "--api-key",
78
+ help="Deploy API key; defaults to URUN_API_KEY, otherwise prompts interactively",
79
+ )
80
+
81
+
82
+ def add_api_args(parser: argparse.ArgumentParser) -> None:
83
+ parser.add_argument(
84
+ "--api-url",
85
+ help=(
86
+ f"API base URL; defaults to URUN_API_URL, saved login credentials, or {DEFAULT_API_URL}"
87
+ ),
88
+ )
89
+ parser.add_argument(
90
+ "--api-key",
91
+ help="Deploy API key; defaults to URUN_API_KEY or saved login credentials",
92
+ )
93
+
94
+
95
+ def add_deploy_args(parser: argparse.ArgumentParser) -> None:
96
+ parser.add_argument("entrypoint", nargs="?", help="Python app file, e.g. app.py")
97
+ parser.add_argument("--name", help="App name (defaults from entrypoint file)")
98
+ add_api_args(parser)
99
+ parser.add_argument(
100
+ "--no-wait", action="store_true", help="Finalize deployment but do not poll for readiness"
101
+ )
102
+ parser.add_argument("--poll-interval", type=float, default=2.0)
103
+ parser.add_argument("--timeout", type=float, default=1800.0)
104
+
105
+
106
+ def login(args: argparse.Namespace) -> int:
107
+ api_url = args.api_url or os.getenv("URUN_API_URL") or DEFAULT_API_URL
108
+ api_key = args.api_key or os.getenv("URUN_API_KEY")
109
+ if not api_key:
110
+ if not sys.stdin.isatty():
111
+ raise UrunError("missing API key; pass --api-key or set URUN_API_KEY")
112
+ api_key = getpass.getpass("URUN API key: ").strip()
113
+
114
+ validate_api_key(api_key)
115
+ config = ApiClient(api_url, api_key).org_config()
116
+ org_id = config_value(config, "org_id")
117
+ key_id = config_value(config, "key_id")
118
+ path = save_credentials(
119
+ {
120
+ "api_url": api_url,
121
+ "api_key": api_key,
122
+ "org_id": org_id,
123
+ "key_id": key_id,
124
+ }
125
+ )
126
+
127
+ print("Authenticated successfully. Credentials saved.")
128
+ print("Login currently uses a manually vended deploy API key.")
129
+ print(f"Org: {org_id}")
130
+ print(f"Key ID: {key_id}")
131
+ print(f"Credentials: {path}")
132
+ return 0
133
+
134
+
135
+ def deploy(args: argparse.Namespace) -> int:
136
+ api_url, api_key = resolve_api_credentials(args)
137
+ if args.poll_interval <= 0:
138
+ raise UrunError("--poll-interval must be greater than 0")
139
+ if args.timeout <= 0:
140
+ raise UrunError("--timeout must be greater than 0")
141
+
142
+ project_root = Path.cwd()
143
+ entrypoint, files = discover_main_files(args.entrypoint, project_root)
144
+ deps, deps_blob, python_version = resolve_deps(project_root)
145
+
146
+ app_name = args.name or derive_app_name(args.entrypoint)
147
+ all_blobs = files + ([deps_blob] if deps_blob is not None else [])
148
+ manifest = {
149
+ "version": 1,
150
+ "app_name": app_name,
151
+ "entrypoint": entrypoint,
152
+ "python_version": python_version,
153
+ "files": [b.manifest_entry() for b in sorted(files, key=lambda b: b.path)],
154
+ "deps": deps,
155
+ "created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
156
+ "client_version": f"urun-cli/{__version__}",
157
+ }
158
+ mh = manifest_hash(manifest)
159
+
160
+ print(f"Deploying {app_name} ({mh[:12]})")
161
+ print(f"Files: {len(files)} app, deps: {deps['kind']}")
162
+
163
+ client = ApiClient(api_url, api_key)
164
+ register = client.register_manifest(manifest)
165
+ server_hash = register.get("manifest_hash")
166
+ if server_hash and server_hash != mh:
167
+ raise UrunError(f"server manifest hash mismatch: local {mh}, server {server_hash}")
168
+ missing = register.get("missing_blobs", [])
169
+ print(f"Uploading {len(missing)} missing blob(s)")
170
+ upload_missing_blobs(missing, unique_blobs(all_blobs))
171
+
172
+ final = client.finalize(mh)
173
+ print(f"Deployment {final.get('status', 'queued')}: {mh}")
174
+ if args.no_wait:
175
+ status_url = register.get("status_url")
176
+ if status_url:
177
+ print(f"Status: {status_url}")
178
+ return 0
179
+
180
+ status = poll_until_done(client, mh, args.poll_interval, args.timeout)
181
+ if status.get("status") == "failed":
182
+ err = status.get("error") or {}
183
+ code = err.get("code")
184
+ message = err.get("message")
185
+ detail = f"{code}: {message}" if code and message else (message or code or "unknown error")
186
+ raise UrunError(f"deployment failed: {detail}")
187
+ print_ready(status)
188
+ return 0
189
+
190
+
191
+ def resolve_api_credentials(args: argparse.Namespace) -> tuple[str, str]:
192
+ api_key = args.api_key or os.getenv("URUN_API_KEY")
193
+ api_url = args.api_url or os.getenv("URUN_API_URL")
194
+ if not api_key or not api_url:
195
+ saved = load_credentials()
196
+ api_key = api_key or string_value(saved.get("api_key"))
197
+ api_url = api_url or string_value(saved.get("api_url"))
198
+
199
+ api_url = api_url or DEFAULT_API_URL
200
+ if not api_key:
201
+ raise UrunError("missing API key; run `urun login`, set URUN_API_KEY, or pass --api-key")
202
+ validate_api_key(api_key)
203
+ return api_url, api_key
204
+
205
+
206
+ def validate_api_key(api_key: str) -> None:
207
+ if not API_KEY_RE.fullmatch(api_key):
208
+ raise UrunError("invalid API key format; expected urun_<32 lowercase hex chars>")
209
+
210
+
211
+ def config_value(config: dict[str, Any], key: str) -> str:
212
+ value = string_value(config.get(key))
213
+ if not value:
214
+ raise UrunError(f"org-config response did not include {key}")
215
+ return value
216
+
217
+
218
+ def string_value(value: Any) -> str | None:
219
+ return value if isinstance(value, str) and value else None
220
+
221
+
222
+ def print_ready(status: dict[str, Any]) -> None:
223
+ print("Deployment ready")
224
+ if status.get("url"):
225
+ print(f"URL: {status['url']}")
226
+
227
+
228
+ if __name__ == "__main__":
229
+ raise SystemExit(main())
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .errors import UrunError
10
+
11
+
12
+ def config_dir() -> Path:
13
+ override = os.getenv("URUN_CONFIG_HOME")
14
+ if override:
15
+ return Path(override).expanduser()
16
+
17
+ xdg = os.getenv("XDG_CONFIG_HOME")
18
+ if xdg:
19
+ return Path(xdg).expanduser() / "urun"
20
+
21
+ return Path.home() / ".config" / "urun"
22
+
23
+
24
+ def credentials_path() -> Path:
25
+ return config_dir() / "credentials.json"
26
+
27
+
28
+ def load_credentials() -> dict[str, Any]:
29
+ path = credentials_path()
30
+ if not path.exists():
31
+ return {}
32
+ try:
33
+ data = json.loads(path.read_text(encoding="utf-8"))
34
+ except OSError as exc:
35
+ raise UrunError(f"could not read credentials file: {path}") from exc
36
+ except json.JSONDecodeError as exc:
37
+ raise UrunError(f"credentials file is not valid JSON: {path}") from exc
38
+ if not isinstance(data, dict):
39
+ raise UrunError(f"credentials file must contain a JSON object: {path}")
40
+ return data
41
+
42
+
43
+ def save_credentials(credentials: dict[str, Any]) -> Path:
44
+ path = credentials_path()
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ payload = {
47
+ **credentials,
48
+ "updated_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
49
+ }
50
+ tmp = path.with_suffix(".json.tmp")
51
+ try:
52
+ tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
53
+ tmp.chmod(0o600)
54
+ tmp.replace(path)
55
+ path.chmod(0o600)
56
+ except OSError as exc:
57
+ raise UrunError(f"could not write credentials file: {path}") from exc
58
+ return path
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ DEFAULT_PYTHON_VERSION = "3.12"
7
+
8
+
9
+ def resolve_deps(_project_root: Path) -> tuple[dict[str, Any], None, str]:
10
+ # Dependencies are declared by the urun app in app.py, not by project-level
11
+ # pyproject.toml or requirements.txt files.
12
+ python_version = DEFAULT_PYTHON_VERSION
13
+ return {"kind": "app", "python_version": python_version}, None, python_version
@@ -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
@@ -1,18 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.1.2
4
-
5
- - Clean the public CLI and docs for the config-free v1 surface.
6
- - Remove legacy spec/config artifacts from the repository and package surface.
7
- - Harden release packaging checks and publish only fresh `urun-cli` artifacts.
8
-
9
- ## 0.1.1
10
-
11
- - Publish the CLI under the `urun-cli` PyPI distribution name.
12
- - Keep both `urun` and `urun-cli` command entrypoints available.
13
-
14
- ## 0.1.0
15
-
16
- - Initial end-user `urun deploy` CLI.
17
- - Source manifest generation, Python file discovery, dependency declaration upload, and API deploy flow.
18
- - Test coverage for local manifest generation, API flow, and presigned upload behavior.
urun_cli-0.1.2/README.md DELETED
@@ -1,106 +0,0 @@
1
- # urun CLI
2
-
3
- Deploy Python apps to urun from your terminal.
4
-
5
- [![PyPI](https://img.shields.io/pypi/v/urun-cli.svg)](https://pypi.org/project/urun-cli/)
6
- [![Python](https://img.shields.io/pypi/pyversions/urun-cli.svg)](https://pypi.org/project/urun-cli/)
7
-
8
- ## Install
9
-
10
- ```bash
11
- uv tool install urun-cli
12
- # or
13
- pip install urun-cli
14
- ```
15
-
16
- The package installs the `urun` command:
17
-
18
- ```bash
19
- urun --version
20
- ```
21
-
22
- For one-off `uvx` usage:
23
-
24
- ```bash
25
- uvx --from urun-cli urun --version
26
- # or the package-matching command alias
27
- uvx urun-cli --version
28
- ```
29
-
30
- ## Quick start
31
-
32
- Set your API key:
33
-
34
- ```bash
35
- export URUN_API_KEY=urun_<32hex>
36
- ```
37
-
38
- Create a small Python app:
39
-
40
- ```bash
41
- cat > app.py <<'PY'
42
- print("hello from urun")
43
- PY
44
- ```
45
-
46
- Deploy it:
47
-
48
- ```bash
49
- urun deploy app.py
50
- ```
51
-
52
- You can also deploy a Python module:
53
-
54
- ```bash
55
- urun deploy -m my_package.main
56
- ```
57
-
58
- The API key identifies your account on the server side. There is no separate org
59
- ID, login step, or local project setup for the v1 CLI.
60
-
61
- ## What gets deployed
62
-
63
- `urun deploy` creates a source manifest from your Python entrypoint:
64
-
65
- | Entrypoint | Included source |
66
- | --- | --- |
67
- | `urun deploy app.py` | `app.py` only |
68
- | `urun deploy -m my_package.main` | Python files in the containing package |
69
-
70
- Dependency declarations are included when present. `pyproject.toml` is preferred;
71
- `requirements.txt` is used when no `pyproject.toml` exists.
72
-
73
- Generated/cache content such as `.git`, dotfiles, `__pycache__`, and `.pyc`
74
- files is excluded. Add `.urunignore` to exclude additional paths.
75
-
76
- Non-Python assets such as templates, static files, and data files are not
77
- auto-included yet.
78
-
79
- ## Common options
80
-
81
- | Option | Description |
82
- | --- | --- |
83
- | `-m, --module` | Deploy a Python module entrypoint, e.g. `my_package.main`. |
84
- | `--name` | Override the derived app name. |
85
- | `--api-url` | Override the API URL; defaults to `https://api.urun.sh/v1`. |
86
- | `--api-key` | API key; prefer `URUN_API_KEY` to avoid shell history leaks. |
87
- | `--no-wait` | Finalize but do not poll for readiness. |
88
- | `--poll-interval`, `--timeout` | Control readiness polling. |
89
-
90
- ## Troubleshooting
91
-
92
- | Error | Fix |
93
- | --- | --- |
94
- | `missing API key` | Set `URUN_API_KEY` or pass `--api-key`. |
95
- | `invalid API key format` | Use `urun_<32 lowercase hex chars>`. |
96
- | `entrypoint not found` | Run from the project root or pass the entrypoint path. |
97
- | `path is outside the project root` | Move the file under the project before deploying. |
98
- | Expected files are missing | Use a module entrypoint for packages; non-Python assets are not auto-included yet. |
99
-
100
- ## Development
101
-
102
- Contributing and test instructions are in [CONTRIBUTING.md](CONTRIBUTING.md).
103
-
104
- ## License
105
-
106
- MIT.
@@ -1,126 +0,0 @@
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
- target = deploy.add_mutually_exclusive_group()
43
- target.add_argument("entrypoint", nargs="?", help="Python file entrypoint, e.g. app.py")
44
- target.add_argument("-m", "--module", help="Python module entrypoint, e.g. myapp.main")
45
- deploy.add_argument("--name", help="App name (defaults from entrypoint/module)")
46
- deploy.add_argument(
47
- "--api-url", default=os.getenv("URUN_API_URL", DEFAULT_API_URL), help="API base URL"
48
- )
49
- deploy.add_argument(
50
- "--api-key", default=os.getenv("URUN_API_KEY"), help="API key (or URUN_API_KEY)"
51
- )
52
- deploy.add_argument(
53
- "--no-wait", action="store_true", help="Finalize deployment but do not poll for readiness"
54
- )
55
- deploy.add_argument("--poll-interval", type=float, default=2.0)
56
- deploy.add_argument("--timeout", type=float, default=1800.0)
57
- return parser
58
-
59
-
60
- def deploy(args: argparse.Namespace) -> int:
61
- if not args.api_key:
62
- raise UrunError("missing API key; set URUN_API_KEY or pass --api-key")
63
- if not API_KEY_RE.fullmatch(args.api_key):
64
- raise UrunError("invalid API key format; expected urun_<32 lowercase hex chars>")
65
- if args.poll_interval <= 0:
66
- raise UrunError("--poll-interval must be greater than 0")
67
- if args.timeout <= 0:
68
- raise UrunError("--timeout must be greater than 0")
69
-
70
- project_root = Path.cwd()
71
- entrypoint, files = discover_main_files(args.entrypoint, args.module, project_root)
72
- deps, deps_blob, python_version = resolve_deps(project_root)
73
-
74
- app_name = args.name or derive_app_name(args.entrypoint, args.module)
75
- all_blobs = files + ([deps_blob] if deps_blob is not None else [])
76
- manifest = {
77
- "version": 1,
78
- "app_name": app_name,
79
- "entrypoint": entrypoint,
80
- "python_version": python_version,
81
- "files": [b.manifest_entry() for b in sorted(files, key=lambda b: b.path)],
82
- "deps": deps,
83
- "created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
84
- "client_version": f"urun-cli/{__version__}",
85
- }
86
- mh = manifest_hash(manifest)
87
-
88
- print(f"Deploying {app_name} ({mh[:12]})")
89
- print(f"Files: {len(files)} app, deps: {deps['kind']}")
90
-
91
- client = ApiClient(args.api_url, args.api_key)
92
- register = client.register_manifest(manifest)
93
- server_hash = register.get("manifest_hash")
94
- if server_hash and server_hash != mh:
95
- raise UrunError(f"server manifest hash mismatch: local {mh}, server {server_hash}")
96
- missing = register.get("missing_blobs", [])
97
- print(f"Uploading {len(missing)} missing blob(s)")
98
- upload_missing_blobs(missing, unique_blobs(all_blobs))
99
-
100
- final = client.finalize(mh)
101
- print(f"Deployment {final.get('status', 'queued')}: {mh}")
102
- if args.no_wait:
103
- status_url = register.get("status_url")
104
- if status_url:
105
- print(f"Status: {status_url}")
106
- return 0
107
-
108
- status = poll_until_done(client, mh, args.poll_interval, args.timeout)
109
- if status.get("status") == "failed":
110
- err = status.get("error") or {}
111
- code = err.get("code")
112
- message = err.get("message")
113
- detail = f"{code}: {message}" if code and message else (message or code or "unknown error")
114
- raise UrunError(f"deployment failed: {detail}")
115
- print_ready(status)
116
- return 0
117
-
118
-
119
- def print_ready(status: dict[str, Any]) -> None:
120
- print("Deployment ready")
121
- if status.get("url"):
122
- print(f"URL: {status['url']}")
123
-
124
-
125
- if __name__ == "__main__":
126
- raise SystemExit(main())
@@ -1,80 +0,0 @@
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}"
@@ -1,144 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import fnmatch
4
- import importlib.util
5
- import sys
6
- from pathlib import Path
7
-
8
- from .errors import UrunError
9
- from .manifest import FileBlob, file_blob
10
-
11
- MAX_AUTO_INCLUDE_BYTES = 50 * 1024 * 1024
12
-
13
-
14
- def derive_app_name(entrypoint: str | None, module: str | None) -> str:
15
- if module:
16
- return module.split(".")[0]
17
- if not entrypoint:
18
- raise UrunError("missing entrypoint")
19
- p = Path(entrypoint)
20
- if p.name == "__main__.py" and p.parent.name:
21
- return p.parent.name
22
- if p.parent != Path("."):
23
- return p.parent.name
24
- return p.stem
25
-
26
-
27
- def package_root_for(entrypoint: Path) -> Path | None:
28
- current = entrypoint.resolve().parent
29
- if not (current / "__init__.py").exists():
30
- return None
31
- root = current
32
- while (root.parent / "__init__.py").exists():
33
- root = root.parent
34
- return root
35
-
36
-
37
- def load_urunignore(root: Path) -> list[str]:
38
- ignore = root / ".urunignore"
39
- if not ignore.exists():
40
- return []
41
- return [
42
- line.strip()
43
- for line in ignore.read_text().splitlines()
44
- if line.strip() and not line.lstrip().startswith("#")
45
- ]
46
-
47
-
48
- def is_ignored(path: Path, project_root: Path, patterns: list[str]) -> bool:
49
- try:
50
- rel = path.resolve().relative_to(project_root.resolve()).as_posix()
51
- except ValueError as exc:
52
- raise UrunError(f"path is outside the project root: {path}") from exc
53
- parts = rel.split("/")
54
- if any(part == "__pycache__" for part in parts):
55
- return True
56
- if any(part == ".git" for part in parts):
57
- return True
58
- if path.name.startswith(".") or any(part.startswith(".") for part in parts):
59
- return True
60
- if path.suffix == ".pyc":
61
- return True
62
- return any(
63
- fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(path.name, pattern) for pattern in patterns
64
- )
65
-
66
-
67
- def auto_include(entrypoint: Path, project_root: Path) -> list[FileBlob]:
68
- if not entrypoint.exists() or not entrypoint.is_file():
69
- raise UrunError(f"entrypoint not found: {entrypoint}")
70
- if entrypoint.suffix != ".py":
71
- raise UrunError("entrypoint must be a .py file")
72
- patterns = load_urunignore(project_root)
73
- pkg = package_root_for(entrypoint)
74
- files: list[Path]
75
- if pkg:
76
- files = sorted(
77
- p
78
- for p in pkg.rglob("*.py")
79
- if p.is_file() and not is_ignored(p, project_root, patterns)
80
- )
81
- total = sum(p.stat().st_size for p in files)
82
- if total > MAX_AUTO_INCLUDE_BYTES:
83
- raise UrunError(
84
- f"auto-included package {pkg} is {total} bytes, above 50 MB; "
85
- "add exclusions with .urunignore"
86
- )
87
- else:
88
- if is_ignored(entrypoint, project_root, patterns):
89
- raise UrunError(f"entrypoint is ignored by defaults or .urunignore: {entrypoint}")
90
- files = [entrypoint]
91
- blobs: list[FileBlob] = []
92
- for p in files:
93
- try:
94
- blobs.append(file_blob(p, project_root))
95
- except ValueError as exc:
96
- raise UrunError(f"path is outside the project root: {p}") from exc
97
- return blobs
98
-
99
-
100
- def discover_main_files(
101
- entrypoint_arg: str | None, module_arg: str | None, project_root: Path
102
- ) -> tuple[str, list[FileBlob]]:
103
- ensure_project_on_path(project_root)
104
- if module_arg:
105
- spec = importlib.util.find_spec(module_arg)
106
- if spec is None or spec.origin is None:
107
- raise UrunError(f"module not found: {module_arg}")
108
- entrypoint_path = ensure_under_root(Path(spec.origin), project_root, "module")
109
- elif entrypoint_arg:
110
- entrypoint_path = Path(entrypoint_arg)
111
- else:
112
- raise UrunError("provide an entrypoint or -m module")
113
- if not entrypoint_path.is_absolute():
114
- entrypoint_path = project_root / entrypoint_path
115
- files = auto_include(entrypoint_path, project_root)
116
- try:
117
- manifest_entrypoint = (
118
- module_arg
119
- if module_arg
120
- else entrypoint_path.resolve().relative_to(project_root.resolve()).as_posix()
121
- )
122
- except ValueError as exc:
123
- raise UrunError(f"entrypoint is outside the project root: {entrypoint_path}") from exc
124
- return manifest_entrypoint, files
125
-
126
-
127
- def ensure_project_on_path(project_root: Path) -> None:
128
- candidates = [project_root.resolve()]
129
- src = project_root / "src"
130
- if src.is_dir():
131
- candidates.append(src.resolve())
132
- for candidate in reversed(candidates):
133
- path = str(candidate)
134
- if path not in sys.path:
135
- sys.path.insert(0, path)
136
-
137
-
138
- def ensure_under_root(path: Path, project_root: Path, label: str = "path") -> Path:
139
- resolved = path.resolve()
140
- try:
141
- resolved.relative_to(project_root.resolve())
142
- except ValueError as exc:
143
- raise UrunError(f"{label} is outside the project root: {path}") from exc
144
- return resolved
File without changes
File without changes
File without changes
File without changes
File without changes