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.
- {urun_cli-0.1.2 → urun_cli-0.2.0}/.gitignore +4 -0
- urun_cli-0.2.0/CHANGELOG.md +14 -0
- {urun_cli-0.1.2 → urun_cli-0.2.0}/PKG-INFO +49 -23
- urun_cli-0.2.0/README.md +132 -0
- {urun_cli-0.1.2 → urun_cli-0.2.0}/pyproject.toml +1 -1
- {urun_cli-0.1.2 → urun_cli-0.2.0}/src/urun/api.py +3 -0
- urun_cli-0.2.0/src/urun/cli.py +229 -0
- urun_cli-0.2.0/src/urun/config.py +58 -0
- urun_cli-0.2.0/src/urun/deps.py +13 -0
- urun_cli-0.2.0/src/urun/discovery.py +170 -0
- urun_cli-0.1.2/CHANGELOG.md +0 -18
- urun_cli-0.1.2/README.md +0 -106
- urun_cli-0.1.2/src/urun/cli.py +0 -126
- urun_cli-0.1.2/src/urun/deps.py +0 -80
- urun_cli-0.1.2/src/urun/discovery.py +0 -144
- {urun_cli-0.1.2 → urun_cli-0.2.0}/LICENSE +0 -0
- {urun_cli-0.1.2 → urun_cli-0.2.0}/SECURITY.md +0 -0
- {urun_cli-0.1.2 → urun_cli-0.2.0}/src/urun/__init__.py +0 -0
- {urun_cli-0.1.2 → urun_cli-0.2.0}/src/urun/errors.py +0 -0
- {urun_cli-0.1.2 → urun_cli-0.2.0}/src/urun/manifest.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
urun login --api-key urun_<32hex>
|
|
58
59
|
```
|
|
59
60
|
|
|
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:
|
|
61
66
|
|
|
62
67
|
```bash
|
|
63
|
-
|
|
64
|
-
print("hello from urun")
|
|
65
|
-
PY
|
|
68
|
+
export URUN_API_KEY=urun_<32hex>
|
|
66
69
|
```
|
|
67
70
|
|
|
68
|
-
|
|
71
|
+
Create `app.py`:
|
|
69
72
|
|
|
70
|
-
```
|
|
71
|
-
urun
|
|
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
|
-
|
|
86
|
+
Run it:
|
|
75
87
|
|
|
76
88
|
```bash
|
|
77
|
-
urun
|
|
89
|
+
urun run app.py
|
|
78
90
|
```
|
|
79
91
|
|
|
80
|
-
|
|
81
|
-
|
|
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`
|
|
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
|
-
|
|
93
|
-
`requirements.txt`
|
|
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;
|
|
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` |
|
|
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 |
|
|
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.
|
urun_cli-0.2.0/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# urun CLI
|
|
2
|
+
|
|
3
|
+
Deploy Python apps to urun from your terminal.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/urun-cli/)
|
|
6
|
+
[](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.
|
|
@@ -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
|
urun_cli-0.1.2/CHANGELOG.md
DELETED
|
@@ -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
|
-
[](https://pypi.org/project/urun-cli/)
|
|
6
|
-
[](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.
|
urun_cli-0.1.2/src/urun/cli.py
DELETED
|
@@ -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())
|
urun_cli-0.1.2/src/urun/deps.py
DELETED
|
@@ -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
|