hotcodepush 0.2.0__tar.gz → 0.2.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.
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/PKG-INFO +11 -23
- hotcodepush-0.2.1/README.md +55 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/_http.py +8 -45
- hotcodepush-0.2.1/src/hotcodepush/_types.py +12 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/_upload.py +4 -13
- hotcodepush-0.2.1/src/hotcodepush/_version.py +1 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/client.py +9 -13
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/exceptions.py +3 -6
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/models.py +105 -82
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/_base.py +0 -2
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/apps.py +6 -5
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/audit_logs.py +6 -4
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/binaries.py +5 -6
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/bundle_deltas.py +5 -3
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/bundle_files.py +4 -4
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/bundle_pack.py +3 -3
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/bundle_pack_upload_parts.py +3 -3
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/bundle_pack_uploads.py +9 -9
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/bundles.py +12 -9
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/channel_audience.py +3 -3
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/channel_qr.py +4 -2
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/channel_releases.py +9 -7
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/channels.py +22 -14
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/checkouts.py +7 -6
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/customer_portal_sessions.py +6 -4
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/device_events.py +4 -3
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/devices.py +9 -5
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/file_upload_parts.py +3 -3
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/file_uploads.py +8 -9
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/files.py +6 -4
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/health.py +4 -2
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/invitations.py +6 -5
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/limits.py +1 -1
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/members.py +14 -9
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/notification_preferences.py +11 -7
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/notifications.py +7 -6
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/organization_apps.py +6 -3
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/organization_invitations.py +11 -7
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/organizations.py +11 -7
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/release_audience.py +3 -2
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/releases.py +18 -14
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/rollbacks.py +3 -2
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/signing_keys.py +5 -5
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/sso_provider.py +12 -7
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/sso_provider_verifications.py +6 -5
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/statistics_fleet.py +4 -1
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/statistics_updates.py +4 -3
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/statistics_usage.py +1 -1
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/subscription.py +6 -6
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/usage.py +14 -6
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/user_password.py +3 -4
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/user_sessions.py +3 -2
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/user_tokens.py +3 -2
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/users.py +8 -8
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/webhook_deliveries.py +9 -7
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/webhooks.py +20 -15
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/conftest.py +1 -2
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/openapi_document.py +1 -10
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/stub_api.py +1 -11
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_integration.py +2 -2
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_openapi.py +2 -9
- hotcodepush-0.2.0/README.md +0 -67
- hotcodepush-0.2.0/src/hotcodepush/_types.py +0 -15
- hotcodepush-0.2.0/src/hotcodepush/_version.py +0 -1
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/.gitignore +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/LICENSE +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/openapi.json +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/pyproject.toml +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/__init__.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/py.typed +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/__init__.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/src/hotcodepush/resources/statistics.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/__init__.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_apps.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_audit_logs.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_binaries.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_bundle_deltas.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_bundle_files.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_bundle_pack.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_bundle_pack_upload_parts.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_bundle_pack_uploads.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_bundles.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_channel_audience.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_channel_qr.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_channel_releases.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_channels.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_checkouts.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_customer_portal_sessions.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_device_events.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_devices.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_exceptions.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_file_upload_parts.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_file_uploads.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_files.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_health.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_http.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_invitations.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_limits.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_members.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_notification_preferences.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_notifications.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_organization_apps.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_organization_invitations.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_organizations.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_release_audience.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_releases.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_rollbacks.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_signing_keys.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_sso_provider.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_sso_provider_verifications.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_statistics_fleet.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_statistics_updates.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_statistics_usage.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_subscription.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_usage.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_user_password.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_user_sessions.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_user_tokens.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_users.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_webhook_deliveries.py +0 -0
- {hotcodepush-0.2.0 → hotcodepush-0.2.1}/tests/test_webhooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hotcodepush
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: The typed Python client for the HotCodePush REST API
|
|
5
5
|
Project-URL: Homepage, https://hotcodepush.com
|
|
6
6
|
Project-URL: Documentation, https://hotcodepush.com/docs/api/python
|
|
@@ -34,13 +34,7 @@ The typed Python client for the [HotCodePush](https://hotcodepush.com) REST API,
|
|
|
34
34
|
## Installation
|
|
35
35
|
|
|
36
36
|
```sh
|
|
37
|
-
pip install hotcodepush
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Or with uv:
|
|
41
|
-
|
|
42
|
-
```sh
|
|
43
|
-
uv add hotcodepush
|
|
37
|
+
pip install hotcodepush # or: uv add hotcodepush
|
|
44
38
|
```
|
|
45
39
|
|
|
46
40
|
It requires Python 3.11 or later.
|
|
@@ -61,33 +55,27 @@ hot_code_push.apps.channels.pause(app_id=app.id, channel_id=channel.id)
|
|
|
61
55
|
```
|
|
62
56
|
|
|
63
57
|
The resources mirror the API's paths, `/v1/apps/{appId}/channels` being `apps.channels`.
|
|
64
|
-
Every method takes keyword arguments and returns pydantic models with snake_case fields
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
An upload takes `bytes` or a file object with its `content_length`, and streams it.
|
|
69
|
-
Bytes above 64 MiB go up in parts, and a retry sends them again; a file object is read once, so a failed upload of one is not retried.
|
|
70
|
-
|
|
71
|
-
Creating an organization, an invitation, an app, a channel, a release, a rollback, a bundle or a binary sends an `Idempotency-Key`, a UUID per call kept across the client's own retries; pass `idempotency_key` to reuse one when you retry the call yourself, and the API answers the first result again for 24 hours.
|
|
72
|
-
|
|
73
|
-
A failed request raises a `HotCodePushError` with the API's `code`, `message` and `details` and the HTTP `status`.
|
|
58
|
+
Every method takes keyword arguments and returns pydantic models with snake_case fields; an argument left out stays out of the request, and `None` sends `null`.
|
|
59
|
+
Uploads take `bytes` or a file object with its `content_length` and stream it.
|
|
60
|
+
Creating calls send an `Idempotency-Key` and are retried; pass `idempotency_key` to reuse one across your own retries.
|
|
61
|
+
A failed request raises a `HotCodePushError` with the API's `code`, `message`, `details` and the HTTP `status`.
|
|
74
62
|
|
|
75
63
|
## Documentation
|
|
76
64
|
|
|
77
|
-
|
|
65
|
+
See [hotcodepush.com/docs/api/python](https://hotcodepush.com/docs/api/python).
|
|
78
66
|
|
|
79
67
|
## Development
|
|
80
68
|
|
|
81
69
|
```sh
|
|
82
70
|
uv sync
|
|
83
|
-
uv run ruff format --check .
|
|
84
|
-
uv run ruff check .
|
|
85
|
-
uv run mypy
|
|
86
71
|
uv run pytest
|
|
72
|
+
uv run ruff check . && uv run mypy
|
|
73
|
+
uv run ruff format . # run before every commit
|
|
87
74
|
uv build
|
|
88
75
|
```
|
|
89
76
|
|
|
90
|
-
`uv run
|
|
77
|
+
`uv run scripts/sync_openapi.py` fetches the API's OpenAPI document into `openapi.json`, which the contract test checks the client against.
|
|
78
|
+
`CLAUDE.md` holds the layout and the rules.
|
|
91
79
|
|
|
92
80
|
## License
|
|
93
81
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# hotcodepush
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/hotcodepush/)
|
|
4
|
+
|
|
5
|
+
The typed Python client for the [HotCodePush](https://hotcodepush.com) REST API, the over-the-air (OTA) update platform for React Native, Expo, Capacitor and Cordova.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install hotcodepush # or: uv add hotcodepush
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It requires Python 3.11 or later.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from hotcodepush import HotCodePush
|
|
19
|
+
|
|
20
|
+
hot_code_push = HotCodePush() # reads the API token from HOTCODEPUSH_TOKEN
|
|
21
|
+
|
|
22
|
+
organization, *_ = hot_code_push.organizations.list()
|
|
23
|
+
app = hot_code_push.organizations.apps.create(
|
|
24
|
+
framework="capacitor", name="Demo", organization_id=organization.id
|
|
25
|
+
)
|
|
26
|
+
channel = hot_code_push.apps.channels.create(app_id=app.id, name="staging")
|
|
27
|
+
hot_code_push.apps.channels.pause(app_id=app.id, channel_id=channel.id)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The resources mirror the API's paths, `/v1/apps/{appId}/channels` being `apps.channels`.
|
|
31
|
+
Every method takes keyword arguments and returns pydantic models with snake_case fields; an argument left out stays out of the request, and `None` sends `null`.
|
|
32
|
+
Uploads take `bytes` or a file object with its `content_length` and stream it.
|
|
33
|
+
Creating calls send an `Idempotency-Key` and are retried; pass `idempotency_key` to reuse one across your own retries.
|
|
34
|
+
A failed request raises a `HotCodePushError` with the API's `code`, `message`, `details` and the HTTP `status`.
|
|
35
|
+
|
|
36
|
+
## Documentation
|
|
37
|
+
|
|
38
|
+
See [hotcodepush.com/docs/api/python](https://hotcodepush.com/docs/api/python).
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
uv sync
|
|
44
|
+
uv run pytest
|
|
45
|
+
uv run ruff check . && uv run mypy
|
|
46
|
+
uv run ruff format . # run before every commit
|
|
47
|
+
uv build
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`uv run scripts/sync_openapi.py` fetches the API's OpenAPI document into `openapi.json`, which the contract test checks the client against.
|
|
51
|
+
`CLAUDE.md` holds the layout and the rules.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
See [LICENSE](./LICENSE).
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"""The one place that sends a request: the headers, the timeout, the retry, JSON in and out."""
|
|
2
|
-
|
|
3
1
|
from __future__ import annotations
|
|
4
2
|
|
|
5
3
|
import re
|
|
@@ -25,19 +23,19 @@ IDEMPOTENT_METHODS = frozenset({"DELETE", "GET", "PUT"})
|
|
|
25
23
|
INITIAL_RETRY_DELAY_SECONDS = 0.5
|
|
26
24
|
JSON_TIMEOUT_SECONDS = 60.0
|
|
27
25
|
MAX_ATTEMPTS = 3
|
|
26
|
+
# The API counts its rate limits in sixty-second windows.
|
|
28
27
|
MAX_RETRY_AFTER_SECONDS = 60.0
|
|
29
|
-
"""The longest wait a `Retry-After` is followed for: the API's rate limits count in sixty-second
|
|
30
|
-
windows."""
|
|
31
28
|
PATH_PARAMETER_PATTERN = re.compile(r"\{(\w+)\}")
|
|
32
29
|
RETRY_AFTER_STATUSES = frozenset({429, 503})
|
|
30
|
+
# Enough for the single-upload limit at about 100 kB a second.
|
|
33
31
|
UPLOAD_TIMEOUT_SECONDS = 600.0
|
|
34
|
-
"""Ten minutes per attempt: the single-upload limit at about a hundred kilobytes a second."""
|
|
35
32
|
|
|
36
33
|
HttpMethod = Literal["DELETE", "GET", "PATCH", "POST", "PUT"]
|
|
37
34
|
JsonBody = Mapping[str, object]
|
|
38
35
|
Query = Mapping[str, bool | date | datetime | int | str | Sequence[str] | NotGiven]
|
|
39
36
|
UploadBody = bytes | BinaryIO
|
|
40
|
-
"""
|
|
37
|
+
"""The body of an upload: bytes, which a failed attempt sends again, or a file object, read once
|
|
38
|
+
and never retried."""
|
|
41
39
|
|
|
42
40
|
T = TypeVar("T")
|
|
43
41
|
|
|
@@ -60,14 +58,10 @@ class HttpClient:
|
|
|
60
58
|
self._json_timeout_seconds = JSON_TIMEOUT_SECONDS if timeout is None else timeout
|
|
61
59
|
|
|
62
60
|
def close(self) -> None:
|
|
63
|
-
"""Closes the connection pool, unless the caller passed its own `http_client`."""
|
|
64
61
|
if self._is_httpx_client_owned:
|
|
65
62
|
self._httpx_client.close()
|
|
66
63
|
|
|
67
64
|
def fetch_bytes(self, method: HttpMethod, path: str, *, query: Query | None = None) -> bytes:
|
|
68
|
-
"""A request answered with a binary body, such as a CSV download or a channel's QR image,
|
|
69
|
-
read whole.
|
|
70
|
-
"""
|
|
71
65
|
return self._fetch_response(method, path, query=query).content
|
|
72
66
|
|
|
73
67
|
def fetch_creating_post(
|
|
@@ -78,12 +72,10 @@ class HttpClient:
|
|
|
78
72
|
body: JsonBody,
|
|
79
73
|
idempotency_key: str | NotGiven,
|
|
80
74
|
) -> T:
|
|
81
|
-
"""A creating `POST`, sent with an `Idempotency-Key`: the caller's, or one generated
|
|
82
|
-
for this call and kept across its retries, which the API answers with the first result.
|
|
83
|
-
"""
|
|
84
75
|
resolved_idempotency_key = (
|
|
85
76
|
str(uuid4()) if isinstance(idempotency_key, NotGiven) else idempotency_key
|
|
86
77
|
)
|
|
78
|
+
# The API answers a repeated key with the first result, so a retry is safe.
|
|
87
79
|
return self.fetch_json(
|
|
88
80
|
"POST",
|
|
89
81
|
path,
|
|
@@ -104,12 +96,6 @@ class HttpClient:
|
|
|
104
96
|
is_retryable: bool | None = None,
|
|
105
97
|
query: Query | None = None,
|
|
106
98
|
) -> T:
|
|
107
|
-
"""A request answered with JSON, validated into `response_type`.
|
|
108
|
-
|
|
109
|
-
`is_retryable` defaults to true for `DELETE`, `GET` and `PUT`, false for `PATCH` and
|
|
110
|
-
`POST`; a `POST` sets it where the API makes a repeat safe, through a stored idempotency
|
|
111
|
-
key or a transition that answers a repeat unchanged.
|
|
112
|
-
"""
|
|
113
99
|
response = self._fetch_response(
|
|
114
100
|
method, path, body=body, headers=headers, is_retryable=is_retryable, query=query
|
|
115
101
|
)
|
|
@@ -124,9 +110,6 @@ class HttpClient:
|
|
|
124
110
|
is_retryable: bool | None = None,
|
|
125
111
|
query: Query | None = None,
|
|
126
112
|
) -> None:
|
|
127
|
-
"""A request whose status is the whole answer: the body, such as `/health`'s
|
|
128
|
-
plain-text `ok`, is discarded. `is_retryable` defaults as `fetch_json`'s does.
|
|
129
|
-
"""
|
|
130
113
|
self._fetch_response(method, path, body=body, is_retryable=is_retryable, query=query)
|
|
131
114
|
|
|
132
115
|
def fetch_upload(
|
|
@@ -138,9 +121,6 @@ class HttpClient:
|
|
|
138
121
|
content_length: int | None,
|
|
139
122
|
content_type: str,
|
|
140
123
|
) -> T:
|
|
141
|
-
"""A binary `PUT`, streamed as it is read; a file object is read once, so only bytes
|
|
142
|
-
are retried.
|
|
143
|
-
"""
|
|
144
124
|
request = self._httpx_client.build_request(
|
|
145
125
|
"PUT",
|
|
146
126
|
self._base_url + path,
|
|
@@ -210,9 +190,6 @@ def resolve_headers(*, client: str, token: str | None, user_agent: str | None) -
|
|
|
210
190
|
|
|
211
191
|
|
|
212
192
|
def resolve_json_body(body: JsonBody) -> dict[str, object]:
|
|
213
|
-
"""The body without its `NOT_GIVEN` values, models by their API names without their `None`
|
|
214
|
-
fields, since no nested field of the API takes `null`, and timestamps in UTC.
|
|
215
|
-
"""
|
|
216
193
|
return {
|
|
217
194
|
name: resolve_json_value(value)
|
|
218
195
|
for name, value in body.items()
|
|
@@ -222,6 +199,7 @@ def resolve_json_body(body: JsonBody) -> dict[str, object]:
|
|
|
222
199
|
|
|
223
200
|
def resolve_json_value(value: object) -> object:
|
|
224
201
|
if isinstance(value, BaseModel):
|
|
202
|
+
# No nested field of the API takes `null`.
|
|
225
203
|
return value.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
226
204
|
if isinstance(value, datetime):
|
|
227
205
|
return resolve_utc_timestamp(value)
|
|
@@ -231,10 +209,6 @@ def resolve_json_value(value: object) -> object:
|
|
|
231
209
|
|
|
232
210
|
|
|
233
211
|
def resolve_path(template: str, **parameters: int | str) -> str:
|
|
234
|
-
"""Fills a path template of the OpenAPI snapshot, `/v1/apps/{appId}`, with its encoded
|
|
235
|
-
parameters.
|
|
236
|
-
"""
|
|
237
|
-
|
|
238
212
|
def resolve_parameter(match: re.Match[str]) -> str:
|
|
239
213
|
name = match.group(1)
|
|
240
214
|
if name not in parameters:
|
|
@@ -245,15 +219,12 @@ def resolve_path(template: str, **parameters: int | str) -> str:
|
|
|
245
219
|
|
|
246
220
|
|
|
247
221
|
def resolve_query_params(query: Query) -> tuple[tuple[str, str], ...]:
|
|
248
|
-
"""The query as name and value pairs: `NOT_GIVEN`, empty strings and empty lists are left
|
|
249
|
-
out; `ids` and `relations` are the API's comma lists, and every other list repeats its
|
|
250
|
-
parameter, `?binary=…&binary=…`, since its values may hold a comma.
|
|
251
|
-
"""
|
|
252
222
|
params: list[tuple[str, str]] = []
|
|
253
223
|
for name, value in query.items():
|
|
254
224
|
if isinstance(value, NotGiven) or value == "" or value == []:
|
|
255
225
|
continue
|
|
256
226
|
if isinstance(value, list):
|
|
227
|
+
# Other lists repeat their parameter, since their values may hold a comma.
|
|
257
228
|
values = [",".join(value)] if name in COMMA_LIST_QUERY_NAMES else value
|
|
258
229
|
params.extend((name, item) for item in values)
|
|
259
230
|
elif isinstance(value, bool):
|
|
@@ -266,10 +237,6 @@ def resolve_query_params(query: Query) -> tuple[tuple[str, str], ...]:
|
|
|
266
237
|
|
|
267
238
|
|
|
268
239
|
def resolve_retry_after_seconds(response: httpx.Response) -> float | None:
|
|
269
|
-
"""The wait a 429 or a 503 asks for in `Retry-After`, delay seconds or an HTTP date, capped
|
|
270
|
-
at `MAX_RETRY_AFTER_SECONDS`; None for any other status, a missing header or one that does
|
|
271
|
-
not parse.
|
|
272
|
-
"""
|
|
273
240
|
retry_after = response.headers.get("Retry-After", "").strip()
|
|
274
241
|
if response.status_code not in RETRY_AFTER_STATUSES or not retry_after:
|
|
275
242
|
return None
|
|
@@ -285,15 +252,11 @@ def resolve_retry_after_seconds(response: httpx.Response) -> float | None:
|
|
|
285
252
|
|
|
286
253
|
|
|
287
254
|
def resolve_utc_timestamp(value: datetime) -> str:
|
|
288
|
-
|
|
255
|
+
# The API accepts only the `Z` offset.
|
|
289
256
|
return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
290
257
|
|
|
291
258
|
|
|
292
259
|
def send_with_retry(send: Callable[[], httpx.Response]) -> httpx.Response:
|
|
293
|
-
"""Sends up to `MAX_ATTEMPTS` times on a transport error, 408, 429 and 5xx, backing off
|
|
294
|
-
exponentially from `INITIAL_RETRY_DELAY_SECONDS`, or waiting a 429's or a 503's
|
|
295
|
-
`Retry-After`; the last attempt's response or error is the call's.
|
|
296
|
-
"""
|
|
297
260
|
attempt = 1
|
|
298
261
|
while True:
|
|
299
262
|
is_last_attempt = attempt == MAX_ATTEMPTS
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class NotGiven:
|
|
2
|
+
"""The type of `NOT_GIVEN`, the default of every optional argument.
|
|
3
|
+
|
|
4
|
+
An argument left at `NOT_GIVEN` stays out of the request. `None` sends JSON `null` where the
|
|
5
|
+
API takes it, for example to clear a channel's `expires_at`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
def __repr__(self) -> str:
|
|
9
|
+
return "NOT_GIVEN"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
NOT_GIVEN = NotGiven()
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"""The single-or-parts rule of an upload: bytes above the single-upload limit go up in parts."""
|
|
2
|
-
|
|
3
1
|
from __future__ import annotations
|
|
4
2
|
|
|
5
3
|
from collections.abc import Callable
|
|
@@ -10,17 +8,16 @@ from ._http import UploadBody
|
|
|
10
8
|
from .models import MultipartUpload, UploadedPart
|
|
11
9
|
|
|
12
10
|
PART_SIZE_BYTES = 10 * 1024 * 1024
|
|
13
|
-
"""
|
|
14
|
-
rule."""
|
|
11
|
+
"""The size of every part but the last. The bucket needs parts of one size, at least 5 MiB."""
|
|
15
12
|
SINGLE_UPLOAD_LIMIT_BYTES = 64 * 1024 * 1024
|
|
16
|
-
"""
|
|
17
|
-
|
|
13
|
+
"""The largest bytes body sent in one request; a larger one goes up in parts. It stays well under
|
|
14
|
+
the API's request body limit."""
|
|
18
15
|
|
|
19
16
|
T = TypeVar("T")
|
|
20
17
|
|
|
21
18
|
|
|
22
19
|
def is_above_single_upload_limit(body: UploadBody) -> TypeGuard[bytes]:
|
|
23
|
-
|
|
20
|
+
# A file object is read once and cannot be split.
|
|
24
21
|
return isinstance(body, bytes) and len(body) > SINGLE_UPLOAD_LIMIT_BYTES
|
|
25
22
|
|
|
26
23
|
|
|
@@ -32,12 +29,6 @@ def upload_in_parts(
|
|
|
32
29
|
delete: Callable[[str], None],
|
|
33
30
|
upload_part: Callable[[str, int, bytes], UploadedPart],
|
|
34
31
|
) -> T:
|
|
35
|
-
"""Uploads bytes in parts of `PART_SIZE_BYTES`, numbered from 1, then completes the upload;
|
|
36
|
-
a failed part or completion deletes the upload and re-raises.
|
|
37
|
-
|
|
38
|
-
Each callable is one call of the multipart upload the files and the pack share, bound to the
|
|
39
|
-
path parameters of its target.
|
|
40
|
-
"""
|
|
41
32
|
upload_id = create().upload_id
|
|
42
33
|
try:
|
|
43
34
|
uploaded_parts = [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1" # x-release-please-version
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"""The client for the HotCodePush REST API."""
|
|
2
|
-
|
|
3
1
|
from __future__ import annotations
|
|
4
2
|
|
|
5
3
|
import os
|
|
@@ -23,20 +21,18 @@ class HotCodePush:
|
|
|
23
21
|
"""The client for the HotCodePush REST API.
|
|
24
22
|
|
|
25
23
|
Resources mirror the paths: `/v1/organizations/{organizationId}/apps` is
|
|
26
|
-
`organizations.apps`, `/v1/apps/{appId}/channels` is `apps.channels`.
|
|
24
|
+
`organizations.apps`, and `/v1/apps/{appId}/channels` is `apps.channels`.
|
|
27
25
|
|
|
28
26
|
Args:
|
|
29
|
-
token: The API token, sent as `Authorization: Bearer <token
|
|
30
|
-
`HOTCODEPUSH_TOKEN` environment variable
|
|
31
|
-
none.
|
|
27
|
+
token: The API token, sent as `Authorization: Bearer <token>`. Falls back to the
|
|
28
|
+
`HOTCODEPUSH_TOKEN` environment variable. Optional, since `/health` needs none.
|
|
32
29
|
base_url: The base URL of the API.
|
|
33
30
|
client: The `X-HotCodePush-Client` header, `<name>/<version>`.
|
|
34
|
-
http_client:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
the sessions page names the client by it.
|
|
31
|
+
http_client: An `httpx.Client` to send the requests with. `close` leaves it open.
|
|
32
|
+
timeout: The seconds each phase of a JSON request attempt may take: connect, read,
|
|
33
|
+
write and pool. Sixty when unset. An upload attempt always gets ten minutes.
|
|
34
|
+
user_agent: The `User-Agent` header, sent only when set. A session stores it, and the
|
|
35
|
+
sessions page names the client by it.
|
|
40
36
|
"""
|
|
41
37
|
|
|
42
38
|
def __init__(
|
|
@@ -66,7 +62,7 @@ class HotCodePush:
|
|
|
66
62
|
self.users = UsersResource(self._http_client)
|
|
67
63
|
|
|
68
64
|
def close(self) -> None:
|
|
69
|
-
"""Closes the connection pool, unless the
|
|
65
|
+
"""Closes the connection pool, unless the caller passed its own `http_client`."""
|
|
70
66
|
self._http_client.close()
|
|
71
67
|
|
|
72
68
|
def __enter__(self) -> HotCodePush:
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"""The error raised for an API answer outside 2xx."""
|
|
2
|
-
|
|
3
1
|
from __future__ import annotations
|
|
4
2
|
|
|
5
3
|
import json
|
|
@@ -8,9 +6,9 @@ import json
|
|
|
8
6
|
class HotCodePushError(Exception):
|
|
9
7
|
"""Raised when the API answers with a status outside 2xx.
|
|
10
8
|
|
|
11
|
-
`code`, `message` and `details`
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
`code`, `message` and `details` come verbatim from the API's error body. A response without
|
|
10
|
+
that shape, such as a page from a proxy in front of the API, raises `unexpected_response` with
|
|
11
|
+
the status and without the body.
|
|
14
12
|
"""
|
|
15
13
|
|
|
16
14
|
def __init__(self, *, code: str, details: object, message: str, status: int) -> None:
|
|
@@ -37,7 +35,6 @@ def resolve_hot_code_push_error(status: int, text: str) -> HotCodePushError:
|
|
|
37
35
|
|
|
38
36
|
|
|
39
37
|
def resolve_json_object(text: str) -> dict[str, object]:
|
|
40
|
-
"""The JSON object the text holds; empty when it holds none, such as an HTML page."""
|
|
41
38
|
try:
|
|
42
39
|
body: object = json.loads(text)
|
|
43
40
|
except ValueError:
|