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.
Files changed (62) hide show
  1. hotcodepush/__init__.py +183 -0
  2. hotcodepush/_http.py +314 -0
  3. hotcodepush/_types.py +15 -0
  4. hotcodepush/_upload.py +51 -0
  5. hotcodepush/_version.py +1 -0
  6. hotcodepush/client.py +81 -0
  7. hotcodepush/exceptions.py +45 -0
  8. hotcodepush/models.py +899 -0
  9. hotcodepush/py.typed +0 -0
  10. hotcodepush/resources/__init__.py +0 -0
  11. hotcodepush/resources/_base.py +10 -0
  12. hotcodepush/resources/apps.py +78 -0
  13. hotcodepush/resources/audit_logs.py +112 -0
  14. hotcodepush/resources/binaries.py +84 -0
  15. hotcodepush/resources/bundle_deltas.py +41 -0
  16. hotcodepush/resources/bundle_files.py +44 -0
  17. hotcodepush/resources/bundle_pack.py +54 -0
  18. hotcodepush/resources/bundle_pack_upload_parts.py +36 -0
  19. hotcodepush/resources/bundle_pack_uploads.py +65 -0
  20. hotcodepush/resources/bundles.py +147 -0
  21. hotcodepush/resources/channel_audience.py +43 -0
  22. hotcodepush/resources/channel_qr.py +27 -0
  23. hotcodepush/resources/channel_releases.py +119 -0
  24. hotcodepush/resources/channels.py +169 -0
  25. hotcodepush/resources/checkouts.py +26 -0
  26. hotcodepush/resources/customer_portal_sessions.py +25 -0
  27. hotcodepush/resources/device_events.py +44 -0
  28. hotcodepush/resources/devices.py +116 -0
  29. hotcodepush/resources/file_upload_parts.py +36 -0
  30. hotcodepush/resources/file_uploads.py +63 -0
  31. hotcodepush/resources/files.py +53 -0
  32. hotcodepush/resources/health.py +11 -0
  33. hotcodepush/resources/invitations.py +50 -0
  34. hotcodepush/resources/limits.py +20 -0
  35. hotcodepush/resources/members.py +121 -0
  36. hotcodepush/resources/notification_preferences.py +51 -0
  37. hotcodepush/resources/notifications.py +62 -0
  38. hotcodepush/resources/organization_apps.py +51 -0
  39. hotcodepush/resources/organization_invitations.py +112 -0
  40. hotcodepush/resources/organizations.py +110 -0
  41. hotcodepush/resources/release_audience.py +21 -0
  42. hotcodepush/resources/releases.py +162 -0
  43. hotcodepush/resources/rollbacks.py +31 -0
  44. hotcodepush/resources/signing_keys.py +53 -0
  45. hotcodepush/resources/sso_provider.py +61 -0
  46. hotcodepush/resources/sso_provider_verifications.py +27 -0
  47. hotcodepush/resources/statistics.py +15 -0
  48. hotcodepush/resources/statistics_fleet.py +17 -0
  49. hotcodepush/resources/statistics_updates.py +35 -0
  50. hotcodepush/resources/statistics_usage.py +25 -0
  51. hotcodepush/resources/subscription.py +53 -0
  52. hotcodepush/resources/usage.py +66 -0
  53. hotcodepush/resources/user_password.py +21 -0
  54. hotcodepush/resources/user_sessions.py +18 -0
  55. hotcodepush/resources/user_tokens.py +18 -0
  56. hotcodepush/resources/users.py +46 -0
  57. hotcodepush/resources/webhook_deliveries.py +91 -0
  58. hotcodepush/resources/webhooks.py +158 -0
  59. hotcodepush-0.1.0.dist-info/METADATA +98 -0
  60. hotcodepush-0.1.0.dist-info/RECORD +62 -0
  61. hotcodepush-0.1.0.dist-info/WHEEL +4 -0
  62. hotcodepush-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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
@@ -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 {}