vertracloud-api 0.2.2__tar.gz → 0.3.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.
Files changed (35) hide show
  1. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/CHANGELOG.md +26 -0
  2. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/PKG-INFO +24 -4
  3. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/README.md +23 -3
  4. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/pyproject.toml +4 -1
  5. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/client.py +36 -6
  6. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/resources/apps.py +51 -25
  7. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/transport.py +36 -1
  8. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/types.py +106 -29
  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.1}/.gitignore +0 -0
  24. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/LICENSE +0 -0
  25. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/__init__.py +0 -0
  26. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/errors.py +0 -0
  27. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/py.typed +0 -0
  28. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/resources/__init__.py +0 -0
  29. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/resources/account.py +0 -0
  30. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/resources/billing.py +0 -0
  31. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/resources/blob.py +0 -0
  32. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/resources/databases.py +0 -0
  33. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/resources/snapshots.py +0 -0
  34. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/resources/workspaces.py +0 -0
  35. {vertracloud_api-0.2.2 → vertracloud_api-0.3.1}/src/vertracloud/sse.py +0 -0
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1](https://github.com/vertracloud/sdk-api-py/compare/v0.3.0...v0.3.1) (2026-10-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * **apps:** Deno, .NET and Elixir and language on create ([c7a7bb3](https://github.com/vertracloud/sdk-api-py/commit/c7a7bb3e4141d8f0c45853807acfd49719841100))
9
+ * **apps:** remove cleanup_old_runtime_language from the restart body ([e8aa75d](https://github.com/vertracloud/sdk-api-py/commit/e8aa75daf69752af8ea16048e84e85857f613ff4))
10
+
11
+ ## [0.3.0](https://github.com/vertracloud/sdk-api-py/compare/v0.2.2...v0.3.0) (2026-10-22)
12
+
13
+
14
+ ### ⚠ BREAKING CHANGES
15
+
16
+ * plan names and multiple custom domains per app
17
+
18
+ ### Features
19
+
20
+ * plan names and multiple custom domains per app ([1df9eab](https://github.com/vertracloud/sdk-api-py/commit/1df9eab6d548a6136409652419afd26dbd6c8b41))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * drop the Authorization header when a redirect changes scheme, host or port ([70f5451](https://github.com/vertracloud/sdk-api-py/commit/70f5451d8ee02579344ac2fde46a16ed1b9952eb))
26
+ * reject empty and dot path segments before sending a request ([2964db9](https://github.com/vertracloud/sdk-api-py/commit/2964db9c6da64ce0427ee4b4c2975b012edbad8e))
27
+ * **upload:** escape quotes and reject CR/LF in multipart Content-Disposition ([e56ebb0](https://github.com/vertracloud/sdk-api-py/commit/e56ebb0ef9717cd45d766d94905225ede8c4466e))
28
+
3
29
  ## [0.2.2](https://github.com/vertracloud/sdk-api-py/compare/v0.2.1...v0.2.2) (2026-10-18)
4
30
 
5
31
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vertracloud-api
3
- Version: 0.2.2
3
+ Version: 0.3.1
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
@@ -157,7 +177,7 @@ except vertracloud.WaitTimeoutError: # still building after `timeout` seconds
157
177
  ...
158
178
  ```
159
179
 
160
- `missing_dependencies` on the app is filled only after the build ends. Before it ends, calls that need a stopped or running app can fail with `APP_BUILDING` (409).
180
+ While the build runs, calls that need a stopped or running app can fail with `APP_BUILDING` (409).
161
181
 
162
182
  ### Realtime logs (SSE)
163
183
 
@@ -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
@@ -138,7 +158,7 @@ except vertracloud.WaitTimeoutError: # still building after `timeout` seconds
138
158
  ...
139
159
  ```
140
160
 
141
- `missing_dependencies` on the app is filled only after the build ends. Before it ends, calls that need a stopped or running app can fail with `APP_BUILDING` (409).
161
+ While the build runs, calls that need a stopped or running app can fail with `APP_BUILDING` (409).
142
162
 
143
163
  ### Realtime logs (SSE)
144
164
 
@@ -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.1"
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.1" # 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)
@@ -508,7 +534,7 @@ class AppsResource:
508
534
  self, app_id: str, body: RestartAppBody | None = None, *, workspace_id: str | None = None
509
535
  ) -> APIApplicationOperationResponse:
510
536
  """POST /v1/apps/:id/restart — scope `apps:write`. Flags: `reinstall_dependencies`,
511
- `force_build`, `cleanup_old_runtime_language`."""
537
+ `force_build`."""
512
538
  return self._c.request_json(
513
539
  "POST",
514
540
  f"/v1/apps/{encode_path_param(app_id)}/restart",
@@ -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 {}