pubship 0.24.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 (80) hide show
  1. pubship/__init__.py +3 -0
  2. pubship/account_access.py +322 -0
  3. pubship/account_access_config.py +99 -0
  4. pubship/account_access_contracts.py +151 -0
  5. pubship/account_access_http.py +213 -0
  6. pubship/account_access_tools.py +74 -0
  7. pubship/appstore.py +307 -0
  8. pubship/appstore_config.py +68 -0
  9. pubship/appstore_contracts.py +226 -0
  10. pubship/appstore_http.py +145 -0
  11. pubship/appstore_tools.py +77 -0
  12. pubship/artifact.py +502 -0
  13. pubship/artifact_contracts.py +438 -0
  14. pubship/artifact_files.py +298 -0
  15. pubship/artifact_http.py +176 -0
  16. pubship/auth.py +113 -0
  17. pubship/catalog.py +94 -0
  18. pubship/config.py +386 -0
  19. pubship/contracts.json +10576 -0
  20. pubship/contracts.py +129 -0
  21. pubship/coverage.py +254 -0
  22. pubship/edit_lifecycle.py +216 -0
  23. pubship/edit_lifecycle_http.py +152 -0
  24. pubship/edit_writes.py +282 -0
  25. pubship/edit_writes_contracts.py +291 -0
  26. pubship/edit_writes_http.py +58 -0
  27. pubship/errors.py +5 -0
  28. pubship/hosted.py +983 -0
  29. pubship/hosted_administration.py +213 -0
  30. pubship/hosted_cancellation.py +89 -0
  31. pubship/hosted_catalog.py +288 -0
  32. pubship/hosted_config.py +172 -0
  33. pubship/hosted_distribution.py +305 -0
  34. pubship/hosted_edits.py +674 -0
  35. pubship/hosted_grants.py +627 -0
  36. pubship/hosted_pages.py +124 -0
  37. pubship/hosted_token_transport.py +71 -0
  38. pubship/hosted_tools.py +631 -0
  39. pubship/hosted_transfer_client.py +289 -0
  40. pubship/hosted_transfer_http.py +231 -0
  41. pubship/hosted_transfers.py +650 -0
  42. pubship/http.py +115 -0
  43. pubship/legacy_product_contracts.py +210 -0
  44. pubship/listing_preview.py +105 -0
  45. pubship/methods.json +6816 -0
  46. pubship/monetization.py +262 -0
  47. pubship/monetization_contracts.py +363 -0
  48. pubship/monetization_http.py +134 -0
  49. pubship/oauth.py +596 -0
  50. pubship/onetime_contracts.py +300 -0
  51. pubship/operator_identity.py +81 -0
  52. pubship/play.py +120 -0
  53. pubship/provider_transport.py +146 -0
  54. pubship/publisher_reads.py +74 -0
  55. pubship/publisher_reads_contracts.py +147 -0
  56. pubship/publishing.py +251 -0
  57. pubship/publishing_http.py +78 -0
  58. pubship/purchase.py +248 -0
  59. pubship/purchase_contracts.py +609 -0
  60. pubship/purchase_http.py +726 -0
  61. pubship/reporting.py +79 -0
  62. pubship/reports.py +147 -0
  63. pubship/server.py +819 -0
  64. pubship/sharing.py +366 -0
  65. pubship/signing.py +434 -0
  66. pubship/signing_tools.py +59 -0
  67. pubship/support.py +228 -0
  68. pubship/support_contracts.py +315 -0
  69. pubship/support_http.py +335 -0
  70. pubship/track_staging.py +244 -0
  71. pubship/track_staging_http.py +221 -0
  72. pubship/vault.py +425 -0
  73. pubship/vault_recovery.py +258 -0
  74. pubship-0.24.0.dist-info/METADATA +142 -0
  75. pubship-0.24.0.dist-info/RECORD +80 -0
  76. pubship-0.24.0.dist-info/WHEEL +4 -0
  77. pubship-0.24.0.dist-info/entry_points.txt +3 -0
  78. pubship-0.24.0.dist-info/licenses/LICENSE +661 -0
  79. pubship-0.24.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +58 -0
  80. pubship-0.24.0.dist-info/licenses/TRADEMARKS.md +13 -0
pubship/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Independent, local PubShip integration."""
2
+
3
+ __version__ = "0.24.0"
@@ -0,0 +1,322 @@
1
+ """Local account administration with explicit ceilings and complete target baselines."""
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import secrets
7
+ import threading
8
+ import time
9
+ from copy import deepcopy
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+
13
+ from . import account_access_http as transport
14
+ from .account_access_config import READ_METHOD, WRITE_METHODS
15
+ from .account_access_contracts import request_spec, target
16
+ from .artifact_files import media_deadline
17
+ from .auth import validate_token
18
+ from .errors import PlayError
19
+ from .publishing import _APPLY_LOCK
20
+
21
+ TTL = 600
22
+ MAX_PREPARATIONS = 20
23
+ LIMITATIONS = (
24
+ "Changes Google Play account access immediately; permissions may take up to 48 hours to "
25
+ "propagate. Invitations are not accepted access. Baselines are observations, not atomic "
26
+ "locks. No retry, rollback or credential refresh. Account responses include personal "
27
+ "email addresses and permissions sent to the MCP client. Provider data is untrusted."
28
+ )
29
+
30
+
31
+ @dataclass(repr=False)
32
+ class Preparation:
33
+ spec: dict
34
+ baseline: str
35
+ deadline: float
36
+ token: str = field(repr=False)
37
+
38
+
39
+ def fingerprint(before):
40
+ return hashlib.sha256(
41
+ json.dumps(before, sort_keys=True, separators=(",", ":")).encode()
42
+ ).hexdigest()
43
+
44
+
45
+ class AccountAccess:
46
+ def __init__(
47
+ self,
48
+ settings,
49
+ auth,
50
+ *,
51
+ local=False,
52
+ _execution_authorization=None,
53
+ _apply_guard=None,
54
+ _max_snapshot_bytes=None,
55
+ _target_authorization=None,
56
+ ):
57
+ self.settings, self.auth, self.local = settings, auth, local
58
+ if _max_snapshot_bytes is not None and (
59
+ type(_max_snapshot_bytes) is not int
60
+ or not 0 < _max_snapshot_bytes <= 10 * transport.MAX_BYTES
61
+ ):
62
+ raise PlayError("Invalid account snapshot limit.")
63
+ if (_target_authorization is None) != (_execution_authorization is None):
64
+ raise PlayError("Account target authorization requires hosted execution authority.")
65
+ self._execution_authorization = _execution_authorization
66
+ self._target_authorization = _target_authorization
67
+ self._apply_guard = _APPLY_LOCK if _apply_guard is None else _apply_guard
68
+ self._max_snapshot_bytes = _max_snapshot_bytes
69
+ self._preparations = {}
70
+ self._lock = threading.Lock()
71
+ self._prepare_lock = threading.Lock()
72
+
73
+ def _local(self):
74
+ if self._execution_authorization is not None:
75
+ self._execution_authorization()
76
+ return
77
+ if self.local is not True:
78
+ raise PlayError("Account administration requires explicitly local services.")
79
+
80
+ def _authorize(self, spec, *, mutation=False):
81
+ self._local()
82
+ if self._target_authorization is not None:
83
+ self._target_authorization(deepcopy(spec), None)
84
+ self._local()
85
+ return
86
+ self.settings.require(spec["developer"], spec["method"], spec["user"], spec["app"])
87
+ if mutation:
88
+ self.settings.require(spec["developer"], READ_METHOD)
89
+ body = spec["body"] or {}
90
+ self.settings.permissions(
91
+ body.get("developerAccountPermissions", []), body.get("appLevelPermissions", [])
92
+ )
93
+
94
+ def _preflight(self, spec, before):
95
+ if before is not None and before.get("partial", False):
96
+ raise PlayError(
97
+ "Partial or owner account users cannot be safely managed via this tool."
98
+ )
99
+ existing = before
100
+ if spec["grant"]:
101
+ if before is None:
102
+ raise PlayError("Account grant target user does not exist.")
103
+ existing = next(
104
+ (g for g in before.get("grants", []) if g["name"] == spec["resource"]), None
105
+ )
106
+ if spec["operation"] == "create" and existing is not None:
107
+ raise PlayError("Account target already exists; create is not an update.")
108
+ if spec["operation"] != "create" and existing is None:
109
+ raise PlayError("Account target is absent; no mutation attempted.")
110
+ if self._target_authorization is not None:
111
+ self._target_authorization(deepcopy(spec), deepcopy(before))
112
+ self._local()
113
+ return
114
+ if existing is not None:
115
+ self.settings.permissions(
116
+ existing.get("developerAccountPermissions", []),
117
+ existing.get("appLevelPermissions", []),
118
+ )
119
+ if not spec["grant"] and spec["operation"] == "delete":
120
+ # Removing a user also removes every app grant; all must be within scope.
121
+ for grant in before.get("grants", []):
122
+ developer, user, app = target(grant["name"], grant=True)
123
+ self.settings.require(developer, spec["method"], user, app)
124
+ self.settings.permissions(app_permissions=grant.get("appLevelPermissions", []))
125
+
126
+ def _current(self, deadline):
127
+ self._local()
128
+ transport.fresh(deadline)
129
+
130
+ def _target_current(self, spec, deadline, before=None):
131
+ self._current(deadline)
132
+ if self._target_authorization is not None:
133
+ self._target_authorization(deepcopy(spec), deepcopy(before))
134
+ self._current(deadline)
135
+
136
+ def _options(self, deadline, spec, *, observation=False, before=None):
137
+ if self._execution_authorization is None and self._max_snapshot_bytes is None:
138
+ return {}
139
+ options = {"_execution_authorization": lambda: self._target_current(spec, deadline, before)}
140
+ if observation:
141
+ options["_max_snapshot_bytes"] = self._max_snapshot_bytes
142
+ else:
143
+ options["deadline"] = deadline
144
+ options["_max_bytes"] = min(
145
+ self._max_snapshot_bytes or transport.MAX_BYTES, transport.MAX_BYTES
146
+ )
147
+ return options
148
+
149
+ def _prune(self):
150
+ for key, preparation in list(self._preparations.items()):
151
+ if time.monotonic() >= preparation.deadline:
152
+ self._clear(self._preparations.pop(key))
153
+
154
+ @staticmethod
155
+ def _clear(preparation):
156
+ preparation.token = ""
157
+ preparation.spec = {}
158
+ preparation.baseline = ""
159
+
160
+ def _dispose(self, operation_id):
161
+ with self._prepare_lock, self._lock:
162
+ self._prune()
163
+ preparation = self._preparations.pop(operation_id, None)
164
+ if preparation is not None:
165
+ self._clear(preparation)
166
+ return "disposed"
167
+
168
+ def close(self):
169
+ with self._prepare_lock, self._lock:
170
+ for preparation in self._preparations.values():
171
+ self._clear(preparation)
172
+ self._preparations.clear()
173
+
174
+ def _retained_metadata(self, operation_id):
175
+ with self._lock:
176
+ p = self._preparations[operation_id]
177
+ return {
178
+ "spec": deepcopy(p.spec),
179
+ "baseline": p.baseline,
180
+ "deadline": p.deadline,
181
+ "credential_bytes": len(p.token.encode()),
182
+ }
183
+
184
+ def read(self, method, parameters):
185
+ if method != READ_METHOD:
186
+ raise PlayError("Only users.list is an account read method.")
187
+ spec = request_spec(method, parameters)
188
+ self._authorize(spec)
189
+ deadline = media_deadline(time.monotonic() + TTL, self._execution_authorization)
190
+ token = validate_token(self.auth.token("publisher"))
191
+ self._target_current(spec, deadline)
192
+ response = transport.request(spec, token, **self._options(deadline, spec))
193
+ transport.validate_response(spec, response)
194
+ self._current(deadline)
195
+ return {
196
+ "method": method,
197
+ "response": response,
198
+ "data_scope": "account_personal_data",
199
+ "fetched_at": datetime.now(UTC).isoformat(),
200
+ "note": "One provider page; preserve nextPageToken. " + LIMITATIONS,
201
+ }
202
+
203
+ def prepare(self, method, parameters, body=None, acknowledge_access_effects=False):
204
+ if not isinstance(method, str) or method not in WRITE_METHODS:
205
+ raise PlayError("Method is not a supported account mutation.")
206
+ spec = request_spec(method, parameters, body)
207
+ self._authorize(spec, mutation=True)
208
+ if acknowledge_access_effects is not True:
209
+ raise PlayError("acknowledge_access_effects must be true. " + LIMITATIONS)
210
+ with self._prepare_lock:
211
+ now = time.monotonic()
212
+ with self._lock:
213
+ self._prune()
214
+ if len(self._preparations) >= MAX_PREPARATIONS:
215
+ raise PlayError(
216
+ "Account preparation capacity reached; apply or wait for expiry."
217
+ )
218
+ deadline = media_deadline(now + TTL, self._execution_authorization)
219
+ token = validate_token(self.auth.token("publisher"))
220
+ self._target_current(spec, deadline)
221
+ before = transport.observe(
222
+ spec, token, deadline, **self._options(deadline, spec, observation=True)
223
+ )
224
+ self._preflight(spec, before)
225
+ self._current(deadline)
226
+ operation_id = "account_" + secrets.token_urlsafe(32)
227
+ with self._lock:
228
+ while operation_id in self._preparations:
229
+ operation_id = "account_" + secrets.token_urlsafe(32)
230
+ self._preparations[operation_id] = Preparation(
231
+ spec, fingerprint(before), deadline, token
232
+ )
233
+ return {
234
+ "operation_id": operation_id,
235
+ "method": method,
236
+ "before": deepcopy(before),
237
+ "proposed_request": deepcopy(spec),
238
+ "executed": False,
239
+ "effects": LIMITATIONS,
240
+ "access_lifetime": (
241
+ (spec["body"] or {}).get("expirationTime", "indefinite")
242
+ if not spec["grant"]
243
+ and (
244
+ spec["operation"] == "create"
245
+ or "expirationTime" in spec["parameters"].get("updateMask", "").split(",")
246
+ )
247
+ else "unchanged"
248
+ ),
249
+ "expires_at": datetime.fromtimestamp(
250
+ time.time() + deadline - time.monotonic(), UTC
251
+ ).isoformat(),
252
+ "note": "Repeat operation_id exactly after reviewing all effects. Confirmation is not proof of human approval. Single use within ten minutes; original credential retained privately; lost on restart.",
253
+ }
254
+
255
+ def apply(self, operation_id, confirmation):
256
+ self._local()
257
+ if (
258
+ not isinstance(operation_id, str)
259
+ or not re.fullmatch(r"account_[A-Za-z0-9_-]{43}", operation_id)
260
+ or confirmation != operation_id
261
+ ):
262
+ raise PlayError("Confirmation must exactly repeat the account operation_id.")
263
+ with self._lock:
264
+ p = self._preparations.pop(operation_id, None)
265
+ self._prune()
266
+ if p is None:
267
+ raise PlayError("Unknown, consumed or lost account preparation.")
268
+ acquired = False
269
+ try:
270
+ acquired = self._apply_guard.acquire(blocking=False)
271
+ if not acquired:
272
+ raise PlayError(
273
+ "Another apply is in progress; this account operation ID is consumed."
274
+ )
275
+ spec = request_spec(p.spec["method"], p.spec["parameters"], p.spec["body"])
276
+ self._authorize(spec, mutation=True)
277
+ self._current(p.deadline)
278
+ before = transport.observe(
279
+ spec, p.token, p.deadline, **self._options(p.deadline, spec, observation=True)
280
+ )
281
+ self._preflight(spec, before)
282
+ if fingerprint(before) != p.baseline:
283
+ raise PlayError("Account baseline changed; no mutation attempted. Prepare again.")
284
+ self._current(p.deadline)
285
+ mutation = transport.request(
286
+ spec, p.token, **self._options(p.deadline, spec, before=before)
287
+ )
288
+ try:
289
+ transport.validate_response(spec, mutation)
290
+ except PlayError:
291
+ raise PlayError(transport.UNKNOWN_OUTCOME) from None
292
+ result = {
293
+ "operation_id": operation_id,
294
+ "method": spec["method"],
295
+ "executed": True,
296
+ "mutation_succeeded": True,
297
+ "mutation_response": mutation,
298
+ "permission_propagation_verified": False,
299
+ "note": LIMITATIONS,
300
+ }
301
+ try:
302
+ self._current(p.deadline)
303
+ after = transport.observe(
304
+ spec, p.token, p.deadline, **self._options(p.deadline, spec, observation=True)
305
+ )
306
+ self._current(p.deadline)
307
+ except Exception:
308
+ result.update(
309
+ readback_succeeded=False,
310
+ observation_error="Mutation succeeded but account readback failed; reconcile before preparing again.",
311
+ )
312
+ else:
313
+ result.update(
314
+ readback_succeeded=True,
315
+ after=after,
316
+ observation_note="Observed resource only; effective access and permission propagation are not verified.",
317
+ )
318
+ return result
319
+ finally:
320
+ self._clear(p)
321
+ if acquired:
322
+ self._apply_guard.release()
@@ -0,0 +1,99 @@
1
+ """Independent local account administration scopes and permission ceilings."""
2
+
3
+ import os
4
+ import re
5
+ from dataclasses import dataclass
6
+
7
+ from .config import PACKAGE
8
+ from .contracts import definitions
9
+ from .errors import PlayError
10
+
11
+ PREFIX = "androidpublisher."
12
+ READ_METHOD = PREFIX + "users.list"
13
+ WRITE_METHODS = frozenset(
14
+ PREFIX + family + "." + action
15
+ for family in ("users", "grants")
16
+ for action in ("create", "patch", "delete")
17
+ )
18
+ METHODS = WRITE_METHODS | {READ_METHOD}
19
+ DEVELOPER = re.compile(r"[0-9]{1,30}")
20
+ EMAIL = re.compile(r"[A-Za-z0-9.!#$&'*+=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?")
21
+ APP_ID = re.compile(r"[0-9]{1,30}")
22
+
23
+
24
+ def valid_email(value):
25
+ return isinstance(value, str) and len(value) <= 254 and EMAIL.fullmatch(value) is not None
26
+
27
+
28
+ def valid_app(value):
29
+ return (
30
+ isinstance(value, str)
31
+ and len(value) <= 255
32
+ and (PACKAGE.fullmatch(value) is not None or APP_ID.fullmatch(value) is not None)
33
+ )
34
+
35
+
36
+ def permission_values(resource, field):
37
+ values = definitions()["androidpublisher"]["schemas"][resource]["properties"][field]["items"]
38
+ return frozenset(
39
+ value
40
+ for value, deprecated in zip(values["enum"], values["enumDeprecated"], strict=True)
41
+ if not deprecated and not value.endswith("_UNSPECIFIED")
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class AccountAccessSettings:
47
+ developers: frozenset[str] = frozenset()
48
+ methods: frozenset[str] = frozenset()
49
+ users: frozenset[str] = frozenset()
50
+ apps: frozenset[str] = frozenset()
51
+ developer_permissions: frozenset[str] = frozenset()
52
+ app_permissions: frozenset[str] = frozenset()
53
+
54
+ def __post_init__(self):
55
+ checks = (
56
+ (self.developers, lambda v: isinstance(v, str) and DEVELOPER.fullmatch(v)),
57
+ (self.methods, lambda v: v in METHODS),
58
+ (self.users, valid_email),
59
+ (self.apps, valid_app),
60
+ (
61
+ self.developer_permissions,
62
+ lambda v: v in permission_values("User", "developerAccountPermissions"),
63
+ ),
64
+ (
65
+ self.app_permissions,
66
+ lambda v: v in permission_values("Grant", "appLevelPermissions"),
67
+ ),
68
+ )
69
+ for values, predicate in checks:
70
+ if not isinstance(values, frozenset) or any(not predicate(v) for v in values):
71
+ raise PlayError("Invalid exact account access scope or permission ceiling.")
72
+
73
+ @classmethod
74
+ def from_env(cls, base_settings=None):
75
+ # Deliberately independent of package, publishing, ADC and hosted settings.
76
+ def values(suffix):
77
+ raw = os.environ.get("GOOGLE_PLAY_ACCOUNT_ACCESS_" + suffix, "")
78
+ if not raw:
79
+ return frozenset()
80
+ parts = raw.split(",")
81
+ if any(not part.strip() for part in parts):
82
+ raise PlayError("Account access scopes require nonempty comma-separated values.")
83
+ return frozenset(part.strip() for part in parts)
84
+
85
+ return cls(**{name: values(name.upper()) for name in cls.__dataclass_fields__})
86
+
87
+ def require(self, developer, method, user=None, app=None):
88
+ if developer not in self.developers or method not in self.methods:
89
+ raise PlayError("Account developer and exact method require explicit local grants.")
90
+ if user is not None and user not in self.users:
91
+ raise PlayError("Account target user requires an explicit local grant.")
92
+ if app is not None and app not in self.apps:
93
+ raise PlayError("Account target app requires an explicit local grant.")
94
+
95
+ def permissions(self, developer_permissions=(), app_permissions=()):
96
+ if not set(developer_permissions).issubset(self.developer_permissions) or not set(
97
+ app_permissions
98
+ ).issubset(self.app_permissions):
99
+ raise PlayError("Account operation exceeds the configured permission ceiling.")
@@ -0,0 +1,151 @@
1
+ """Strict users/grants contracts supplementing the pinned provider schema."""
2
+
3
+ import re
4
+ from copy import deepcopy
5
+ from datetime import UTC, datetime
6
+ from urllib.parse import quote, urlencode
7
+
8
+ from .account_access_config import (
9
+ DEVELOPER,
10
+ METHODS,
11
+ READ_METHOD,
12
+ permission_values,
13
+ valid_app,
14
+ valid_email,
15
+ )
16
+ from .contracts import methods, validate_request
17
+ from .errors import PlayError
18
+
19
+
20
+ def _fail():
21
+ raise PlayError("Invalid account access target, fields or permissions.")
22
+
23
+
24
+ def target(name, grant=False, parent=False):
25
+ if not isinstance(name, str):
26
+ _fail()
27
+ parts = name.split("/")
28
+ length = 2 if parent else 6 if grant else 4
29
+ if len(parts) != length or parts[0] != "developers" or not DEVELOPER.fullmatch(parts[1]):
30
+ _fail()
31
+ if length > 2 and (parts[2] != "users" or not valid_email(parts[3])):
32
+ _fail()
33
+ if grant and (parts[4] != "grants" or not valid_app(parts[5])):
34
+ _fail()
35
+ return parts[1], parts[3] if length > 2 else None, parts[5] if grant else None
36
+
37
+
38
+ def _permissions(body, field, resource):
39
+ if field not in body:
40
+ return
41
+ values = body[field]
42
+ if (
43
+ not isinstance(values, list)
44
+ or len(values) != len(set(values))
45
+ or not set(values).issubset(permission_values(resource, field))
46
+ ):
47
+ _fail()
48
+
49
+
50
+ def request_spec(method, parameters, body=None):
51
+ if not isinstance(method, str) or method not in METHODS:
52
+ _fail()
53
+ validate_request(method, parameters, body)
54
+ parameters, body = deepcopy(parameters), deepcopy(body)
55
+ operation = method.rsplit(".", 1)[1]
56
+ grant = ".grants." in method
57
+ if method == READ_METHOD:
58
+ developer, user, app = target(parameters["parent"], parent=True)
59
+ if "pageSize" in parameters and (
60
+ type(parameters["pageSize"]) is not int or not 1 <= parameters["pageSize"] <= 1000
61
+ ):
62
+ raise PlayError("Account reads support bounded pageSize values from 1 to 1000.")
63
+ if "pageToken" in parameters and (
64
+ not isinstance(parameters["pageToken"], str)
65
+ or not 1 <= len(parameters["pageToken"]) <= 8192
66
+ or not parameters["pageToken"].isprintable()
67
+ ):
68
+ _fail()
69
+ resource = None
70
+ elif operation == "create":
71
+ developer, user, _ = target(parameters["parent"], parent=not grant)
72
+ resource = body.get("name")
73
+ bd, bu, app = target(resource, grant=grant)
74
+ if bd != developer or grant and bu != user:
75
+ _fail()
76
+ user = bu
77
+ else:
78
+ resource = parameters["name"]
79
+ developer, user, app = target(resource, grant=grant)
80
+ if body is not None:
81
+ allowed = (
82
+ {"name", "packageName", "appLevelPermissions"}
83
+ if grant
84
+ else {"name", "email", "expirationTime", "developerAccountPermissions"}
85
+ )
86
+ if not set(body).issubset(allowed) or body.get("name", resource) != resource:
87
+ _fail()
88
+ if grant:
89
+ expected = "" if app.isdigit() else app
90
+ if body.get("packageName", expected) != expected:
91
+ _fail()
92
+ _permissions(body, "appLevelPermissions", "Grant")
93
+ else:
94
+ if body.get("email", user) != user:
95
+ _fail()
96
+ _permissions(body, "developerAccountPermissions", "User")
97
+ if "expirationTime" in body:
98
+ stamp = body["expirationTime"]
99
+ if not isinstance(stamp, str) or not re.fullmatch(
100
+ r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])",
101
+ stamp,
102
+ ):
103
+ _fail()
104
+ try:
105
+ parsed = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
106
+ except ValueError:
107
+ _fail()
108
+ if parsed <= datetime.now(UTC):
109
+ raise PlayError("Account access expiration must be in the future.")
110
+ if operation == "create":
111
+ if not grant and body.get("email") != user:
112
+ _fail()
113
+ if operation == "patch":
114
+ mutable = (
115
+ {"appLevelPermissions"}
116
+ if grant
117
+ else {"expirationTime", "developerAccountPermissions"}
118
+ )
119
+ mask = parameters.get("updateMask")
120
+ if not isinstance(mask, str):
121
+ raise PlayError("Account patches require an explicit supported updateMask.")
122
+ fields = mask.split(",")
123
+ if not fields or len(set(fields)) != len(fields) or not set(fields).issubset(mutable):
124
+ _fail()
125
+ if not (set(body) - {"name", "email", "packageName"}).issubset(fields):
126
+ _fail()
127
+ # A mask with an omitted value deliberately resets that field.
128
+ definition = methods()[method]
129
+ path = definition["path"]
130
+ query = {}
131
+ for key, value in parameters.items():
132
+ if definition["parameters"][key]["location"] == "path":
133
+ encoded = "/".join(quote(p, safe="") for p in value.split("/"))
134
+ path = path.replace("{+" + key + "}", encoded)
135
+ else:
136
+ query[key] = value
137
+ return {
138
+ "method": method,
139
+ "http_method": definition["httpMethod"],
140
+ "url": "https://androidpublisher.googleapis.com/"
141
+ + path
142
+ + ("?" + urlencode(query) if query else ""),
143
+ "parameters": parameters,
144
+ "body": body,
145
+ "developer": developer,
146
+ "user": user,
147
+ "app": app,
148
+ "resource": resource,
149
+ "operation": operation,
150
+ "grant": grant,
151
+ }