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.
Files changed (35) hide show
  1. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/CHANGELOG.md +18 -0
  2. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/PKG-INFO +23 -3
  3. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/README.md +22 -2
  4. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/pyproject.toml +4 -1
  5. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/client.py +36 -6
  6. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/apps.py +50 -24
  7. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/transport.py +36 -1
  8. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/types.py +83 -23
  9. vertracloud_api-0.2.2/.github/workflows/ci.yml +0 -24
  10. vertracloud_api-0.2.2/.github/workflows/release.yml +0 -37
  11. vertracloud_api-0.2.2/.release-please-manifest.json +0 -1
  12. vertracloud_api-0.2.2/release-please-config.json +0 -50
  13. vertracloud_api-0.2.2/tests/__init__.py +0 -0
  14. vertracloud_api-0.2.2/tests/fake_transport.py +0 -80
  15. vertracloud_api-0.2.2/tests/routes_matrix.py +0 -138
  16. vertracloud_api-0.2.2/tests/test_blob.py +0 -336
  17. vertracloud_api-0.2.2/tests/test_client.py +0 -159
  18. vertracloud_api-0.2.2/tests/test_contract.py +0 -355
  19. vertracloud_api-0.2.2/tests/test_multipart_and_binary.py +0 -156
  20. vertracloud_api-0.2.2/tests/test_routes_matrix.py +0 -183
  21. vertracloud_api-0.2.2/tests/test_sse.py +0 -90
  22. vertracloud_api-0.2.2/uv.lock +0 -517
  23. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/.gitignore +0 -0
  24. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/LICENSE +0 -0
  25. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/__init__.py +0 -0
  26. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/errors.py +0 -0
  27. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/py.typed +0 -0
  28. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/__init__.py +0 -0
  29. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/account.py +0 -0
  30. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/billing.py +0 -0
  31. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/blob.py +0 -0
  32. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/databases.py +0 -0
  33. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/snapshots.py +0 -0
  34. {vertracloud_api-0.2.2 → vertracloud_api-0.3.0}/src/vertracloud/resources/workspaces.py +0 -0
  35. {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.2.2
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
- print(f"Hi {me['name']}! Plan: {me['plan']['name']}")
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`) | 40 |
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
- print(f"Hi {me['name']}! Plan: {me['plan']['name']}")
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`) | 40 |
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.2.2"
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.2.2" # x-release-please-version
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
- return quote(str(value), safe="")
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 — 40 routes (scopes `apps:read/write/delete/envs/files`).
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 AppCustomDomainResource:
174
+ class AppDomainsResource:
171
175
  def __init__(self, client: VertraClient) -> None:
172
176
  self._c = client
173
177
 
174
- def get(self, app_id: str, *, workspace_id: str | None = None) -> APICustomDomainResponse:
175
- """GET /v1/apps/:id/network/custom — scope `apps:read`."""
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)}/network/custom", query={"workspace_id": workspace_id}
181
+ "GET", f"/v1/apps/{encode_path_param(app_id)}/domains", query={"workspace_id": workspace_id}
178
182
  )
179
183
 
180
- def set(self, app_id: str, body: CustomDomainBody, *, workspace_id: str | None = None) -> APICustomDomainResponse:
181
- """POST /v1/apps/:id/network/custom — scope `apps:write`.
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)}/network/custom",
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 remove(self, app_id: str, *, workspace_id: str | None = None) -> None:
192
- """DELETE /v1/apps/:id/network/custom — scope `apps:write`."""
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
- "DELETE", f"/v1/apps/{encode_path_param(app_id)}/network/custom", query={"workspace_id": workspace_id}
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
- class AppNetworkResource:
199
- def __init__(self, client: VertraClient) -> None:
200
- self._c = client
201
- self.custom_domain = AppCustomDomainResource(client)
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 dns(self, app_id: str, *, workspace_id: str | None = None) -> list[APIDnsRecord]:
204
- """GET /v1/apps/:id/network/dns — scope `apps:read`."""
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
- "GET", f"/v1/apps/{encode_path_param(app_id)}/network/dns", query={"workspace_id": workspace_id}
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 = urllib.request.urlopen(req, timeout=timeout) # noqa: S310 - URL comes from the configured base_url
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
- class CustomDomainBody(TypedDict):
91
- domain: str
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
- owner_plan_id: str
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
- custom_domain: str | None
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
- custom_domain: str | None
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
- owner_plan_id: str
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
- id: str
618
- name: str
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
- owner_plan_id: str
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"}