djangocloud-cli 0.1.1__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.
- djangocloud_cli-0.1.1/.github/workflows/publish.yml +31 -0
- djangocloud_cli-0.1.1/.github/workflows/test.yml +15 -0
- djangocloud_cli-0.1.1/.gitignore +11 -0
- djangocloud_cli-0.1.1/LICENSE +21 -0
- djangocloud_cli-0.1.1/PKG-INFO +93 -0
- djangocloud_cli-0.1.1/README.md +62 -0
- djangocloud_cli-0.1.1/pyproject.toml +62 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/__init__.py +6 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/_version.py +24 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/api.py +48 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/auth.py +37 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/cli.py +233 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/config.py +52 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/link.py +51 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/management/__init__.py +0 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/management/commands/__init__.py +0 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/management/commands/djangocloud.py +16 -0
- djangocloud_cli-0.1.1/src/djangocloud_cli/ui.py +64 -0
- djangocloud_cli-0.1.1/tests/conftest.py +96 -0
- djangocloud_cli-0.1.1/tests/test_cli.py +195 -0
- djangocloud_cli-0.1.1/tests/test_manage_py.py +42 -0
- djangocloud_cli-0.1.1/uv.lock +357 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Publishes to PyPI when a GitHub Release is published. Uses PyPI "trusted publishing" (OIDC), so no
|
|
2
|
+
# API token is stored anywhere. One-time setup: on pypi.org add a trusted publisher for this repository,
|
|
3
|
+
# workflow "publish.yml", environment "pypi".
|
|
4
|
+
name: publish
|
|
5
|
+
on:
|
|
6
|
+
release:
|
|
7
|
+
types: [published]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
publish:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
environment: pypi
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read # a job-level permissions block drops the defaults; checkout of a private repo needs this
|
|
15
|
+
id-token: write # trusted publishing
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
with:
|
|
19
|
+
fetch-depth: 0 # the version is read from git tags
|
|
20
|
+
- uses: astral-sh/setup-uv@v5
|
|
21
|
+
- run: uv build
|
|
22
|
+
- name: Built version must match the release tag
|
|
23
|
+
run: |
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
expected="${GITHUB_REF_NAME#v}"
|
|
26
|
+
built=$(ls dist/*.whl | sed -E 's#.*/[^-]+-([^-]+)-.*#\1#')
|
|
27
|
+
if [ "$built" != "$expected" ]; then
|
|
28
|
+
echo "::error::Built version $built does not match tag ${GITHUB_REF_NAME}"
|
|
29
|
+
exit 1
|
|
30
|
+
fi
|
|
31
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python: ["3.10", "3.13"]
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: astral-sh/setup-uv@v5
|
|
12
|
+
- run: uv python install ${{ matrix.python }}
|
|
13
|
+
- run: uv sync --python ${{ matrix.python }}
|
|
14
|
+
- run: uv run ruff check
|
|
15
|
+
- run: uv run pytest
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter van Doorn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: djangocloud-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Deploy a Django app from the command line: djangocloud login, djangocloud deploy, djangocloud logs.
|
|
5
|
+
Project-URL: Homepage, https://djangocloud.dev
|
|
6
|
+
Project-URL: Source, https://github.com/two-trick-pony-NL/djangocloud-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/two-trick-pony-NL/djangocloud-cli/issues
|
|
8
|
+
Author-email: Peter van Doorn <peter@petervandoorn.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: aws,cli,deploy,django,lightsail
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Framework :: Django :: 5.2
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Topic :: Software Development
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: questionary>=2.0
|
|
29
|
+
Requires-Dist: rich>=13
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# djangocloud-cli
|
|
33
|
+
|
|
34
|
+
Command-line client for DjangoCloud. Standard library only.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
pip install djangocloud-cli # then add "djangocloud_cli" to INSTALLED_APPS
|
|
38
|
+
python manage.py djangocloud login
|
|
39
|
+
python manage.py djangocloud deploy
|
|
40
|
+
python manage.py djangocloud logs -f
|
|
41
|
+
python manage.py djangocloud help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A standalone `djangocloud` script is installed too (same commands, no `manage.py`), for when your settings won't load.
|
|
45
|
+
|
|
46
|
+
**Status:** early. `help`, `login`, `logout`, `whoami`, `link`, `unlink` and the interactive setup in `deploy` work
|
|
47
|
+
(they need the DjangoCloud API). Uploading, building and deploying, `logs` and `status` are next.
|
|
48
|
+
|
|
49
|
+
## First run
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
$ python manage.py djangocloud deploy
|
|
53
|
+
You're not signed in yet.
|
|
54
|
+
Open https://djangocloud.dev/dashboard/cli/?code=ABCD-EFGH and check that the code is ABCD-EFGH.
|
|
55
|
+
✓ Signed in as you@example.com
|
|
56
|
+
? Which project is this? (arrow keys)
|
|
57
|
+
? Server size Nano 0.25 vCPU, 0.5 GB RAM $10/month
|
|
58
|
+
? This will cost $10 per month. Continue? Yes
|
|
59
|
+
✓ Linked to my-shop (.djangocloud/config.json)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`.djangocloud/config.json` says which project this folder deploys to. It holds no secrets and is git-ignored by a
|
|
63
|
+
`.gitignore` inside the folder. Later runs skip every prompt.
|
|
64
|
+
|
|
65
|
+
## CI (GitHub Actions)
|
|
66
|
+
|
|
67
|
+
Create a token under **Command line → Token for CI** in the dashboard and store it as the repository secret
|
|
68
|
+
`DJANGOCLOUD_TOKEN`. `--no-input` makes the CLI fail with a clear message instead of ever prompting or opening a browser.
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
- run: pip install djangocloud-cli
|
|
72
|
+
- run: python manage.py djangocloud deploy --no-input --project my-shop
|
|
73
|
+
env:
|
|
74
|
+
DJANGOCLOUD_TOKEN: ${{ secrets.DJANGOCLOUD_TOKEN }}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
| Variable | Purpose |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `DJANGOCLOUD_TOKEN` | API token for CI; takes precedence over the stored login |
|
|
82
|
+
| `DJANGOCLOUD_NO_INPUT` | Same as `--no-input` (set it to `1`) |
|
|
83
|
+
| `DJANGOCLOUD_API` | API base URL (default `https://djangocloud.dev/api/v1`) |
|
|
84
|
+
|
|
85
|
+
The token from `login` is stored in `~/.config/djangocloud/credentials.json` (mode 600).
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
uv sync
|
|
91
|
+
uv run pytest
|
|
92
|
+
uv run ruff check
|
|
93
|
+
```
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# djangocloud-cli
|
|
2
|
+
|
|
3
|
+
Command-line client for DjangoCloud. Standard library only.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install djangocloud-cli # then add "djangocloud_cli" to INSTALLED_APPS
|
|
7
|
+
python manage.py djangocloud login
|
|
8
|
+
python manage.py djangocloud deploy
|
|
9
|
+
python manage.py djangocloud logs -f
|
|
10
|
+
python manage.py djangocloud help
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
A standalone `djangocloud` script is installed too (same commands, no `manage.py`), for when your settings won't load.
|
|
14
|
+
|
|
15
|
+
**Status:** early. `help`, `login`, `logout`, `whoami`, `link`, `unlink` and the interactive setup in `deploy` work
|
|
16
|
+
(they need the DjangoCloud API). Uploading, building and deploying, `logs` and `status` are next.
|
|
17
|
+
|
|
18
|
+
## First run
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
$ python manage.py djangocloud deploy
|
|
22
|
+
You're not signed in yet.
|
|
23
|
+
Open https://djangocloud.dev/dashboard/cli/?code=ABCD-EFGH and check that the code is ABCD-EFGH.
|
|
24
|
+
✓ Signed in as you@example.com
|
|
25
|
+
? Which project is this? (arrow keys)
|
|
26
|
+
? Server size Nano 0.25 vCPU, 0.5 GB RAM $10/month
|
|
27
|
+
? This will cost $10 per month. Continue? Yes
|
|
28
|
+
✓ Linked to my-shop (.djangocloud/config.json)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`.djangocloud/config.json` says which project this folder deploys to. It holds no secrets and is git-ignored by a
|
|
32
|
+
`.gitignore` inside the folder. Later runs skip every prompt.
|
|
33
|
+
|
|
34
|
+
## CI (GitHub Actions)
|
|
35
|
+
|
|
36
|
+
Create a token under **Command line → Token for CI** in the dashboard and store it as the repository secret
|
|
37
|
+
`DJANGOCLOUD_TOKEN`. `--no-input` makes the CLI fail with a clear message instead of ever prompting or opening a browser.
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
- run: pip install djangocloud-cli
|
|
41
|
+
- run: python manage.py djangocloud deploy --no-input --project my-shop
|
|
42
|
+
env:
|
|
43
|
+
DJANGOCLOUD_TOKEN: ${{ secrets.DJANGOCLOUD_TOKEN }}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
| Variable | Purpose |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `DJANGOCLOUD_TOKEN` | API token for CI; takes precedence over the stored login |
|
|
51
|
+
| `DJANGOCLOUD_NO_INPUT` | Same as `--no-input` (set it to `1`) |
|
|
52
|
+
| `DJANGOCLOUD_API` | API base URL (default `https://djangocloud.dev/api/v1`) |
|
|
53
|
+
|
|
54
|
+
The token from `login` is stored in `~/.config/djangocloud/credentials.json` (mode 600).
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
uv sync
|
|
60
|
+
uv run pytest
|
|
61
|
+
uv run ruff check
|
|
62
|
+
```
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "djangocloud-cli"
|
|
3
|
+
dynamic = ["version"] # from the git tag (hatch-vcs): tag v0.2.0 -> version 0.2.0
|
|
4
|
+
description = "Deploy a Django app from the command line: djangocloud login, djangocloud deploy, djangocloud logs."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = ["rich>=13", "questionary>=2.0"]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
authors = [{ name = "Peter van Doorn", email = "peter@petervandoorn.com" }]
|
|
11
|
+
keywords = ["django", "deploy", "cli", "aws", "lightsail"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Framework :: Django",
|
|
16
|
+
"Framework :: Django :: 4.2",
|
|
17
|
+
"Framework :: Django :: 5.0",
|
|
18
|
+
"Framework :: Django :: 5.1",
|
|
19
|
+
"Framework :: Django :: 5.2",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Software Development",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://djangocloud.dev"
|
|
32
|
+
Source = "https://github.com/two-trick-pony-NL/djangocloud-cli"
|
|
33
|
+
Issues = "https://github.com/two-trick-pony-NL/djangocloud-cli/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
djangocloud = "djangocloud_cli.cli:main"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.version]
|
|
43
|
+
source = "vcs"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.hooks.vcs]
|
|
46
|
+
version-file = "src/djangocloud_cli/_version.py"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/djangocloud_cli"]
|
|
50
|
+
|
|
51
|
+
[dependency-groups]
|
|
52
|
+
dev = ["pytest>=8", "ruff>=0.6", "django>=4.2"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
line-length = 120
|
|
59
|
+
target-version = "py310"
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint]
|
|
62
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Minimal JSON client for the DjangoCloud API (urllib: no HTTP dependency to conflict with)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
|
|
9
|
+
TIMEOUT = 30
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApiError(Exception):
|
|
13
|
+
def __init__(self, status: int, code: str, message: str):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status, self.code, self.message = status, code, message
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Client:
|
|
19
|
+
def __init__(self, base_url: str, token: str | None = None):
|
|
20
|
+
self.base_url = base_url.rstrip("/")
|
|
21
|
+
self.token = token
|
|
22
|
+
|
|
23
|
+
def request(self, method: str, path: str, data: dict | None = None) -> dict:
|
|
24
|
+
headers = {"Accept": "application/json", "User-Agent": f"djangocloud-cli/{__version__}"}
|
|
25
|
+
body = None
|
|
26
|
+
if data is not None:
|
|
27
|
+
body = json.dumps(data).encode()
|
|
28
|
+
headers["Content-Type"] = "application/json"
|
|
29
|
+
if self.token:
|
|
30
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
31
|
+
req = urllib.request.Request(self.base_url + path, data=body, headers=headers, method=method)
|
|
32
|
+
try:
|
|
33
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as response:
|
|
34
|
+
return json.loads(response.read() or b"{}")
|
|
35
|
+
except urllib.error.HTTPError as exc:
|
|
36
|
+
try:
|
|
37
|
+
payload = json.loads(exc.read())
|
|
38
|
+
except ValueError:
|
|
39
|
+
payload = {}
|
|
40
|
+
raise ApiError(exc.code, payload.get("error", "http_error"), payload.get("message", exc.reason)) from None
|
|
41
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
42
|
+
raise ApiError(0, "unreachable", f"Couldn't reach {self.base_url}: {getattr(exc, 'reason', exc)}") from None
|
|
43
|
+
|
|
44
|
+
def get(self, path: str) -> dict:
|
|
45
|
+
return self.request("GET", path)
|
|
46
|
+
|
|
47
|
+
def post(self, path: str, data: dict | None = None) -> dict:
|
|
48
|
+
return self.request("POST", path, data or {})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""`djangocloud login`: the device-code flow. No passwords or keys are typed into the terminal."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import webbrowser
|
|
5
|
+
|
|
6
|
+
from . import config
|
|
7
|
+
from .api import ApiError, Client
|
|
8
|
+
from .ui import console
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def login(client: Client, *, sleep=time.sleep, open_browser=webbrowser.open, client_name: str = "cloud CLI") -> str:
|
|
12
|
+
started = client.post("/auth/device", {"client_name": client_name})
|
|
13
|
+
url, code = started["verification_uri_complete"], started["user_code"]
|
|
14
|
+
console.print(f"\nOpen [link={url}]{url}[/link]\nand check that the code is [bold]{code}[/bold].\n")
|
|
15
|
+
try:
|
|
16
|
+
open_browser(url)
|
|
17
|
+
except Exception: # noqa: BLE001 - no browser (SSH, CI) is fine: the URL is printed above
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
interval = started.get("interval", 5)
|
|
21
|
+
deadline = time.monotonic() + started.get("expires_in", 600)
|
|
22
|
+
with console.status("Waiting for you to approve in the browser…"):
|
|
23
|
+
while time.monotonic() < deadline:
|
|
24
|
+
sleep(interval)
|
|
25
|
+
try:
|
|
26
|
+
token = client.post("/auth/token", {"device_code": started["device_code"]})["access_token"]
|
|
27
|
+
except ApiError as exc:
|
|
28
|
+
if exc.code == "authorization_pending":
|
|
29
|
+
continue
|
|
30
|
+
if exc.code == "slow_down":
|
|
31
|
+
interval += 5
|
|
32
|
+
continue
|
|
33
|
+
raise
|
|
34
|
+
config.save_token(token)
|
|
35
|
+
client.token = token
|
|
36
|
+
return token
|
|
37
|
+
raise ApiError(400, "expired_token", "That login expired. Run the login command again.")
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""`djangocloud` command: one parser shared by the standalone script and `manage.py djangocloud`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__, auth, config, link
|
|
7
|
+
from .api import ApiError, Client
|
|
8
|
+
from .ui import NotInteractive, confirm, console, err, interactive, no_input, select, set_no_input, text
|
|
9
|
+
|
|
10
|
+
STANDALONE = "djangocloud"
|
|
11
|
+
MANAGE_PY = "python manage.py djangocloud"
|
|
12
|
+
_prog = MANAGE_PY # what messages tell people to type; set by run() to match how we were invoked
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def say(command: str) -> str:
|
|
16
|
+
"""The command as the user types it, e.g. 'python manage.py djangocloud login'."""
|
|
17
|
+
return f"{_prog} {command}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
COMING_SOON = "'{command}' isn't available yet: the build and deploy pipeline is still being built."
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser(prog: str = STANDALONE) -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(prog=prog, description="Deploy your Django app.")
|
|
25
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--no-input",
|
|
28
|
+
action="store_true",
|
|
29
|
+
help="Never prompt or open a browser (for CI). Needs DJANGOCLOUD_TOKEN and a linked folder or --project",
|
|
30
|
+
)
|
|
31
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
32
|
+
|
|
33
|
+
sub.add_parser("login", help="Sign in (approve a code in your browser)")
|
|
34
|
+
sub.add_parser("logout", help="Forget the stored token")
|
|
35
|
+
sub.add_parser("whoami", help="Show who you are signed in as")
|
|
36
|
+
|
|
37
|
+
linkers = (
|
|
38
|
+
("deploy", "Link this folder to a project, then deploy it"),
|
|
39
|
+
("link", "Pick or create the project this folder deploys to"),
|
|
40
|
+
)
|
|
41
|
+
for name, helptext in linkers:
|
|
42
|
+
cmd = sub.add_parser(name, help=helptext)
|
|
43
|
+
cmd.add_argument("--project", help="Use this existing project (slug) without prompting")
|
|
44
|
+
cmd.add_argument("--name", help="Create a new project with this name without prompting")
|
|
45
|
+
cmd.add_argument("--size", help="Server size for a new project, e.g. nano")
|
|
46
|
+
cmd.add_argument("-y", "--yes", action="store_true", help="Don't ask for confirmation")
|
|
47
|
+
sub.add_parser("unlink", help="Detach this folder from its project")
|
|
48
|
+
sub.add_parser("status", help="Show the current release and its state")
|
|
49
|
+
|
|
50
|
+
logs = sub.add_parser("logs", help="Show a project's logs")
|
|
51
|
+
logs.add_argument("-f", "--follow", action="store_true", help="Keep streaming new lines")
|
|
52
|
+
logs.add_argument("--source", choices=["app", "build", "release"], help="Only this kind of log line")
|
|
53
|
+
logs.add_argument("--since", metavar="DURATION", help="Only lines newer than this, e.g. 2h")
|
|
54
|
+
|
|
55
|
+
helper = sub.add_parser("help", help="Show this help")
|
|
56
|
+
helper.add_argument("topic", nargs="?", help="A command to get help for")
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CliError(Exception):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def make_client() -> Client:
|
|
65
|
+
return Client(config.api_url(), config.load_token())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ensure_login(client: Client) -> None:
|
|
69
|
+
"""Make sure the client holds a token the server accepts, signing in if it doesn't."""
|
|
70
|
+
if client.token:
|
|
71
|
+
try:
|
|
72
|
+
client.get("/me")
|
|
73
|
+
return
|
|
74
|
+
except ApiError as exc:
|
|
75
|
+
if exc.code != "unauthorized":
|
|
76
|
+
raise
|
|
77
|
+
if not interactive():
|
|
78
|
+
raise CliError(
|
|
79
|
+
"Not signed in. In CI set DJANGOCLOUD_TOKEN (create one at /dashboard/cli/); "
|
|
80
|
+
f"on your own machine run '{say('login')}'."
|
|
81
|
+
)
|
|
82
|
+
console.print("You're not signed in yet.")
|
|
83
|
+
auth.login(client)
|
|
84
|
+
console.print(f"[green]✓[/green] Signed in as {client.get('/me')['email']}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _price(cents: int) -> str:
|
|
88
|
+
return f"${cents / 100:,.0f}" if cents % 100 == 0 else f"${cents / 100:,.2f}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _size_label(size: dict) -> str:
|
|
92
|
+
specs = f"{size['vcpu']:g} vCPU, {size['ram_gb']:g} GB RAM"
|
|
93
|
+
return f"{size['label']:<8} {specs:<22} {_price(size['price_cents'])}/month"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ensure_linked(client: Client, root, args, *, force: bool = False) -> dict:
|
|
97
|
+
existing = None if force else link.load(root)
|
|
98
|
+
if existing:
|
|
99
|
+
return existing
|
|
100
|
+
|
|
101
|
+
projects = client.get("/projects")["projects"]
|
|
102
|
+
if args.project:
|
|
103
|
+
match = next((p for p in projects if p["slug"] == args.project), None)
|
|
104
|
+
if match is None:
|
|
105
|
+
raise CliError(f"No project {args.project!r} on your account.")
|
|
106
|
+
project = match
|
|
107
|
+
else:
|
|
108
|
+
choice = args.name or select(
|
|
109
|
+
"Which project is this?",
|
|
110
|
+
[("Create a new project", None)] + [(f"{p['name'] or p['slug']} ({p['slug']})", p) for p in projects],
|
|
111
|
+
)
|
|
112
|
+
project = choice if isinstance(choice, dict) else create_project(client, root, args)
|
|
113
|
+
path = link.save(root, project, config.api_url())
|
|
114
|
+
console.print(f"[green]✓[/green] Linked to [bold]{project['slug']}[/bold] ({path.relative_to(root)})")
|
|
115
|
+
return project
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def create_project(client: Client, root, args) -> dict:
|
|
119
|
+
name = args.name or text("Project name", default=root.name)
|
|
120
|
+
sizes = client.get("/sizes")["sizes"]
|
|
121
|
+
if args.size:
|
|
122
|
+
size = next((s for s in sizes if s["power"] == args.size), None)
|
|
123
|
+
if size is None:
|
|
124
|
+
raise CliError(f"Unknown size {args.size!r}. Choose from: {', '.join(s['power'] for s in sizes)}.")
|
|
125
|
+
else:
|
|
126
|
+
size = select("Server size", [(_size_label(s), s) for s in sizes])
|
|
127
|
+
monthly = _price(size["price_cents"])
|
|
128
|
+
if not (args.yes or no_input()) and not confirm(f"This will cost {monthly} per month. Continue?"):
|
|
129
|
+
raise CliError("Cancelled. Nothing was created.")
|
|
130
|
+
try:
|
|
131
|
+
return client.post("/projects", {"name": name, "power": size["power"]})
|
|
132
|
+
except ApiError as exc:
|
|
133
|
+
if exc.code == "payment_required":
|
|
134
|
+
raise CliError(f"{exc.message}") from None
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_login(args, root) -> int:
|
|
139
|
+
if not interactive():
|
|
140
|
+
raise CliError(f"'{say('login')}' needs a browser. In CI use a DJANGOCLOUD_TOKEN instead.")
|
|
141
|
+
client = make_client()
|
|
142
|
+
auth.login(client)
|
|
143
|
+
console.print(f"[green]✓[/green] Signed in as {client.get('/me')['email']}")
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_whoami(args, root) -> int:
|
|
148
|
+
client = make_client()
|
|
149
|
+
if not client.token:
|
|
150
|
+
console.print(f"Not signed in. Run [bold]{say('login')}[/bold].")
|
|
151
|
+
return 1
|
|
152
|
+
try:
|
|
153
|
+
me = client.get("/me")
|
|
154
|
+
except ApiError as exc:
|
|
155
|
+
if exc.code != "unauthorized":
|
|
156
|
+
raise
|
|
157
|
+
console.print(f"Your token is no longer valid. Run [bold]{say('login')}[/bold].")
|
|
158
|
+
return 1
|
|
159
|
+
console.print(f"Signed in as [bold]{me['email']}[/bold]")
|
|
160
|
+
linked = link.load(root)
|
|
161
|
+
if linked:
|
|
162
|
+
console.print(f"This folder deploys to [bold]{linked['slug']}[/bold].")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_logout(args, root) -> int:
|
|
167
|
+
console.print("Signed out." if config.clear_token() else "You weren't signed in.")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cmd_link(args, root) -> int:
|
|
172
|
+
client = make_client()
|
|
173
|
+
ensure_login(client)
|
|
174
|
+
ensure_linked(client, root, args, force=True)
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_unlink(args, root) -> int:
|
|
179
|
+
console.print("Unlinked." if link.remove(root) else "This folder isn't linked.")
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_deploy(args, root) -> int:
|
|
184
|
+
client = make_client()
|
|
185
|
+
ensure_login(client)
|
|
186
|
+
project = ensure_linked(client, root, args)
|
|
187
|
+
console.print(f"Ready to deploy [bold]{project['slug']}[/bold].")
|
|
188
|
+
err.print(COMING_SOON.format(command=say("deploy")))
|
|
189
|
+
err.print("Your project is linked, so the next release will pick it up.")
|
|
190
|
+
return 2
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
COMMANDS = {
|
|
194
|
+
"login": cmd_login,
|
|
195
|
+
"logout": cmd_logout,
|
|
196
|
+
"whoami": cmd_whoami,
|
|
197
|
+
"link": cmd_link,
|
|
198
|
+
"unlink": cmd_unlink,
|
|
199
|
+
"deploy": cmd_deploy,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def run(argv: list[str] | None = None, prog: str = STANDALONE) -> int:
|
|
204
|
+
global _prog
|
|
205
|
+
_prog = prog
|
|
206
|
+
parser = build_parser(prog)
|
|
207
|
+
args = parser.parse_args(argv)
|
|
208
|
+
set_no_input(getattr(args, "no_input", False))
|
|
209
|
+
|
|
210
|
+
if args.command in (None, "help"):
|
|
211
|
+
topic = getattr(args, "topic", None)
|
|
212
|
+
if topic:
|
|
213
|
+
return run([topic, "--help"], prog) # argparse prints the sub-command's help, then exits
|
|
214
|
+
parser.print_help()
|
|
215
|
+
return 0
|
|
216
|
+
handler = COMMANDS.get(args.command)
|
|
217
|
+
if handler is None:
|
|
218
|
+
err.print(COMING_SOON.format(command=say(args.command)))
|
|
219
|
+
return 2
|
|
220
|
+
try:
|
|
221
|
+
return handler(args, link.find_root())
|
|
222
|
+
except (CliError, NotInteractive) as exc:
|
|
223
|
+
err.print(f"[red]Error:[/red] {exc}")
|
|
224
|
+
except ApiError as exc:
|
|
225
|
+
err.print(f"[red]Error:[/red] {exc.message}")
|
|
226
|
+
except KeyboardInterrupt:
|
|
227
|
+
err.print("\nCancelled.")
|
|
228
|
+
return 130
|
|
229
|
+
return 1
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main() -> None:
|
|
233
|
+
sys.exit(run(sys.argv[1:]))
|