vertracloud-api 0.2.2__tar.gz → 0.3.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.
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/CHANGELOG.md +18 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/PKG-INFO +23 -3
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/README.md +22 -2
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/pyproject.toml +4 -1
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/client.py +36 -6
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/apps.py +50 -24
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/transport.py +36 -1
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/types.py +83 -23
- vertracloud_api-0.2.2/.github/workflows/ci.yml +0 -24
- vertracloud_api-0.2.2/.github/workflows/release.yml +0 -37
- vertracloud_api-0.2.2/.release-please-manifest.json +0 -1
- vertracloud_api-0.2.2/release-please-config.json +0 -50
- vertracloud_api-0.2.2/tests/__init__.py +0 -0
- vertracloud_api-0.2.2/tests/fake_transport.py +0 -80
- vertracloud_api-0.2.2/tests/routes_matrix.py +0 -138
- vertracloud_api-0.2.2/tests/test_blob.py +0 -336
- vertracloud_api-0.2.2/tests/test_client.py +0 -159
- vertracloud_api-0.2.2/tests/test_contract.py +0 -355
- vertracloud_api-0.2.2/tests/test_multipart_and_binary.py +0 -156
- vertracloud_api-0.2.2/tests/test_routes_matrix.py +0 -183
- vertracloud_api-0.2.2/tests/test_sse.py +0 -90
- vertracloud_api-0.2.2/uv.lock +0 -517
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/.gitignore +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/LICENSE +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/__init__.py +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/errors.py +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/py.typed +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/__init__.py +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/account.py +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/billing.py +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/blob.py +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/databases.py +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/snapshots.py +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/workspaces.py +0 -0
- {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/sse.py +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/vertracloud/sdk-api-py/compare/v0.2.2...v0.3.0) (2026-10-22)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* plan names and multiple custom domains per app
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* plan names and multiple custom domains per app ([1df9eab](https://github.com/vertracloud/sdk-api-py/commit/1df9eab6d548a6136409652419afd26dbd6c8b41))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* drop the Authorization header when a redirect changes scheme, host or port ([70f5451](https://github.com/vertracloud/sdk-api-py/commit/70f5451d8ee02579344ac2fde46a16ed1b9952eb))
|
|
18
|
+
* reject empty and dot path segments before sending a request ([2964db9](https://github.com/vertracloud/sdk-api-py/commit/2964db9c6da64ce0427ee4b4c2975b012edbad8e))
|
|
19
|
+
* **upload:** escape quotes and reject CR/LF in multipart Content-Disposition ([e56ebb0](https://github.com/vertracloud/sdk-api-py/commit/e56ebb0ef9717cd45d766d94905225ede8c4466e))
|
|
20
|
+
|
|
3
21
|
## [0.2.2](https://github.com/vertracloud/sdk-api-py/compare/v0.2.1...v0.2.2) (2026-10-18)
|
|
4
22
|
|
|
5
23
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vertracloud-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Official Python SDK for the public API of Vertra Cloud, a Brazilian application hosting platform (PaaS).
|
|
5
5
|
Project-URL: Homepage, https://vertracloud.app
|
|
6
6
|
Project-URL: Documentation, https://docs.vertracloud.app/sdks
|
|
@@ -48,7 +48,8 @@ from vertracloud import VertraClient
|
|
|
48
48
|
client = VertraClient(api_key=os.environ["VERTRA_API_KEY"])
|
|
49
49
|
|
|
50
50
|
me = client.account.get()
|
|
51
|
-
|
|
51
|
+
plan = me["plan"]
|
|
52
|
+
print(f"Hi {me['name']}! Plan: {plan['name'] if plan else 'none'}")
|
|
52
53
|
|
|
53
54
|
for app in me["applications"]:
|
|
54
55
|
print(app["id"], app["name"], app["status"])
|
|
@@ -99,6 +100,25 @@ metrics = client.apps.metrics(app_id, range="24h")
|
|
|
99
100
|
logs = client.apps.logs(app_id)
|
|
100
101
|
```
|
|
101
102
|
|
|
103
|
+
### Custom domains
|
|
104
|
+
|
|
105
|
+
An app can have several domains, each with its own port, path prefix or redirect. `add` returns the created
|
|
106
|
+
domain and its automatic `www` (or root) companion; the limit depends on your plan.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
domains = client.apps.domains.add(app_id, {"hostname": "example.com", "port": 8080})
|
|
110
|
+
main = domains[0]
|
|
111
|
+
|
|
112
|
+
for record in client.apps.domains.dns(app_id, main["id"]):
|
|
113
|
+
print(record["type"], record["name"], record["value"], record["status"])
|
|
114
|
+
|
|
115
|
+
client.apps.domains.update(app_id, domains[1]["id"], {"redirect_to": main["id"], "redirect_status": 301})
|
|
116
|
+
client.apps.domains.remove(app_id, main["id"])
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`client.apps.domains.connect(app_id, domain_id)` returns a link that configures the DNS records at providers that
|
|
120
|
+
support it, or raises a 404 `DOMAIN_CONNECT_UNAVAILABLE`.
|
|
121
|
+
|
|
102
122
|
### Releases and rollback
|
|
103
123
|
|
|
104
124
|
```python
|
|
@@ -225,7 +245,7 @@ client = VertraClient(api_key="test", transport=my_fake_transport)
|
|
|
225
245
|
|
|
226
246
|
| Domain | Resource | Routes |
|
|
227
247
|
|---|---|---|
|
|
228
|
-
| Apps | `client.apps` (+ `.deploys`, `.releases`, `.network`, `.envs`, `.files`) |
|
|
248
|
+
| Apps | `client.apps` (+ `.deploys`, `.releases`, `.domains`, `.network`, `.envs`, `.files`) | 42 |
|
|
229
249
|
| Databases | `client.databases` (+ `.credentials`) | 13 |
|
|
230
250
|
| Snapshots | `client.snapshots` | 5 |
|
|
231
251
|
| Account | `client.account` (+ `.sessions`, `.folders`, `.favorites`) | 10 |
|
|
@@ -29,7 +29,8 @@ from vertracloud import VertraClient
|
|
|
29
29
|
client = VertraClient(api_key=os.environ["VERTRA_API_KEY"])
|
|
30
30
|
|
|
31
31
|
me = client.account.get()
|
|
32
|
-
|
|
32
|
+
plan = me["plan"]
|
|
33
|
+
print(f"Hi {me['name']}! Plan: {plan['name'] if plan else 'none'}")
|
|
33
34
|
|
|
34
35
|
for app in me["applications"]:
|
|
35
36
|
print(app["id"], app["name"], app["status"])
|
|
@@ -80,6 +81,25 @@ metrics = client.apps.metrics(app_id, range="24h")
|
|
|
80
81
|
logs = client.apps.logs(app_id)
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
### Custom domains
|
|
85
|
+
|
|
86
|
+
An app can have several domains, each with its own port, path prefix or redirect. `add` returns the created
|
|
87
|
+
domain and its automatic `www` (or root) companion; the limit depends on your plan.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
domains = client.apps.domains.add(app_id, {"hostname": "example.com", "port": 8080})
|
|
91
|
+
main = domains[0]
|
|
92
|
+
|
|
93
|
+
for record in client.apps.domains.dns(app_id, main["id"]):
|
|
94
|
+
print(record["type"], record["name"], record["value"], record["status"])
|
|
95
|
+
|
|
96
|
+
client.apps.domains.update(app_id, domains[1]["id"], {"redirect_to": main["id"], "redirect_status": 301})
|
|
97
|
+
client.apps.domains.remove(app_id, main["id"])
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`client.apps.domains.connect(app_id, domain_id)` returns a link that configures the DNS records at providers that
|
|
101
|
+
support it, or raises a 404 `DOMAIN_CONNECT_UNAVAILABLE`.
|
|
102
|
+
|
|
83
103
|
### Releases and rollback
|
|
84
104
|
|
|
85
105
|
```python
|
|
@@ -206,7 +226,7 @@ client = VertraClient(api_key="test", transport=my_fake_transport)
|
|
|
206
226
|
|
|
207
227
|
| Domain | Resource | Routes |
|
|
208
228
|
|---|---|---|
|
|
209
|
-
| Apps | `client.apps` (+ `.deploys`, `.releases`, `.network`, `.envs`, `.files`) |
|
|
229
|
+
| Apps | `client.apps` (+ `.deploys`, `.releases`, `.domains`, `.network`, `.envs`, `.files`) | 42 |
|
|
210
230
|
| Databases | `client.databases` (+ `.credentials`) | 13 |
|
|
211
231
|
| Snapshots | `client.snapshots` | 5 |
|
|
212
232
|
| Account | `client.account` (+ `.sessions`, `.folders`, `.favorites`) | 10 |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vertracloud-api"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Official Python SDK for the public API of Vertra Cloud, a Brazilian application hosting platform (PaaS)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -21,6 +21,9 @@ Issues = "https://github.com/vertracloud/sdk-api-py/issues"
|
|
|
21
21
|
[project.optional-dependencies]
|
|
22
22
|
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "build>=1.2"]
|
|
23
23
|
|
|
24
|
+
[tool.hatch.build.targets.sdist]
|
|
25
|
+
include = ["src", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
|
|
26
|
+
|
|
24
27
|
[tool.hatch.build.targets.wheel]
|
|
25
28
|
packages = ["src/vertracloud"]
|
|
26
29
|
|
|
@@ -9,14 +9,14 @@ import mimetypes
|
|
|
9
9
|
import uuid
|
|
10
10
|
from collections.abc import Iterable
|
|
11
11
|
from typing import Any
|
|
12
|
-
from urllib.parse import quote, urlencode
|
|
12
|
+
from urllib.parse import quote, unquote, urlencode
|
|
13
13
|
|
|
14
14
|
from .errors import error_from_response
|
|
15
15
|
from .sse import SseStream
|
|
16
16
|
from .transport import Response, Transport, UrllibTransport
|
|
17
17
|
from .types import JsonValue
|
|
18
18
|
|
|
19
|
-
__version__ = "0.
|
|
19
|
+
__version__ = "0.3.0" # x-release-please-version
|
|
20
20
|
|
|
21
21
|
DEFAULT_BASE_URL = "https://api.vertracloud.app"
|
|
22
22
|
DEFAULT_BLOB_BASE_URL = "https://blob.vertracloud.app/v1"
|
|
@@ -25,9 +25,28 @@ DEFAULT_TIMEOUT = 30.0
|
|
|
25
25
|
LARGE_PAYLOAD_TIMEOUT = 120.0
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _is_dot_segment(value: str) -> bool:
|
|
29
|
+
current = value
|
|
30
|
+
for _ in range(4):
|
|
31
|
+
if current in ("", ".", ".."):
|
|
32
|
+
return True
|
|
33
|
+
decoded = unquote(current)
|
|
34
|
+
if decoded == current:
|
|
35
|
+
return False
|
|
36
|
+
current = decoded
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
28
40
|
def encode_path_param(value: str) -> str:
|
|
29
|
-
"""`quote(safe="")` — a path param never lets `/`, `?`, etc. leak into the URL structure.
|
|
30
|
-
|
|
41
|
+
"""`quote(safe="")` — a path param never lets `/`, `?`, etc. leak into the URL structure.
|
|
42
|
+
|
|
43
|
+
Raises `ValueError` (before any request) for an empty value, `.` or `..`, also when percent-encoded:
|
|
44
|
+
servers and URL parsers collapse dot-segments, so the call would reach a different route.
|
|
45
|
+
"""
|
|
46
|
+
text = str(value)
|
|
47
|
+
if _is_dot_segment(text):
|
|
48
|
+
raise ValueError(f"Invalid path segment: {text!r}")
|
|
49
|
+
return quote(text, safe="")
|
|
31
50
|
|
|
32
51
|
|
|
33
52
|
class MultipartField:
|
|
@@ -49,13 +68,24 @@ class MultipartField:
|
|
|
49
68
|
self.content_type = content_type
|
|
50
69
|
|
|
51
70
|
|
|
71
|
+
def _quote_param(value: str, what: str) -> str:
|
|
72
|
+
"""Quote a Content-Disposition parameter like Go's `mime/multipart` (`\\` and `"` escaped).
|
|
73
|
+
|
|
74
|
+
CR/LF are rejected instead of rewritten: silently changing a file name would surprise the
|
|
75
|
+
caller, and a header line break is never a legitimate name.
|
|
76
|
+
"""
|
|
77
|
+
if "\r" in value or "\n" in value:
|
|
78
|
+
raise ValueError(f"multipart {what} must not contain CR/LF")
|
|
79
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
80
|
+
|
|
81
|
+
|
|
52
82
|
def _build_multipart(fields: Iterable[MultipartField]) -> tuple[bytes, str]:
|
|
53
83
|
boundary = f"----vertracloud-{uuid.uuid4().hex}"
|
|
54
84
|
parts: list[bytes] = []
|
|
55
85
|
for field in fields:
|
|
56
|
-
disposition = f'form-data; name="{field.name}"'
|
|
86
|
+
disposition = f'form-data; name="{_quote_param(field.name, "field name")}"'
|
|
57
87
|
if field.filename:
|
|
58
|
-
disposition += f'; filename="{field.filename}"'
|
|
88
|
+
disposition += f'; filename="{_quote_param(field.filename, "filename")}"'
|
|
59
89
|
header = f"--{boundary}\r\nContent-Disposition: {disposition}\r\n"
|
|
60
90
|
content_type = field.content_type
|
|
61
91
|
if content_type is None and field.filename:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""`apps` domain —
|
|
1
|
+
"""`apps` domain — 42 routes (scopes `apps:read/write/delete/envs/files`).
|
|
2
2
|
|
|
3
3
|
`workspace_id` is accepted as an optional query parameter on almost every method.
|
|
4
4
|
"""
|
|
@@ -12,9 +12,13 @@ from ..client import LARGE_PAYLOAD_TIMEOUT, MultipartField, encode_path_param
|
|
|
12
12
|
from ..errors import AppBuildFailedError, WaitTimeoutError
|
|
13
13
|
from ..sse import SseStream
|
|
14
14
|
from ..types import (
|
|
15
|
+
AddDomainBody,
|
|
15
16
|
APIAppFileUploadResponse,
|
|
16
17
|
APIApplication,
|
|
17
18
|
APIApplicationDeployment,
|
|
19
|
+
APIApplicationDomain,
|
|
20
|
+
APIApplicationDomainConnect,
|
|
21
|
+
APIApplicationDomainDnsRecord,
|
|
18
22
|
APIApplicationEnvironment,
|
|
19
23
|
APIApplicationFileContent,
|
|
20
24
|
APIApplicationFileTree,
|
|
@@ -26,14 +30,11 @@ from ..types import (
|
|
|
26
30
|
APIApplicationStatus,
|
|
27
31
|
APIApplicationStatusShort,
|
|
28
32
|
APIApplicationWebPublish,
|
|
29
|
-
APICustomDomainResponse,
|
|
30
|
-
APIDnsRecord,
|
|
31
33
|
APIRuntimesResponse,
|
|
32
34
|
APISubdomainResponse,
|
|
33
35
|
APIWebhookUrl,
|
|
34
36
|
CreateAppBody,
|
|
35
37
|
CreateWebhookBody,
|
|
36
|
-
CustomDomainBody,
|
|
37
38
|
EnvironmentSetBody,
|
|
38
39
|
JsonValue,
|
|
39
40
|
MoveFileBody,
|
|
@@ -43,6 +44,7 @@ from ..types import (
|
|
|
43
44
|
RollbackAppBody,
|
|
44
45
|
SetSubdomainBody,
|
|
45
46
|
UpdateAppConfigBody,
|
|
47
|
+
UpdateDomainBody,
|
|
46
48
|
)
|
|
47
49
|
|
|
48
50
|
if TYPE_CHECKING:
|
|
@@ -51,6 +53,8 @@ if TYPE_CHECKING:
|
|
|
51
53
|
# Aliases so `list[...]` doesn't resolve to the `list` method inside the class itself.
|
|
52
54
|
EnvList = list[APIApplicationEnvironment]
|
|
53
55
|
FileTreeList = list[APIApplicationFileTree]
|
|
56
|
+
DomainList = list[APIApplicationDomain]
|
|
57
|
+
DomainDnsList = list[APIApplicationDomainDnsRecord]
|
|
54
58
|
|
|
55
59
|
|
|
56
60
|
class AppDeploysWebhookResource:
|
|
@@ -167,45 +171,66 @@ class AppReleasesResource:
|
|
|
167
171
|
)
|
|
168
172
|
|
|
169
173
|
|
|
170
|
-
class
|
|
174
|
+
class AppDomainsResource:
|
|
171
175
|
def __init__(self, client: VertraClient) -> None:
|
|
172
176
|
self._c = client
|
|
173
177
|
|
|
174
|
-
def
|
|
175
|
-
"""GET /v1/apps/:id/
|
|
178
|
+
def list(self, app_id: str, *, workspace_id: str | None = None) -> DomainList:
|
|
179
|
+
"""GET /v1/apps/:id/domains — scope `apps:read`. Automatic companions included."""
|
|
176
180
|
return self._c.request_json(
|
|
177
|
-
"GET", f"/v1/apps/{encode_path_param(app_id)}/
|
|
181
|
+
"GET", f"/v1/apps/{encode_path_param(app_id)}/domains", query={"workspace_id": workspace_id}
|
|
178
182
|
)
|
|
179
183
|
|
|
180
|
-
def
|
|
181
|
-
"""POST /v1/apps/:id/
|
|
182
|
-
Doc: https://docs.vertracloud.app/api-reference/endpoint/apps/network/customdomain
|
|
183
|
-
"""
|
|
184
|
+
def add(self, app_id: str, body: AddDomainBody, *, workspace_id: str | None = None) -> DomainList:
|
|
185
|
+
"""POST /v1/apps/:id/domains — scope `apps:write`. Returns the created domain and its companion."""
|
|
184
186
|
return self._c.request_json(
|
|
185
187
|
"POST",
|
|
186
|
-
f"/v1/apps/{encode_path_param(app_id)}/
|
|
188
|
+
f"/v1/apps/{encode_path_param(app_id)}/domains",
|
|
187
189
|
json_body=body,
|
|
188
190
|
query={"workspace_id": workspace_id},
|
|
189
191
|
)
|
|
190
192
|
|
|
191
|
-
def
|
|
192
|
-
|
|
193
|
+
def update(
|
|
194
|
+
self, app_id: str, domain_id: str, body: UpdateDomainBody, *, workspace_id: str | None = None
|
|
195
|
+
) -> DomainList:
|
|
196
|
+
"""PATCH /v1/apps/:id/domains/:domain_id — scope `apps:write`."""
|
|
193
197
|
return self._c.request_json(
|
|
194
|
-
"
|
|
198
|
+
"PATCH",
|
|
199
|
+
f"/v1/apps/{encode_path_param(app_id)}/domains/{encode_path_param(domain_id)}",
|
|
200
|
+
json_body=body,
|
|
201
|
+
query={"workspace_id": workspace_id},
|
|
195
202
|
)
|
|
196
203
|
|
|
204
|
+
def remove(self, app_id: str, domain_id: str, *, workspace_id: str | None = None) -> None:
|
|
205
|
+
"""DELETE /v1/apps/:id/domains/:domain_id — scope `apps:write`. Also removes the automatic companion."""
|
|
206
|
+
return self._c.request_json(
|
|
207
|
+
"DELETE",
|
|
208
|
+
f"/v1/apps/{encode_path_param(app_id)}/domains/{encode_path_param(domain_id)}",
|
|
209
|
+
query={"workspace_id": workspace_id},
|
|
210
|
+
)
|
|
197
211
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
self._c
|
|
201
|
-
|
|
212
|
+
def dns(self, app_id: str, domain_id: str, *, workspace_id: str | None = None) -> DomainDnsList:
|
|
213
|
+
"""GET /v1/apps/:id/domains/:domain_id/dns — scope `apps:read`."""
|
|
214
|
+
return self._c.request_json(
|
|
215
|
+
"GET",
|
|
216
|
+
f"/v1/apps/{encode_path_param(app_id)}/domains/{encode_path_param(domain_id)}/dns",
|
|
217
|
+
query={"workspace_id": workspace_id},
|
|
218
|
+
)
|
|
202
219
|
|
|
203
|
-
def
|
|
204
|
-
"""
|
|
220
|
+
def connect(self, app_id: str, domain_id: str, *, workspace_id: str | None = None) -> APIApplicationDomainConnect:
|
|
221
|
+
"""POST /v1/apps/:id/domains/:domain_id/connect — scope `apps:write`. Raises a 404
|
|
222
|
+
`DOMAIN_CONNECT_UNAVAILABLE` when the DNS provider does not support it."""
|
|
205
223
|
return self._c.request_json(
|
|
206
|
-
"
|
|
224
|
+
"POST",
|
|
225
|
+
f"/v1/apps/{encode_path_param(app_id)}/domains/{encode_path_param(domain_id)}/connect",
|
|
226
|
+
query={"workspace_id": workspace_id},
|
|
207
227
|
)
|
|
208
228
|
|
|
229
|
+
|
|
230
|
+
class AppNetworkResource:
|
|
231
|
+
def __init__(self, client: VertraClient) -> None:
|
|
232
|
+
self._c = client
|
|
233
|
+
|
|
209
234
|
def purge_cache(self, app_id: str, body: PurgeCacheBody | None = None, *, workspace_id: str | None = None) -> None:
|
|
210
235
|
"""POST /v1/apps/:id/network/purge-cache — scope `apps:write`. An empty `{}` body is valid."""
|
|
211
236
|
return self._c.request_json(
|
|
@@ -364,12 +389,13 @@ class AppFilesResource:
|
|
|
364
389
|
|
|
365
390
|
class AppsResource:
|
|
366
391
|
"""`apps.*` — get/status/statusAll/runtimes/realtime/metrics/logs/download/create/start/
|
|
367
|
-
stop/restart/updateConfig/delete + `deploys`, `releases`, `network`, `envs`, `files`."""
|
|
392
|
+
stop/restart/updateConfig/delete + `deploys`, `releases`, `domains`, `network`, `envs`, `files`."""
|
|
368
393
|
|
|
369
394
|
def __init__(self, client: VertraClient) -> None:
|
|
370
395
|
self._c = client
|
|
371
396
|
self.deploys = AppDeploysResource(client)
|
|
372
397
|
self.releases = AppReleasesResource(client)
|
|
398
|
+
self.domains = AppDomainsResource(client)
|
|
373
399
|
self.network = AppNetworkResource(client)
|
|
374
400
|
self.envs = AppEnvsResource(client)
|
|
375
401
|
self.files = AppFilesResource(client)
|
|
@@ -9,7 +9,9 @@ import urllib.error
|
|
|
9
9
|
import urllib.request
|
|
10
10
|
from collections.abc import Iterator
|
|
11
11
|
from dataclasses import dataclass, field
|
|
12
|
+
from email.message import Message
|
|
12
13
|
from typing import Protocol
|
|
14
|
+
from urllib.parse import urlsplit
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
@dataclass
|
|
@@ -35,6 +37,39 @@ class Transport(Protocol):
|
|
|
35
37
|
) -> Response: ...
|
|
36
38
|
|
|
37
39
|
|
|
40
|
+
class _AuthStrippingRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
41
|
+
"""Follows redirects but never forwards `Authorization` to another origin (scheme, host or port)."""
|
|
42
|
+
|
|
43
|
+
def redirect_request(
|
|
44
|
+
self,
|
|
45
|
+
req: urllib.request.Request,
|
|
46
|
+
fp: object,
|
|
47
|
+
code: int,
|
|
48
|
+
msg: str,
|
|
49
|
+
headers: Message | dict[str, str],
|
|
50
|
+
newurl: str,
|
|
51
|
+
) -> urllib.request.Request | None:
|
|
52
|
+
new = super().redirect_request(req, fp, code, msg, headers, newurl) # type: ignore[arg-type]
|
|
53
|
+
if new is not None and _origin(req.full_url) != _origin(new.full_url):
|
|
54
|
+
for name in [h for h in new.headers if h.lower() == "authorization"]:
|
|
55
|
+
del new.headers[name]
|
|
56
|
+
for name in [h for h in new.unredirected_hdrs if h.lower() == "authorization"]:
|
|
57
|
+
del new.unredirected_hdrs[name]
|
|
58
|
+
return new
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _origin(url: str) -> tuple[str, str, int | None]:
|
|
62
|
+
parts = urlsplit(url)
|
|
63
|
+
return (
|
|
64
|
+
parts.scheme.lower(),
|
|
65
|
+
(parts.hostname or "").lower(),
|
|
66
|
+
parts.port or {"http": 80, "https": 443}.get(parts.scheme.lower()),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_opener = urllib.request.build_opener(_AuthStrippingRedirectHandler)
|
|
71
|
+
|
|
72
|
+
|
|
38
73
|
@dataclass
|
|
39
74
|
class UrllibTransport:
|
|
40
75
|
"""Default implementation, pure stdlib (`urllib`) — zero runtime dependencies."""
|
|
@@ -53,7 +88,7 @@ class UrllibTransport:
|
|
|
53
88
|
) -> Response:
|
|
54
89
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
55
90
|
try:
|
|
56
|
-
resp =
|
|
91
|
+
resp = _opener.open(req, timeout=timeout) # noqa: S310 - URL comes from the configured base_url
|
|
57
92
|
except urllib.error.HTTPError as exc:
|
|
58
93
|
data = exc.read()
|
|
59
94
|
resp_headers = {k.lower(): v for k, v in exc.headers.items()} if exc.headers else {}
|
|
@@ -9,6 +9,27 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import Any, Literal, TypedDict
|
|
11
11
|
|
|
12
|
+
UserPlanName = Literal[
|
|
13
|
+
"economy",
|
|
14
|
+
"pro",
|
|
15
|
+
"scale",
|
|
16
|
+
"intermediary",
|
|
17
|
+
"enterprise-4",
|
|
18
|
+
"enterprise-6",
|
|
19
|
+
"enterprise-8",
|
|
20
|
+
"enterprise-12",
|
|
21
|
+
"enterprise-14",
|
|
22
|
+
"enterprise-16",
|
|
23
|
+
"enterprise-18",
|
|
24
|
+
"enterprise-20",
|
|
25
|
+
"enterprise-22",
|
|
26
|
+
"enterprise-24",
|
|
27
|
+
"enterprise-26",
|
|
28
|
+
"enterprise-28",
|
|
29
|
+
"enterprise-30",
|
|
30
|
+
"enterprise-32",
|
|
31
|
+
]
|
|
32
|
+
|
|
12
33
|
JsonValue = Any
|
|
13
34
|
JsonObject = dict[str, Any]
|
|
14
35
|
|
|
@@ -87,8 +108,35 @@ class RestartAppBody(TypedDict, total=False):
|
|
|
87
108
|
force_build: bool
|
|
88
109
|
|
|
89
110
|
|
|
90
|
-
|
|
91
|
-
|
|
111
|
+
ApplicationDomainStatus = Literal["pending", "active", "moved", "deleted"]
|
|
112
|
+
ApplicationDomainSslStatus = Literal["pending", "active", "failed"]
|
|
113
|
+
ApplicationDomainRedirectStatus = Literal[301, 302, 307, 308]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _AddDomainRequired(TypedDict):
|
|
117
|
+
hostname: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AddDomainBody(_AddDomainRequired, total=False):
|
|
121
|
+
"""`POST /v1/apps/:id/domains`. `www` defaults to `True` on the server; `redirect_status`
|
|
122
|
+
only applies with `redirect_to`."""
|
|
123
|
+
|
|
124
|
+
www: bool
|
|
125
|
+
port: int
|
|
126
|
+
path_prefix: str
|
|
127
|
+
redirect_to: str
|
|
128
|
+
redirect_status: ApplicationDomainRedirectStatus
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class UpdateDomainBody(TypedDict, total=False):
|
|
132
|
+
"""`PATCH /v1/apps/:id/domains/:domain_id`. Send only what changes; `None` clears `path_prefix`
|
|
133
|
+
or `redirect_to`. `primary=True` swaps the domain with its companion as the main one."""
|
|
134
|
+
|
|
135
|
+
port: int
|
|
136
|
+
path_prefix: str | None
|
|
137
|
+
redirect_to: str | None
|
|
138
|
+
redirect_status: ApplicationDomainRedirectStatus
|
|
139
|
+
primary: Literal[True]
|
|
92
140
|
|
|
93
141
|
|
|
94
142
|
class SetSubdomainBody(TypedDict):
|
|
@@ -284,19 +332,44 @@ class APIApplicationDeploySettings(TypedDict):
|
|
|
284
332
|
health_check_path: str | None
|
|
285
333
|
|
|
286
334
|
|
|
335
|
+
class APIApplicationDomain(TypedDict):
|
|
336
|
+
id: str
|
|
337
|
+
hostname: str
|
|
338
|
+
apex: bool
|
|
339
|
+
companion_of: str | None
|
|
340
|
+
redirect_to: str | None
|
|
341
|
+
redirect_status: ApplicationDomainRedirectStatus
|
|
342
|
+
path_prefix: str | None
|
|
343
|
+
port: int
|
|
344
|
+
status: ApplicationDomainStatus
|
|
345
|
+
ssl_status: ApplicationDomainSslStatus
|
|
346
|
+
created_at: str
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class APIApplicationDomainDnsRecord(TypedDict):
|
|
350
|
+
type: Literal["CNAME", "TXT"]
|
|
351
|
+
name: str
|
|
352
|
+
value: str
|
|
353
|
+
status: Literal["pending", "active"]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class APIApplicationDomainConnect(TypedDict):
|
|
357
|
+
url: str
|
|
358
|
+
|
|
359
|
+
|
|
287
360
|
class _APIApplicationRequired(TypedDict):
|
|
288
361
|
id: str
|
|
289
362
|
cluster: int
|
|
290
363
|
type: int
|
|
291
364
|
name: str
|
|
292
365
|
owner_id: str
|
|
293
|
-
|
|
366
|
+
owner_plan: UserPlanName | None
|
|
294
367
|
language: ApplicationLanguage
|
|
295
368
|
ram: int
|
|
296
369
|
status: ApplicationStatus
|
|
297
370
|
subdomain: str | None
|
|
298
371
|
public_url: str | None
|
|
299
|
-
|
|
372
|
+
domains: list[APIApplicationDomain]
|
|
300
373
|
last_snapshot: str | None
|
|
301
374
|
created_at: str
|
|
302
375
|
updated_at: str
|
|
@@ -443,13 +516,6 @@ class APIApplicationRollbackPreview(TypedDict):
|
|
|
443
516
|
environment_changes: list[APIApplicationEnvironmentChange]
|
|
444
517
|
|
|
445
518
|
|
|
446
|
-
class APIDnsRecord(TypedDict):
|
|
447
|
-
type: str
|
|
448
|
-
name: str
|
|
449
|
-
value: str
|
|
450
|
-
status: str
|
|
451
|
-
|
|
452
|
-
|
|
453
519
|
class APIWebhookUrl(TypedDict):
|
|
454
520
|
url: str
|
|
455
521
|
|
|
@@ -458,13 +524,9 @@ class APISubdomainResponse(TypedDict):
|
|
|
458
524
|
subdomain: str
|
|
459
525
|
|
|
460
526
|
|
|
461
|
-
class APICustomDomainResponse(TypedDict):
|
|
462
|
-
domain: str
|
|
463
|
-
|
|
464
|
-
|
|
465
527
|
class APIApplicationWebPublish(TypedDict):
|
|
466
528
|
subdomain: str | None
|
|
467
|
-
|
|
529
|
+
domains: list[str]
|
|
468
530
|
type: int
|
|
469
531
|
|
|
470
532
|
|
|
@@ -516,7 +578,7 @@ class APIDatabase(TypedDict):
|
|
|
516
578
|
name: str
|
|
517
579
|
description: str
|
|
518
580
|
owner_id: str
|
|
519
|
-
|
|
581
|
+
owner_plan: UserPlanName | None
|
|
520
582
|
status: DatabaseStatus
|
|
521
583
|
ram: int
|
|
522
584
|
host: str
|
|
@@ -595,7 +657,6 @@ class APIUser(TypedDict):
|
|
|
595
657
|
id: str
|
|
596
658
|
name: str
|
|
597
659
|
email: str
|
|
598
|
-
plan_id: str
|
|
599
660
|
language: str
|
|
600
661
|
workspace_invites_enabled: bool
|
|
601
662
|
created_at: str
|
|
@@ -614,9 +675,8 @@ class APIUserPlanMemory(TypedDict):
|
|
|
614
675
|
|
|
615
676
|
|
|
616
677
|
class APIUserPlan(TypedDict):
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
expires_at: str | None
|
|
678
|
+
name: UserPlanName
|
|
679
|
+
expires_at: str
|
|
620
680
|
duration: int
|
|
621
681
|
memory: APIUserPlanMemory
|
|
622
682
|
|
|
@@ -654,7 +714,7 @@ class APIWorkspaceResourceOrganization(TypedDict):
|
|
|
654
714
|
|
|
655
715
|
|
|
656
716
|
class APIUserInfoResponse(APIUser):
|
|
657
|
-
plan: APIUserPlan
|
|
717
|
+
plan: APIUserPlan | None
|
|
658
718
|
applications: list[APIApplication]
|
|
659
719
|
databases: list[APIDatabase]
|
|
660
720
|
connections: list[APIUserConnection]
|
|
@@ -724,7 +784,7 @@ class APIWorkspaceInfoResponse(APIWorkspace):
|
|
|
724
784
|
databases: list[APIDatabase]
|
|
725
785
|
permissions: list[str]
|
|
726
786
|
is_owner: bool
|
|
727
|
-
|
|
787
|
+
owner_plan: UserPlanName | None
|
|
728
788
|
resource_organization: APIWorkspaceResourceOrganization
|
|
729
789
|
|
|
730
790
|
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
pull_request:
|
|
7
|
-
branches: [main]
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
test:
|
|
11
|
-
runs-on: ubuntu-24.04
|
|
12
|
-
strategy:
|
|
13
|
-
matrix:
|
|
14
|
-
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
15
|
-
steps:
|
|
16
|
-
- uses: actions/checkout@v7
|
|
17
|
-
- uses: actions/setup-python@v7
|
|
18
|
-
with:
|
|
19
|
-
python-version: ${{ matrix.python-version }}
|
|
20
|
-
- run: pip install -e .[dev]
|
|
21
|
-
- run: ruff check .
|
|
22
|
-
- run: ruff format --check .
|
|
23
|
-
- run: mypy src
|
|
24
|
-
- run: python -m pytest -q
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
name: release
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
|
|
7
|
-
permissions:
|
|
8
|
-
contents: write
|
|
9
|
-
pull-requests: write
|
|
10
|
-
|
|
11
|
-
jobs:
|
|
12
|
-
release-please:
|
|
13
|
-
runs-on: ubuntu-24.04
|
|
14
|
-
outputs:
|
|
15
|
-
release_created: ${{ steps.rp.outputs.release_created }}
|
|
16
|
-
steps:
|
|
17
|
-
- id: rp
|
|
18
|
-
uses: googleapis/release-please-action@v5
|
|
19
|
-
with:
|
|
20
|
-
config-file: release-please-config.json
|
|
21
|
-
manifest-file: .release-please-manifest.json
|
|
22
|
-
|
|
23
|
-
publish:
|
|
24
|
-
needs: release-please
|
|
25
|
-
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
|
26
|
-
runs-on: ubuntu-24.04
|
|
27
|
-
environment: pypi
|
|
28
|
-
permissions:
|
|
29
|
-
id-token: write # trusted publishing no PyPI, sem token
|
|
30
|
-
steps:
|
|
31
|
-
- uses: actions/checkout@v7
|
|
32
|
-
- uses: actions/setup-python@v7
|
|
33
|
-
with:
|
|
34
|
-
python-version: "3.12"
|
|
35
|
-
- run: pip install build
|
|
36
|
-
- run: python -m build
|
|
37
|
-
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{".":"0.2.2"}
|