hotcodepush 0.1.0__py3-none-any.whl
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/__init__.py +183 -0
- hotcodepush/_http.py +314 -0
- hotcodepush/_types.py +15 -0
- hotcodepush/_upload.py +51 -0
- hotcodepush/_version.py +1 -0
- hotcodepush/client.py +81 -0
- hotcodepush/exceptions.py +45 -0
- hotcodepush/models.py +899 -0
- hotcodepush/py.typed +0 -0
- hotcodepush/resources/__init__.py +0 -0
- hotcodepush/resources/_base.py +10 -0
- hotcodepush/resources/apps.py +78 -0
- hotcodepush/resources/audit_logs.py +112 -0
- hotcodepush/resources/binaries.py +84 -0
- hotcodepush/resources/bundle_deltas.py +41 -0
- hotcodepush/resources/bundle_files.py +44 -0
- hotcodepush/resources/bundle_pack.py +54 -0
- hotcodepush/resources/bundle_pack_upload_parts.py +36 -0
- hotcodepush/resources/bundle_pack_uploads.py +65 -0
- hotcodepush/resources/bundles.py +147 -0
- hotcodepush/resources/channel_audience.py +43 -0
- hotcodepush/resources/channel_qr.py +27 -0
- hotcodepush/resources/channel_releases.py +119 -0
- hotcodepush/resources/channels.py +169 -0
- hotcodepush/resources/checkouts.py +26 -0
- hotcodepush/resources/customer_portal_sessions.py +25 -0
- hotcodepush/resources/device_events.py +44 -0
- hotcodepush/resources/devices.py +116 -0
- hotcodepush/resources/file_upload_parts.py +36 -0
- hotcodepush/resources/file_uploads.py +63 -0
- hotcodepush/resources/files.py +53 -0
- hotcodepush/resources/health.py +11 -0
- hotcodepush/resources/invitations.py +50 -0
- hotcodepush/resources/limits.py +20 -0
- hotcodepush/resources/members.py +121 -0
- hotcodepush/resources/notification_preferences.py +51 -0
- hotcodepush/resources/notifications.py +62 -0
- hotcodepush/resources/organization_apps.py +51 -0
- hotcodepush/resources/organization_invitations.py +112 -0
- hotcodepush/resources/organizations.py +110 -0
- hotcodepush/resources/release_audience.py +21 -0
- hotcodepush/resources/releases.py +162 -0
- hotcodepush/resources/rollbacks.py +31 -0
- hotcodepush/resources/signing_keys.py +53 -0
- hotcodepush/resources/sso_provider.py +61 -0
- hotcodepush/resources/sso_provider_verifications.py +27 -0
- hotcodepush/resources/statistics.py +15 -0
- hotcodepush/resources/statistics_fleet.py +17 -0
- hotcodepush/resources/statistics_updates.py +35 -0
- hotcodepush/resources/statistics_usage.py +25 -0
- hotcodepush/resources/subscription.py +53 -0
- hotcodepush/resources/usage.py +66 -0
- hotcodepush/resources/user_password.py +21 -0
- hotcodepush/resources/user_sessions.py +18 -0
- hotcodepush/resources/user_tokens.py +18 -0
- hotcodepush/resources/users.py +46 -0
- hotcodepush/resources/webhook_deliveries.py +91 -0
- hotcodepush/resources/webhooks.py +158 -0
- hotcodepush-0.1.0.dist-info/METADATA +98 -0
- hotcodepush-0.1.0.dist-info/RECORD +62 -0
- hotcodepush-0.1.0.dist-info/WHEEL +4 -0
- hotcodepush-0.1.0.dist-info/licenses/LICENSE +21 -0
hotcodepush/__init__.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""The typed Python client for the HotCodePush REST API."""
|
|
2
|
+
|
|
3
|
+
from ._http import UploadBody
|
|
4
|
+
from ._types import NOT_GIVEN, NotGiven
|
|
5
|
+
from ._upload import PART_SIZE_BYTES, SINGLE_UPLOAD_LIMIT_BYTES
|
|
6
|
+
from ._version import __version__
|
|
7
|
+
from .client import HotCodePush
|
|
8
|
+
from .exceptions import HotCodePushError
|
|
9
|
+
from .models import (
|
|
10
|
+
Account,
|
|
11
|
+
AdoptionPoint,
|
|
12
|
+
App,
|
|
13
|
+
AppFile,
|
|
14
|
+
AppUsageMonth,
|
|
15
|
+
AppUsageMonthRelation,
|
|
16
|
+
AttributeCondition,
|
|
17
|
+
Audience,
|
|
18
|
+
AudienceWarning,
|
|
19
|
+
AuditLog,
|
|
20
|
+
AuditLogRelation,
|
|
21
|
+
Binary,
|
|
22
|
+
BinaryCondition,
|
|
23
|
+
BinaryCount,
|
|
24
|
+
Bundle,
|
|
25
|
+
BundleDelta,
|
|
26
|
+
BundleDeltaRelation,
|
|
27
|
+
BundleFile,
|
|
28
|
+
BundleSignature,
|
|
29
|
+
Channel,
|
|
30
|
+
Checkout,
|
|
31
|
+
Count,
|
|
32
|
+
CreatedWebhook,
|
|
33
|
+
CustomerPortalSession,
|
|
34
|
+
DailyUpdateCounts,
|
|
35
|
+
Device,
|
|
36
|
+
DeviceCondition,
|
|
37
|
+
DeviceCount,
|
|
38
|
+
DeviceRelation,
|
|
39
|
+
EmbeddedRelease,
|
|
40
|
+
EmbeddedUser,
|
|
41
|
+
FailureAction,
|
|
42
|
+
FingerprintCondition,
|
|
43
|
+
FleetStatistics,
|
|
44
|
+
Framework,
|
|
45
|
+
HotCodePushModel,
|
|
46
|
+
Invitation,
|
|
47
|
+
InvitationRelation,
|
|
48
|
+
InvitationRole,
|
|
49
|
+
Limit,
|
|
50
|
+
ManifestFile,
|
|
51
|
+
Member,
|
|
52
|
+
MemberRelation,
|
|
53
|
+
MultipartUpload,
|
|
54
|
+
NamedEmbed,
|
|
55
|
+
Notification,
|
|
56
|
+
NotificationCategory,
|
|
57
|
+
NotificationMedium,
|
|
58
|
+
NotificationPreference,
|
|
59
|
+
NotificationRelation,
|
|
60
|
+
OidcConfig,
|
|
61
|
+
Organization,
|
|
62
|
+
OsCondition,
|
|
63
|
+
OsVersionCount,
|
|
64
|
+
Platform,
|
|
65
|
+
ReasonCount,
|
|
66
|
+
Release,
|
|
67
|
+
ReleaseAdoption,
|
|
68
|
+
ReleaseCondition,
|
|
69
|
+
ReleaseCount,
|
|
70
|
+
ReleaseCounters,
|
|
71
|
+
ReleaseDeviceEvent,
|
|
72
|
+
ReleaseProgression,
|
|
73
|
+
ReleaseRelation,
|
|
74
|
+
Role,
|
|
75
|
+
SamlConfig,
|
|
76
|
+
SigningKey,
|
|
77
|
+
SsoProvider,
|
|
78
|
+
TimeToAdoption,
|
|
79
|
+
UpdateStatistics,
|
|
80
|
+
UploadedBundlePack,
|
|
81
|
+
UploadedPart,
|
|
82
|
+
Usage,
|
|
83
|
+
UsageDay,
|
|
84
|
+
UsageMonth,
|
|
85
|
+
UsageStatistics,
|
|
86
|
+
User,
|
|
87
|
+
UserRelation,
|
|
88
|
+
Webhook,
|
|
89
|
+
WebhookDelivery,
|
|
90
|
+
WebhookEvent,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"NOT_GIVEN",
|
|
95
|
+
"PART_SIZE_BYTES",
|
|
96
|
+
"SINGLE_UPLOAD_LIMIT_BYTES",
|
|
97
|
+
"Account",
|
|
98
|
+
"AdoptionPoint",
|
|
99
|
+
"App",
|
|
100
|
+
"AppFile",
|
|
101
|
+
"AppUsageMonth",
|
|
102
|
+
"AppUsageMonthRelation",
|
|
103
|
+
"AttributeCondition",
|
|
104
|
+
"Audience",
|
|
105
|
+
"AudienceWarning",
|
|
106
|
+
"AuditLog",
|
|
107
|
+
"AuditLogRelation",
|
|
108
|
+
"Binary",
|
|
109
|
+
"BinaryCondition",
|
|
110
|
+
"BinaryCount",
|
|
111
|
+
"Bundle",
|
|
112
|
+
"BundleDelta",
|
|
113
|
+
"BundleDeltaRelation",
|
|
114
|
+
"BundleFile",
|
|
115
|
+
"BundleSignature",
|
|
116
|
+
"Channel",
|
|
117
|
+
"Checkout",
|
|
118
|
+
"Count",
|
|
119
|
+
"CreatedWebhook",
|
|
120
|
+
"CustomerPortalSession",
|
|
121
|
+
"DailyUpdateCounts",
|
|
122
|
+
"Device",
|
|
123
|
+
"DeviceCondition",
|
|
124
|
+
"DeviceCount",
|
|
125
|
+
"DeviceRelation",
|
|
126
|
+
"EmbeddedRelease",
|
|
127
|
+
"EmbeddedUser",
|
|
128
|
+
"FailureAction",
|
|
129
|
+
"FingerprintCondition",
|
|
130
|
+
"FleetStatistics",
|
|
131
|
+
"Framework",
|
|
132
|
+
"HotCodePush",
|
|
133
|
+
"HotCodePushError",
|
|
134
|
+
"HotCodePushModel",
|
|
135
|
+
"Invitation",
|
|
136
|
+
"InvitationRelation",
|
|
137
|
+
"InvitationRole",
|
|
138
|
+
"Limit",
|
|
139
|
+
"ManifestFile",
|
|
140
|
+
"Member",
|
|
141
|
+
"MemberRelation",
|
|
142
|
+
"MultipartUpload",
|
|
143
|
+
"NamedEmbed",
|
|
144
|
+
"NotGiven",
|
|
145
|
+
"Notification",
|
|
146
|
+
"NotificationCategory",
|
|
147
|
+
"NotificationMedium",
|
|
148
|
+
"NotificationPreference",
|
|
149
|
+
"NotificationRelation",
|
|
150
|
+
"OidcConfig",
|
|
151
|
+
"Organization",
|
|
152
|
+
"OsCondition",
|
|
153
|
+
"OsVersionCount",
|
|
154
|
+
"Platform",
|
|
155
|
+
"ReasonCount",
|
|
156
|
+
"Release",
|
|
157
|
+
"ReleaseAdoption",
|
|
158
|
+
"ReleaseCondition",
|
|
159
|
+
"ReleaseCount",
|
|
160
|
+
"ReleaseCounters",
|
|
161
|
+
"ReleaseDeviceEvent",
|
|
162
|
+
"ReleaseProgression",
|
|
163
|
+
"ReleaseRelation",
|
|
164
|
+
"Role",
|
|
165
|
+
"SamlConfig",
|
|
166
|
+
"SigningKey",
|
|
167
|
+
"SsoProvider",
|
|
168
|
+
"TimeToAdoption",
|
|
169
|
+
"UpdateStatistics",
|
|
170
|
+
"UploadBody",
|
|
171
|
+
"UploadedBundlePack",
|
|
172
|
+
"UploadedPart",
|
|
173
|
+
"Usage",
|
|
174
|
+
"UsageDay",
|
|
175
|
+
"UsageMonth",
|
|
176
|
+
"UsageStatistics",
|
|
177
|
+
"User",
|
|
178
|
+
"UserRelation",
|
|
179
|
+
"Webhook",
|
|
180
|
+
"WebhookDelivery",
|
|
181
|
+
"WebhookEvent",
|
|
182
|
+
"__version__",
|
|
183
|
+
]
|
hotcodepush/_http.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""The one place that sends a request: the headers, the timeout, the retry, JSON in and out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
from datetime import UTC, date, datetime
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
from time import sleep
|
|
10
|
+
from typing import BinaryIO, Literal, TypeVar
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import BaseModel, TypeAdapter
|
|
16
|
+
|
|
17
|
+
from ._types import NotGiven
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .exceptions import resolve_hot_code_push_error
|
|
20
|
+
|
|
21
|
+
COMMA_LIST_QUERY_NAMES = frozenset({"ids", "relations"})
|
|
22
|
+
DEFAULT_BASE_URL = "https://api.hotcodepush.com"
|
|
23
|
+
DEFAULT_CLIENT = f"python/{__version__}"
|
|
24
|
+
IDEMPOTENT_METHODS = frozenset({"DELETE", "GET", "PUT"})
|
|
25
|
+
INITIAL_RETRY_DELAY_SECONDS = 0.5
|
|
26
|
+
JSON_TIMEOUT_SECONDS = 60.0
|
|
27
|
+
MAX_ATTEMPTS = 3
|
|
28
|
+
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
|
+
PATH_PARAMETER_PATTERN = re.compile(r"\{(\w+)\}")
|
|
32
|
+
RETRY_AFTER_STATUSES = frozenset({429, 503})
|
|
33
|
+
UPLOAD_TIMEOUT_SECONDS = 600.0
|
|
34
|
+
"""Ten minutes per attempt: the single-upload limit at about a hundred kilobytes a second."""
|
|
35
|
+
|
|
36
|
+
HttpMethod = Literal["DELETE", "GET", "PATCH", "POST", "PUT"]
|
|
37
|
+
JsonBody = Mapping[str, object]
|
|
38
|
+
Query = Mapping[str, bool | date | datetime | int | str | Sequence[str] | NotGiven]
|
|
39
|
+
UploadBody = bytes | BinaryIO
|
|
40
|
+
"""Bytes, read again when a retry needs them, or a file object, read once and never retried."""
|
|
41
|
+
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HttpClient:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
base_url: str,
|
|
50
|
+
client: str,
|
|
51
|
+
http_client: httpx.Client | None,
|
|
52
|
+
timeout: float | None,
|
|
53
|
+
token: str | None,
|
|
54
|
+
user_agent: str | None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._base_url = base_url.rstrip("/")
|
|
57
|
+
self._headers = resolve_headers(client=client, token=token, user_agent=user_agent)
|
|
58
|
+
self._httpx_client = http_client or httpx.Client()
|
|
59
|
+
self._is_httpx_client_owned = http_client is None
|
|
60
|
+
self._json_timeout_seconds = JSON_TIMEOUT_SECONDS if timeout is None else timeout
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
"""Closes the connection pool, unless the caller passed its own `http_client`."""
|
|
64
|
+
if self._is_httpx_client_owned:
|
|
65
|
+
self._httpx_client.close()
|
|
66
|
+
|
|
67
|
+
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
|
+
return self._fetch_response(method, path, query=query).content
|
|
72
|
+
|
|
73
|
+
def fetch_creating_post(
|
|
74
|
+
self,
|
|
75
|
+
path: str,
|
|
76
|
+
response_type: type[T],
|
|
77
|
+
*,
|
|
78
|
+
body: JsonBody,
|
|
79
|
+
idempotency_key: str | NotGiven,
|
|
80
|
+
) -> 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
|
+
resolved_idempotency_key = (
|
|
85
|
+
str(uuid4()) if isinstance(idempotency_key, NotGiven) else idempotency_key
|
|
86
|
+
)
|
|
87
|
+
return self.fetch_json(
|
|
88
|
+
"POST",
|
|
89
|
+
path,
|
|
90
|
+
response_type,
|
|
91
|
+
body=body,
|
|
92
|
+
headers={"Idempotency-Key": resolved_idempotency_key},
|
|
93
|
+
is_retryable=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def fetch_json(
|
|
97
|
+
self,
|
|
98
|
+
method: HttpMethod,
|
|
99
|
+
path: str,
|
|
100
|
+
response_type: type[T],
|
|
101
|
+
*,
|
|
102
|
+
body: JsonBody | None = None,
|
|
103
|
+
headers: Mapping[str, str] | None = None,
|
|
104
|
+
is_retryable: bool | None = None,
|
|
105
|
+
query: Query | None = None,
|
|
106
|
+
) -> 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
|
+
response = self._fetch_response(
|
|
114
|
+
method, path, body=body, headers=headers, is_retryable=is_retryable, query=query
|
|
115
|
+
)
|
|
116
|
+
return TypeAdapter(response_type).validate_json(response.content)
|
|
117
|
+
|
|
118
|
+
def fetch_status(
|
|
119
|
+
self,
|
|
120
|
+
method: HttpMethod,
|
|
121
|
+
path: str,
|
|
122
|
+
*,
|
|
123
|
+
body: JsonBody | None = None,
|
|
124
|
+
is_retryable: bool | None = None,
|
|
125
|
+
query: Query | None = None,
|
|
126
|
+
) -> 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
|
+
self._fetch_response(method, path, body=body, is_retryable=is_retryable, query=query)
|
|
131
|
+
|
|
132
|
+
def fetch_upload(
|
|
133
|
+
self,
|
|
134
|
+
path: str,
|
|
135
|
+
response_type: type[T],
|
|
136
|
+
*,
|
|
137
|
+
body: UploadBody,
|
|
138
|
+
content_length: int | None,
|
|
139
|
+
content_type: str,
|
|
140
|
+
) -> T:
|
|
141
|
+
"""A binary `PUT`, streamed as it is read; a file object is read once, so only bytes
|
|
142
|
+
are retried.
|
|
143
|
+
"""
|
|
144
|
+
request = self._httpx_client.build_request(
|
|
145
|
+
"PUT",
|
|
146
|
+
self._base_url + path,
|
|
147
|
+
content=body,
|
|
148
|
+
headers={
|
|
149
|
+
**self._headers,
|
|
150
|
+
"Content-Length": str(resolve_content_length(body, content_length)),
|
|
151
|
+
"Content-Type": content_type,
|
|
152
|
+
},
|
|
153
|
+
timeout=UPLOAD_TIMEOUT_SECONDS,
|
|
154
|
+
)
|
|
155
|
+
response = self._send(request, is_retryable=isinstance(body, bytes))
|
|
156
|
+
return TypeAdapter(response_type).validate_json(response.content)
|
|
157
|
+
|
|
158
|
+
def _fetch_response(
|
|
159
|
+
self,
|
|
160
|
+
method: HttpMethod,
|
|
161
|
+
path: str,
|
|
162
|
+
*,
|
|
163
|
+
body: JsonBody | None = None,
|
|
164
|
+
headers: Mapping[str, str] | None = None,
|
|
165
|
+
is_retryable: bool | None = None,
|
|
166
|
+
query: Query | None = None,
|
|
167
|
+
) -> httpx.Response:
|
|
168
|
+
request = self._httpx_client.build_request(
|
|
169
|
+
method,
|
|
170
|
+
self._base_url + path,
|
|
171
|
+
headers={**self._headers, **(headers or {})},
|
|
172
|
+
json=None if body is None else resolve_json_body(body),
|
|
173
|
+
params=resolve_query_params(query or {}),
|
|
174
|
+
timeout=self._json_timeout_seconds,
|
|
175
|
+
)
|
|
176
|
+
return self._send(
|
|
177
|
+
request,
|
|
178
|
+
is_retryable=method in IDEMPOTENT_METHODS if is_retryable is None else is_retryable,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _send(self, request: httpx.Request, *, is_retryable: bool) -> httpx.Response:
|
|
182
|
+
def send() -> httpx.Response:
|
|
183
|
+
return self._httpx_client.send(request)
|
|
184
|
+
|
|
185
|
+
response = send_with_retry(send) if is_retryable else send()
|
|
186
|
+
if not response.is_success:
|
|
187
|
+
raise resolve_hot_code_push_error(response.status_code, response.text)
|
|
188
|
+
return response
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def is_retryable_status(status: int) -> bool:
|
|
192
|
+
return status in (408, 429) or status >= 500
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def resolve_content_length(body: UploadBody, content_length: int | None) -> int:
|
|
196
|
+
if isinstance(body, bytes):
|
|
197
|
+
return len(body)
|
|
198
|
+
if content_length is None:
|
|
199
|
+
raise ValueError("A file object body needs its content_length.")
|
|
200
|
+
return content_length
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def resolve_headers(*, client: str, token: str | None, user_agent: str | None) -> dict[str, str]:
|
|
204
|
+
headers = {"Accept": "application/json", "X-HotCodePush-Client": client}
|
|
205
|
+
if token:
|
|
206
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
207
|
+
if user_agent:
|
|
208
|
+
headers["User-Agent"] = user_agent
|
|
209
|
+
return headers
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
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
|
+
return {
|
|
217
|
+
name: resolve_json_value(value)
|
|
218
|
+
for name, value in body.items()
|
|
219
|
+
if not isinstance(value, NotGiven)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def resolve_json_value(value: object) -> object:
|
|
224
|
+
if isinstance(value, BaseModel):
|
|
225
|
+
return value.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
226
|
+
if isinstance(value, datetime):
|
|
227
|
+
return resolve_utc_timestamp(value)
|
|
228
|
+
if isinstance(value, list):
|
|
229
|
+
return [resolve_json_value(item) for item in value]
|
|
230
|
+
return value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
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
|
+
def resolve_parameter(match: re.Match[str]) -> str:
|
|
239
|
+
name = match.group(1)
|
|
240
|
+
if name not in parameters:
|
|
241
|
+
raise ValueError(f"The path parameter {name} of {template} is missing.")
|
|
242
|
+
return quote(str(parameters[name]), safe="")
|
|
243
|
+
|
|
244
|
+
return PATH_PARAMETER_PATTERN.sub(resolve_parameter, template)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
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
|
+
params: list[tuple[str, str]] = []
|
|
253
|
+
for name, value in query.items():
|
|
254
|
+
if isinstance(value, NotGiven) or value == "" or value == []:
|
|
255
|
+
continue
|
|
256
|
+
if isinstance(value, list):
|
|
257
|
+
values = [",".join(value)] if name in COMMA_LIST_QUERY_NAMES else value
|
|
258
|
+
params.extend((name, item) for item in values)
|
|
259
|
+
elif isinstance(value, bool):
|
|
260
|
+
params.append((name, "true" if value else "false"))
|
|
261
|
+
elif isinstance(value, datetime):
|
|
262
|
+
params.append((name, resolve_utc_timestamp(value)))
|
|
263
|
+
else:
|
|
264
|
+
params.append((name, str(value)))
|
|
265
|
+
return tuple(params)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
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
|
+
retry_after = response.headers.get("Retry-After", "").strip()
|
|
274
|
+
if response.status_code not in RETRY_AFTER_STATUSES or not retry_after:
|
|
275
|
+
return None
|
|
276
|
+
if retry_after.isdigit():
|
|
277
|
+
retry_after_seconds = float(retry_after)
|
|
278
|
+
else:
|
|
279
|
+
try:
|
|
280
|
+
retry_at = parsedate_to_datetime(retry_after)
|
|
281
|
+
except ValueError:
|
|
282
|
+
return None
|
|
283
|
+
retry_after_seconds = (retry_at - datetime.now(UTC)).total_seconds()
|
|
284
|
+
return min(max(retry_after_seconds, 0.0), MAX_RETRY_AFTER_SECONDS)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def resolve_utc_timestamp(value: datetime) -> str:
|
|
288
|
+
"""ISO 8601 in UTC with a `Z`, the only offset the API accepts."""
|
|
289
|
+
return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
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
|
+
attempt = 1
|
|
298
|
+
while True:
|
|
299
|
+
is_last_attempt = attempt == MAX_ATTEMPTS
|
|
300
|
+
retry_after_seconds = None
|
|
301
|
+
try:
|
|
302
|
+
response = send()
|
|
303
|
+
except httpx.TransportError:
|
|
304
|
+
if is_last_attempt:
|
|
305
|
+
raise
|
|
306
|
+
else:
|
|
307
|
+
if is_last_attempt or not is_retryable_status(response.status_code):
|
|
308
|
+
return response
|
|
309
|
+
response.close()
|
|
310
|
+
retry_after_seconds = resolve_retry_after_seconds(response)
|
|
311
|
+
if retry_after_seconds is None:
|
|
312
|
+
retry_after_seconds = INITIAL_RETRY_DELAY_SECONDS * 2 ** (attempt - 1)
|
|
313
|
+
sleep(retry_after_seconds)
|
|
314
|
+
attempt += 1
|
hotcodepush/_types.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The sentinel of an optional argument the request leaves out."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NotGiven:
|
|
5
|
+
"""The type of `NOT_GIVEN`, the default of every optional argument.
|
|
6
|
+
|
|
7
|
+
An argument left at `NOT_GIVEN` stays out of the request; `None` sends JSON `null`
|
|
8
|
+
where the API takes one, such as clearing a channel's `expires_at`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __repr__(self) -> str:
|
|
12
|
+
return "NOT_GIVEN"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
NOT_GIVEN = NotGiven()
|
hotcodepush/_upload.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The single-or-parts rule of an upload: bytes above the single-upload limit go up in parts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from typing import TypeGuard, TypeVar
|
|
8
|
+
|
|
9
|
+
from ._http import UploadBody
|
|
10
|
+
from .models import MultipartUpload, UploadedPart
|
|
11
|
+
|
|
12
|
+
PART_SIZE_BYTES = 10 * 1024 * 1024
|
|
13
|
+
"""Every part but the last is this size: at least five mebibytes and all of one size, the bucket's
|
|
14
|
+
rule."""
|
|
15
|
+
SINGLE_UPLOAD_LIMIT_BYTES = 64 * 1024 * 1024
|
|
16
|
+
"""Bytes go up in one request up to this size and in parts above it, well under the request-body
|
|
17
|
+
cap of the API's plan."""
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_above_single_upload_limit(body: UploadBody) -> TypeGuard[bytes]:
|
|
23
|
+
"""A file object is read once and cannot be split, so only bytes go up in parts."""
|
|
24
|
+
return isinstance(body, bytes) and len(body) > SINGLE_UPLOAD_LIMIT_BYTES
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def upload_in_parts(
|
|
28
|
+
body: bytes,
|
|
29
|
+
*,
|
|
30
|
+
complete: Callable[[str, list[UploadedPart]], T],
|
|
31
|
+
create: Callable[[], MultipartUpload],
|
|
32
|
+
delete: Callable[[str], None],
|
|
33
|
+
upload_part: Callable[[str, int, bytes], UploadedPart],
|
|
34
|
+
) -> 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
|
+
upload_id = create().upload_id
|
|
42
|
+
try:
|
|
43
|
+
uploaded_parts = [
|
|
44
|
+
upload_part(upload_id, part_number, body[start : start + PART_SIZE_BYTES])
|
|
45
|
+
for part_number, start in enumerate(range(0, len(body), PART_SIZE_BYTES), start=1)
|
|
46
|
+
]
|
|
47
|
+
return complete(upload_id, uploaded_parts)
|
|
48
|
+
except Exception:
|
|
49
|
+
with suppress(Exception):
|
|
50
|
+
delete(upload_id)
|
|
51
|
+
raise
|
hotcodepush/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0" # x-release-please-version
|
hotcodepush/client.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The client for the HotCodePush REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._http import DEFAULT_BASE_URL, DEFAULT_CLIENT, HttpClient
|
|
11
|
+
from .resources.apps import AppsResource
|
|
12
|
+
from .resources.health import HealthResource
|
|
13
|
+
from .resources.invitations import InvitationsResource
|
|
14
|
+
from .resources.notification_preferences import NotificationPreferencesResource
|
|
15
|
+
from .resources.notifications import NotificationsResource
|
|
16
|
+
from .resources.organizations import OrganizationsResource
|
|
17
|
+
from .resources.users import UsersResource
|
|
18
|
+
|
|
19
|
+
TOKEN_ENVIRONMENT_VARIABLE = "HOTCODEPUSH_TOKEN"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HotCodePush:
|
|
23
|
+
"""The client for the HotCodePush REST API.
|
|
24
|
+
|
|
25
|
+
Resources mirror the paths: `/v1/organizations/{organizationId}/apps` is
|
|
26
|
+
`organizations.apps`, `/v1/apps/{appId}/channels` is `apps.channels`.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
token: The API token, sent as `Authorization: Bearer <token>`; falls back to the
|
|
30
|
+
`HOTCODEPUSH_TOKEN` environment variable and stays optional, since `/health` needs
|
|
31
|
+
none.
|
|
32
|
+
base_url: The base URL of the API.
|
|
33
|
+
client: The `X-HotCodePush-Client` header, `<name>/<version>`.
|
|
34
|
+
http_client: The `httpx.Client` that sends the requests in place of the client's own;
|
|
35
|
+
`close` leaves it open.
|
|
36
|
+
timeout: The seconds every phase of a JSON call's attempt may take, httpx's connect,
|
|
37
|
+
read, write and pool timeouts; sixty when unset. An upload attempt gets ten minutes.
|
|
38
|
+
user_agent: The `User-Agent` header, sent only when set, since a session keeps it and
|
|
39
|
+
the sessions page names the client by it.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
token: str | None = None,
|
|
45
|
+
*,
|
|
46
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
47
|
+
client: str = DEFAULT_CLIENT,
|
|
48
|
+
http_client: httpx.Client | None = None,
|
|
49
|
+
timeout: float | None = None,
|
|
50
|
+
user_agent: str | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._http_client = HttpClient(
|
|
53
|
+
base_url=base_url,
|
|
54
|
+
client=client,
|
|
55
|
+
http_client=http_client,
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
token=token or os.environ.get(TOKEN_ENVIRONMENT_VARIABLE),
|
|
58
|
+
user_agent=user_agent,
|
|
59
|
+
)
|
|
60
|
+
self.apps = AppsResource(self._http_client)
|
|
61
|
+
self.health = HealthResource(self._http_client)
|
|
62
|
+
self.invitations = InvitationsResource(self._http_client)
|
|
63
|
+
self.notification_preferences = NotificationPreferencesResource(self._http_client)
|
|
64
|
+
self.notifications = NotificationsResource(self._http_client)
|
|
65
|
+
self.organizations = OrganizationsResource(self._http_client)
|
|
66
|
+
self.users = UsersResource(self._http_client)
|
|
67
|
+
|
|
68
|
+
def close(self) -> None:
|
|
69
|
+
"""Closes the connection pool, unless the requests go through the caller's `http_client`."""
|
|
70
|
+
self._http_client.close()
|
|
71
|
+
|
|
72
|
+
def __enter__(self) -> HotCodePush:
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def __exit__(
|
|
76
|
+
self,
|
|
77
|
+
exception_type: type[BaseException] | None,
|
|
78
|
+
exception: BaseException | None,
|
|
79
|
+
traceback: TracebackType | None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.close()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The error raised for an API answer outside 2xx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HotCodePushError(Exception):
|
|
9
|
+
"""Raised when the API answers with a status outside 2xx.
|
|
10
|
+
|
|
11
|
+
`code`, `message` and `details` are the API's, verbatim, from its one error shape;
|
|
12
|
+
a response outside that shape, such as a page of the platform in front of the API,
|
|
13
|
+
is `E_UNEXPECTED_RESPONSE` with the status, its body left out.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *, code: str, details: object, message: str, status: int) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.code = code
|
|
19
|
+
self.details = details
|
|
20
|
+
self.message = message
|
|
21
|
+
self.status = status
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_hot_code_push_error(status: int, text: str) -> HotCodePushError:
|
|
25
|
+
body = resolve_json_object(text)
|
|
26
|
+
code, message = body.get("code"), body.get("message")
|
|
27
|
+
if isinstance(code, str) and isinstance(message, str):
|
|
28
|
+
return HotCodePushError(
|
|
29
|
+
code=code, details=body.get("details"), message=message, status=status
|
|
30
|
+
)
|
|
31
|
+
return HotCodePushError(
|
|
32
|
+
code="E_UNEXPECTED_RESPONSE",
|
|
33
|
+
details=None,
|
|
34
|
+
message=f"Request failed with status {status}.",
|
|
35
|
+
status=status,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
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
|
+
try:
|
|
42
|
+
body: object = json.loads(text)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return {}
|
|
45
|
+
return body if isinstance(body, dict) else {}
|