urun-cli 0.1.1__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.
Files changed (57) hide show
  1. {urun_cli-0.1.1 → urun_cli-0.2.0}/.gitignore +4 -0
  2. urun_cli-0.2.0/CHANGELOG.md +14 -0
  3. urun_cli-0.2.0/PKG-INFO +154 -0
  4. urun_cli-0.2.0/README.md +132 -0
  5. {urun_cli-0.1.1 → urun_cli-0.2.0}/SECURITY.md +2 -6
  6. {urun_cli-0.1.1 → urun_cli-0.2.0}/pyproject.toml +12 -2
  7. urun_cli-0.2.0/src/urun/__init__.py +22 -0
  8. {urun_cli-0.1.1 → urun_cli-0.2.0}/src/urun/api.py +3 -0
  9. urun_cli-0.2.0/src/urun/cli.py +229 -0
  10. urun_cli-0.2.0/src/urun/config.py +58 -0
  11. urun_cli-0.2.0/src/urun/deps.py +13 -0
  12. urun_cli-0.2.0/src/urun/discovery.py +170 -0
  13. urun_cli-0.1.1/.dagger-pipeline.yaml +0 -5
  14. urun_cli-0.1.1/.env.example +0 -11
  15. urun_cli-0.1.1/.envrc +0 -44
  16. urun_cli-0.1.1/.github/CODEOWNERS +0 -3
  17. urun_cli-0.1.1/.github/actionlint.yaml +0 -4
  18. urun_cli-0.1.1/.github/dependabot.yml +0 -10
  19. urun_cli-0.1.1/.github/workflows/ci.yml +0 -87
  20. urun_cli-0.1.1/.github/workflows/precommit.yaml +0 -80
  21. urun_cli-0.1.1/.github/workflows/publish.yaml +0 -31
  22. urun_cli-0.1.1/.github/workflows/release.yaml +0 -46
  23. urun_cli-0.1.1/.markdownlint-cli2.yaml +0 -15
  24. urun_cli-0.1.1/.pre-commit-config.yaml +0 -64
  25. urun_cli-0.1.1/CHANGELOG.md +0 -7
  26. urun_cli-0.1.1/CONTRIBUTING.md +0 -51
  27. urun_cli-0.1.1/PKG-INFO +0 -130
  28. urun_cli-0.1.1/README.md +0 -108
  29. urun_cli-0.1.1/SPEC.md +0 -467
  30. urun_cli-0.1.1/SPEC_EXTERNAL.md +0 -773
  31. urun_cli-0.1.1/SPEC_TRACKING.md +0 -88
  32. urun_cli-0.1.1/compose.minio.yml +0 -21
  33. urun_cli-0.1.1/flake.lock +0 -82
  34. urun_cli-0.1.1/flake.nix +0 -33
  35. urun_cli-0.1.1/src/urun/__init__.py +0 -1
  36. urun_cli-0.1.1/src/urun/cli.py +0 -148
  37. urun_cli-0.1.1/src/urun/config.py +0 -115
  38. urun_cli-0.1.1/src/urun/deps.py +0 -92
  39. urun_cli-0.1.1/src/urun/discovery.py +0 -204
  40. urun_cli-0.1.1/tests/test_api.py +0 -17
  41. urun_cli-0.1.1/tests/test_api_coverage.py +0 -188
  42. urun_cli-0.1.1/tests/test_api_errors.py +0 -96
  43. urun_cli-0.1.1/tests/test_cli_coverage.py +0 -136
  44. urun_cli-0.1.1/tests/test_config_coverage.py +0 -55
  45. urun_cli-0.1.1/tests/test_config_deps.py +0 -34
  46. urun_cli-0.1.1/tests/test_deps_coverage.py +0 -50
  47. urun_cli-0.1.1/tests/test_discovery.py +0 -37
  48. urun_cli-0.1.1/tests/test_discovery_coverage.py +0 -175
  49. urun_cli-0.1.1/tests/test_http_flow.py +0 -209
  50. urun_cli-0.1.1/tests/test_manifest.py +0 -20
  51. urun_cli-0.1.1/tests/test_minio_integration.py +0 -247
  52. urun_cli-0.1.1/tests/test_path_policy.py +0 -58
  53. urun_cli-0.1.1/tests/test_prod_hardening.py +0 -214
  54. urun_cli-0.1.1/uv.lock +0 -1085
  55. {urun_cli-0.1.1 → urun_cli-0.2.0}/LICENSE +0 -0
  56. {urun_cli-0.1.1 → urun_cli-0.2.0}/src/urun/errors.py +0 -0
  57. {urun_cli-0.1.1 → urun_cli-0.2.0}/src/urun/manifest.py +0 -0
@@ -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.
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: urun-cli
3
+ Version: 0.2.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
+ Today, an operator manually vends an org-scoped deploy API key. Save it locally
55
+ with `urun login`:
56
+
57
+ ```bash
58
+ urun login --api-key urun_<32hex>
59
+ ```
60
+
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:
66
+
67
+ ```bash
68
+ export URUN_API_KEY=urun_<32hex>
69
+ ```
70
+
71
+ Create `app.py`:
72
+
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)}
84
+ ```
85
+
86
+ Run it:
87
+
88
+ ```bash
89
+ urun run app.py
90
+ ```
91
+
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.
95
+
96
+ ## What gets deployed
97
+
98
+ `urun deploy` creates a source manifest from your Python entrypoint:
99
+
100
+ | Entrypoint | Included source |
101
+ | --- | --- |
102
+ | `urun deploy app.py` | `app.py` and local Python files it imports |
103
+
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.
107
+
108
+ Generated/cache content such as `.git`, dotfiles, `__pycache__`, and `.pyc`
109
+ files is excluded. Add `.urunignore` to exclude additional paths.
110
+
111
+ Non-Python assets such as templates, static files, and data files are not
112
+ auto-included yet.
113
+
114
+ ## Common options
115
+
116
+ Shared by `run` and `deploy`:
117
+
118
+ | Option | Description |
119
+ | --- | --- |
120
+ | `--name` | Override the derived app name. |
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. |
123
+ | `--no-wait` | Finalize but do not poll for readiness. |
124
+ | `--poll-interval`, `--timeout` | Control readiness polling. |
125
+
126
+ ## Troubleshooting
127
+
128
+ | Error | Fix |
129
+ | --- | --- |
130
+ | `missing API key` | Run `urun login`, set `URUN_API_KEY`, or pass `--api-key`. |
131
+ | `invalid API key format` | Use `urun_<32 lowercase hex chars>`. |
132
+ | `entrypoint not found` | Run from the project root or pass the entrypoint path. |
133
+ | `path is outside the project root` | Move the file under the project before deploying. |
134
+ | Expected files are missing | Import local Python files from `app.py`; non-Python assets are not auto-included yet. |
135
+
136
+ ## Development
137
+
138
+ Contributing and test instructions are in [CONTRIBUTING.md](CONTRIBUTING.md).
139
+
140
+ ## License
141
+
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.
@@ -12,10 +12,6 @@ Please report suspected vulnerabilities privately to the urun maintainers. Do no
12
12
 
13
13
  ## Source upload behavior
14
14
 
15
- `urun deploy` uploads selected source files and dependency declaration files to urun via presigned object-store URLs. Review `.urunignore` and `urun.toml` before deploying sensitive repositories.
15
+ `urun deploy` uploads selected source files and dependency declaration files to urun via presigned object-store URLs. Review `.urunignore` before deploying sensitive repositories.
16
16
 
17
- The CLI enforces project-root containment for entrypoints, explicit mounts, and dependency files. Symlinks are resolved before that containment check.
18
-
19
- ## Local MinIO
20
-
21
- MinIO is optional for local S3-compatible integration tests. The documented `minioadmin` credentials are development-only and must not be used for production services.
17
+ The CLI enforces project-root containment for entrypoints and dependency files. Symlinks are resolved before that containment check.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "urun-cli"
7
- version = "0.1.1"
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"
@@ -74,9 +74,19 @@ type = "github"
74
74
  ignore_token_for_push = false
75
75
 
76
76
  [tool.semantic_release.publish]
77
- dist_glob_patterns = ["dist/*"]
77
+ dist_glob_patterns = ["dist/urun_cli-*"]
78
78
  upload_to_vcs_release = true
79
79
 
80
+ [tool.hatch.build.targets.sdist]
81
+ include = [
82
+ "/src",
83
+ "/README.md",
84
+ "/LICENSE",
85
+ "/SECURITY.md",
86
+ "/CHANGELOG.md",
87
+ "/pyproject.toml",
88
+ ]
89
+
80
90
  [tool.hatch.build.targets.wheel]
81
91
  packages = ["src/urun"]
82
92
 
@@ -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()
@@ -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