mkit-cli 0.3.1__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.
mkit/cloud/play.py ADDED
@@ -0,0 +1,497 @@
1
+ """Google Play publishing: edits, bundle upload, tracks, promote, rollout.
2
+
3
+ Everything that changes Play happens inside an edit: open it, make changes,
4
+ validate, then commit or discard. A dry run does all of it on Play's servers
5
+ and only skips the commit, which catches a bad bundle or a permission gap
6
+ before anything goes live, and a discarded edit spends no versionCode.
7
+
8
+ Track names: the CLI uses Play Console's words (internal, closed, open, prod);
9
+ the API kept older ids (internal, alpha, beta, production). A raw id, such as a
10
+ custom closed track, is accepted as-is. A Wear OS module ships on its own
11
+ `wear:` track family, found by asking Play which tracks exist rather than by
12
+ building names, because Google's naming there is not reliably documented.
13
+ """
14
+ import threading
15
+ import os
16
+ import re
17
+ from pathlib import Path
18
+
19
+ from .. import output
20
+ from ..errors import Conflict, MkitError, NotFound, UsageError
21
+ from . import auth
22
+
23
+ TRACKS = {'internal': 'internal', 'closed': 'alpha', 'open': 'beta',
24
+ 'prod': 'production', 'production': 'production'}
25
+ LABELS = {'internal': 'internal testing', 'alpha': 'closed testing', 'beta': 'open testing',
26
+ 'production': 'production'}
27
+ WEAR_PREFIX = 'wear:'
28
+ WEAR_SUFFIXES = {'internal': ('internal', 'qa'), 'alpha': ('alpha',), 'beta': ('beta',),
29
+ 'production': ('production',)}
30
+
31
+ WHATSNEW_PREFIX = 'whatsnew-'
32
+ WHATSNEW_LIMIT = 500
33
+ UPLOAD_RETRIES = 6
34
+ _DRAFT_ONLY = 'Only releases with status draft may be created on draft app'
35
+ _EDIT_EXPIRED = 'This edit has expired'
36
+ # Play's refusal to send an edit for review by itself, which it gives while the
37
+ # app has a rejected update or an open policy issue.
38
+ _MANUAL_REVIEW = 'changesNotSentForReview'
39
+ MANUAL_REVIEW_NOTE = ('Play would not send these changes for review by itself (the app has a '
40
+ 'rejected update or an open policy issue), so they are saved but not '
41
+ 'submitted. Send them in Play Console > Publishing overview > Send '
42
+ 'changes for review.')
43
+
44
+ NO_WEAR_TRACK = ('No Wear OS track exists on Play, so the watch bundle was not uploaded. Add it '
45
+ 'once in Play Console > Test and release > Advanced settings > Form factors > '
46
+ 'Wear OS, and opt in to the Wear OS review policy on that page.')
47
+
48
+
49
+ def track_id(name):
50
+ return TRACKS.get(name, name)
51
+
52
+
53
+ def label(track):
54
+ base = track[len(WEAR_PREFIX):] if track.startswith(WEAR_PREFIX) else track
55
+ text = LABELS.get(base, base)
56
+ return f'Wear {text}' if track.startswith(WEAR_PREFIX) else text
57
+
58
+
59
+ class Edit:
60
+ """One Play edit, discarded on exit unless it was committed."""
61
+
62
+ def __init__(self, ctx, svc=None):
63
+ self.ctx = ctx
64
+ self.package = ctx.project.get('play.package') or ctx.project.package
65
+ if not self.package:
66
+ raise UsageError('app.package is not set in mkit.toml', code='BAD_CONFIG')
67
+ self.svc = svc or auth.play(ctx)
68
+ self.id = None
69
+ self.committed = False
70
+ # Set once Play refuses to send this edit for review by itself.
71
+ self.manual_review = False
72
+ # The Google client is not thread-safe: each worker thread gets its
73
+ # own (an injected test service is shared as is).
74
+ self._local = threading.local()
75
+ self._make = None if svc else (lambda: auth.play(ctx))
76
+
77
+ def __enter__(self):
78
+ from googleapiclient.errors import HttpError
79
+ try:
80
+ self.id = auth.retry(lambda: self.svc.edits().insert(packageName=self.package, body={}),
81
+ 'open edit')['id']
82
+ except HttpError as e:
83
+ err = auth.translate(e, 'Play')
84
+ if auth.status_of(e) == 404:
85
+ err = NotFound(f'Play has no app {self.package}',
86
+ hint='Create it in Play Console and upload once by hand; the API '
87
+ 'cannot create apps. See `mkit manual`.', code='NO_APP')
88
+ raise err from None
89
+ return self
90
+
91
+ def __exit__(self, exc_type, exc, tb):
92
+ if not self.committed and self.id:
93
+ try:
94
+ self.svc.edits().delete(packageName=self.package, editId=self.id).execute()
95
+ except Exception:
96
+ pass
97
+ return False
98
+
99
+ @property
100
+ def edits(self):
101
+ return self.svc.edits()
102
+
103
+ def _edits(self):
104
+ if self._make is None or threading.current_thread() is threading.main_thread():
105
+ return self.svc.edits()
106
+ if not hasattr(self._local, 'svc'):
107
+ self._local.svc = self._make()
108
+ return self._local.svc.edits()
109
+
110
+ def call(self, fn, what):
111
+ """fn(edits, package, edit_id) -> request, run with retry. Safe from
112
+ worker threads: each has its own client."""
113
+ return auth.retry(lambda: fn(self._edits(), self.package, self.id), what)
114
+
115
+ def tracks(self):
116
+ resp = self.call(lambda e, p, i: e.tracks().list(packageName=p, editId=i), 'list tracks')
117
+ return resp.get('tracks', [])
118
+
119
+ def track(self, track):
120
+ return self.call(lambda e, p, i: e.tracks().get(packageName=p, editId=i, track=track),
121
+ f'read {track}')
122
+
123
+ def set_track(self, track, releases):
124
+ return self.call(lambda e, p, i: e.tracks().update(
125
+ packageName=p, editId=i, track=track, body={'track': track, 'releases': releases}),
126
+ f'stage {track}')
127
+
128
+ def validate(self):
129
+ """Validate. When Play will not send the edit for review by itself, it
130
+ refuses to validate it too; the commit, marked to be sent from Play
131
+ Console, then validates it instead (the client has no such mark on
132
+ validate), and the rest of this edit remembers that."""
133
+ from googleapiclient.errors import HttpError
134
+ if self.manual_review:
135
+ return
136
+ try:
137
+ self.call(lambda e, p, i: e.validate(packageName=p, editId=i), 'validate')
138
+ except HttpError as error:
139
+ if _MANUAL_REVIEW not in str(error):
140
+ raise
141
+ self.manual_review = True
142
+
143
+ def commit(self, in_review='resubmit'):
144
+ """Commit. in_review='error' refuses instead of cancelling a review that
145
+ is already running (listing and product changes use that)."""
146
+ from googleapiclient.errors import HttpError
147
+ kwargs = {}
148
+ if in_review == 'error':
149
+ kwargs['changesInReviewBehavior'] = 'ERROR_IF_IN_REVIEW'
150
+
151
+ def run():
152
+ extra = {'changesNotSentForReview': True} if self.manual_review else {}
153
+ self.call(lambda e, p, i: e.commit(packageName=p, editId=i, **kwargs, **extra),
154
+ 'commit')
155
+ try:
156
+ run()
157
+ except HttpError as error:
158
+ if self.manual_review or _MANUAL_REVIEW not in str(error):
159
+ raise
160
+ self.manual_review = True
161
+ run()
162
+ self.committed = True
163
+ if self.manual_review:
164
+ output.out.warn(MANUAL_REVIEW_NOTE)
165
+
166
+ def finish(self, dry_run, in_review='resubmit'):
167
+ """Validate, then commit unless this is a dry run."""
168
+ self.validate()
169
+ if not dry_run:
170
+ self.commit(in_review)
171
+
172
+
173
+ # ------------------------------------------------------------------ tracks
174
+
175
+ def resolve_track(available, factor, name):
176
+ """The real track id for a form factor and a CLI name, or None."""
177
+ wanted = track_id(name)
178
+ if factor == 'phone':
179
+ return wanted if wanted in available else None
180
+ if wanted.startswith(WEAR_PREFIX):
181
+ return wanted if wanted in available else None
182
+ wear = [t for t in available if t.startswith(WEAR_PREFIX)]
183
+ for suffix in WEAR_SUFFIXES.get(wanted, ()):
184
+ if WEAR_PREFIX + suffix in wear:
185
+ return WEAR_PREFIX + suffix
186
+ if wanted != 'alpha':
187
+ return None
188
+ known = {WEAR_PREFIX + s for group in WEAR_SUFFIXES.values() for s in group}
189
+ custom = [t for t in wear if t not in known]
190
+ if len(custom) == 1:
191
+ return custom[0]
192
+ if len(custom) > 1:
193
+ raise UsageError(f'several Wear closed tracks exist ({", ".join(custom)}); name one with --track')
194
+ return None
195
+
196
+
197
+ def release_notes(project):
198
+ """whatsnew-<locale> files in the configured folder, as Play releaseNotes.
199
+ Checked here because Play rejects an over-long note only at commit, after
200
+ the whole bundle has gone up."""
201
+ folder = project.path('store.whatsnew_dir')
202
+ if not folder or not folder.is_dir():
203
+ return []
204
+ notes = []
205
+ for f in sorted(folder.iterdir()):
206
+ if not f.name.startswith(WHATSNEW_PREFIX):
207
+ continue
208
+ text = f.read_text(encoding='utf-8').strip()
209
+ if len(text) > WHATSNEW_LIMIT:
210
+ raise UsageError(f'{f.name} is {len(text)} characters; Play allows {WHATSNEW_LIMIT}',
211
+ code='WHATSNEW_TOO_LONG')
212
+ if text:
213
+ notes.append({'language': f.name[len(WHATSNEW_PREFIX):], 'text': text})
214
+ return notes
215
+
216
+
217
+ # What a release note may not hold (docs/store.md): Play shows it as plain text
218
+ # under its own "What's new" heading, one change per line.
219
+ NOTE_BANNED = {'—': 'em dash', '–': 'en dash', '→': 'arrow', '←': 'arrow',
220
+ '•': 'bullet', '●': 'bullet', '▪': 'bullet', '‣': 'bullet',
221
+ '·': 'middle dot'}
222
+ NOTE_MARKERS = ('- ', '* ', '+ ')
223
+ NOTE_HEADINGS = {"what's new", 'whats new', 'new in this version', 'release notes'}
224
+ _EMOJI = re.compile('[🀀-🫿☀-➿️]')
225
+
226
+
227
+ def note_problems(text):
228
+ """Why one note breaks the house style, as short phrases; empty when it is fine."""
229
+ found = [name for ch, name in NOTE_BANNED.items() if ch in text]
230
+ lines = [l.strip() for l in text.splitlines() if l.strip()]
231
+ if ' - ' in text:
232
+ found.append('hyphen used as a dash')
233
+ if any(l.startswith(NOTE_MARKERS) for l in lines):
234
+ found.append('list marker')
235
+ if _EMOJI.search(text):
236
+ found.append('emoji')
237
+ # English only: a translated heading cannot be told from a first change.
238
+ if lines and lines[0].rstrip(':').replace('’', "'").casefold() in NOTE_HEADINGS:
239
+ found.append('heading line (Play shows its own)')
240
+ return list(dict.fromkeys(found))
241
+
242
+
243
+ def check_note_style(notes):
244
+ """Stop a release whose notes break the house style, naming every locale at once."""
245
+ bad = {n['language']: note_problems(n['text']) for n in notes}
246
+ bad = {lang: why for lang, why in bad.items() if why}
247
+ if bad:
248
+ raise UsageError('release notes break the house style: '
249
+ + '; '.join(f'{lang}: {", ".join(why)}' for lang, why in sorted(bad.items())),
250
+ code='WHATSNEW_STYLE',
251
+ hint='Plain lines, one change each: no bullets, dashes, arrows, emoji or '
252
+ 'heading (docs/store.md). `mkit whatsnew check` lists them.')
253
+
254
+
255
+ def _is_draft_only(error):
256
+ # Play answers a completed release on a never-published app with a clean
257
+ # 400 on some tracks and a bare 500 on others; a 500 that survived retry
258
+ # is treated as the same refusal and checked by re-staging as draft.
259
+ from googleapiclient.errors import HttpError
260
+ return isinstance(error, HttpError) and (_DRAFT_ONLY in str(error) or auth.status_of(error) >= 500)
261
+
262
+
263
+ def _stage(edit, track, release):
264
+ """Stage `release` on `track` and validate; fall back to a draft release on
265
+ an app Play still treats as a draft. Returns the status that validated."""
266
+ from googleapiclient.errors import HttpError
267
+ edit.set_track(track, [release])
268
+ try:
269
+ edit.validate()
270
+ return release['status']
271
+ except HttpError as error:
272
+ if release['status'] == 'draft' or not _is_draft_only(error):
273
+ raise
274
+ draft = {k: v for k, v in release.items() if k != 'userFraction'}
275
+ draft['status'] = 'draft'
276
+ edit.set_track(track, [draft])
277
+ try:
278
+ edit.validate()
279
+ except HttpError:
280
+ raise error from None
281
+ output.out.warn('the app is still a draft on Play, so this went up as a DRAFT release; '
282
+ 'publish it once in Play Console, after which releases complete on their own')
283
+ return 'draft'
284
+
285
+
286
+ # ------------------------------------------------------------------ upload
287
+
288
+ def _upload(request):
289
+ """Run a resumable upload, reporting real transferred percentage and
290
+ riding out stalls: the resumable session survives a dropped connection."""
291
+ out = output.out
292
+ response, stalls = None, 0
293
+ while response is None:
294
+ try:
295
+ status, response = request.next_chunk(num_retries=5)
296
+ except auth.TRANSIENT as error:
297
+ stalls += 1
298
+ if stalls > UPLOAD_RETRIES:
299
+ raise
300
+ out.warn(f'upload stalled ({type(error).__name__}), resuming [{stalls}/{UPLOAD_RETRIES}]')
301
+ continue
302
+ if status:
303
+ out.upload(int(status.progress() * 100))
304
+ out.upload(100)
305
+ return response
306
+
307
+
308
+ def _media(path, chunk_mb):
309
+ from googleapiclient.http import MediaFileUpload
310
+ return MediaFileUpload(str(path), mimetype='application/octet-stream', resumable=True,
311
+ chunksize=chunk_mb * 1024 * 1024)
312
+
313
+
314
+ def upload_release(ctx, kind, track='internal', notes=None, dry_run=False, rollout=None,
315
+ release_name=None, attempts=3):
316
+ """Upload every module's bundle (and mapping) and point its track at it.
317
+
318
+ One release per form factor: Play refuses a watch bundle inside the phone's
319
+ release at review time. A watch bundle with no Wear track is not uploaded
320
+ at all -- an artifact on no track spends a versionCode for nothing.
321
+
322
+ An expired edit (someone committed in the Console meanwhile) restarts the
323
+ whole upload on a fresh edit; the bundles in the dead edit went with it.
324
+ Returns [{"module", "track", "versionCode", "status"}].
325
+ """
326
+ from googleapiclient.errors import HttpError
327
+ out = output.out
328
+ modules = kind.modules()
329
+ missing = [str(m.aab) for m in modules if not m.aab.is_file()]
330
+ if missing:
331
+ raise MkitError('bundle not built: ' + ', '.join(missing), code='NO_BUNDLE',
332
+ hint='`mkit deploy` builds before uploading.')
333
+ notes = release_notes(ctx.project) if notes is None else notes
334
+ chunk = int(ctx.project.get('play.upload_chunk_mb', 4))
335
+ for attempt in range(attempts):
336
+ try:
337
+ with Edit(ctx) as edit:
338
+ available = [t['track'] for t in edit.tracks()]
339
+ plan = []
340
+ for m in modules:
341
+ target = resolve_track(available, m.factor, track)
342
+ if target:
343
+ plan.append((m, target))
344
+ elif m.factor == 'wear':
345
+ out.warn(f'{m.label}: {NO_WEAR_TRACK}')
346
+ else:
347
+ raise NotFound(f'Play has no "{track}" track for this app '
348
+ f'(it has: {", ".join(available) or "none"})')
349
+ out.detail(f"what's new for {len(notes)} locale(s)" if notes else "no what's new notes")
350
+ results = []
351
+ for m, target in plan:
352
+ out.detail(f'{m.label} bundle {m.aab.stat().st_size / 1_000_000:.1f} MB')
353
+ try:
354
+ uploaded = _upload(edit.edits.bundles().upload(
355
+ packageName=edit.package, editId=edit.id, media_body=_media(m.aab, chunk)))
356
+ except HttpError as e:
357
+ if 'already been used' in str(e):
358
+ raise Conflict(f'Play already has a {m.label} bundle with this versionCode',
359
+ hint='Bump before releasing again; `mkit deploy` bumps and builds together.',
360
+ code='VERSION_CODE_USED') from None
361
+ raise
362
+ code = str(uploaded['versionCode'])
363
+ if m.mapping.is_file():
364
+ _upload(edit.edits.deobfuscationfiles().upload(
365
+ packageName=edit.package, editId=edit.id, apkVersionCode=int(code),
366
+ deobfuscationFileType='proguard', media_body=_media(m.mapping, chunk)))
367
+ out.detail(f'{m.label} mapping uploaded')
368
+ release = {'versionCodes': [code], 'status': 'completed'}
369
+ if release_name:
370
+ release['name'] = release_name
371
+ if notes:
372
+ release['releaseNotes'] = notes
373
+ if rollout is not None and target.endswith('production'):
374
+ release.update(status='inProgress', userFraction=rollout)
375
+ status = _stage(edit, target, release)
376
+ out.detail(f'{m.label} versionCode {code} staged on {target} ({status})')
377
+ results.append({'module': m.label, 'track': target, 'versionCode': code,
378
+ 'status': status})
379
+ if not dry_run:
380
+ edit.commit()
381
+ return results
382
+ except HttpError as e:
383
+ if _EDIT_EXPIRED in str(e) and attempt < attempts - 1:
384
+ out.warn(f'Play expired the edit mid-upload; starting again ({attempt + 1}/{attempts - 1})')
385
+ continue
386
+ raise auth.translate(e, 'Play') from None
387
+
388
+
389
+ # ------------------------------------------------------------------ promote / rollout / status
390
+
391
+ def _source_release(releases):
392
+ """A completed release first; a draft one will do on a never-published app."""
393
+ for status in ('completed', 'inProgress', 'draft'):
394
+ found = next((r for r in releases if r.get('status') == status and r.get('versionCodes')), None)
395
+ if found:
396
+ return found
397
+ return None
398
+
399
+
400
+ def promote(ctx, kind, source='internal', dest='prod', rollout=None, dry_run=False):
401
+ """Point `dest` at what `source` holds, for every form factor. No rebuild.
402
+
403
+ The release name and "What's new" travel with the build: they are what the
404
+ testers saw and belong to that binary, not to today's working tree.
405
+ Returns [{"factor", "from", "to", "versionCodes", "status"}].
406
+ """
407
+ from googleapiclient.errors import HttpError
408
+ if track_id(source) == track_id(dest):
409
+ raise UsageError(f'from and to are both {dest}; nothing to promote')
410
+ out = output.out
411
+ factors = sorted({m.factor for m in kind.modules()}, key=lambda f: f != 'phone')
412
+ try:
413
+ with Edit(ctx) as edit:
414
+ available = [t['track'] for t in edit.tracks()]
415
+ moved = []
416
+ for factor in factors:
417
+ src, dst = resolve_track(available, factor, source), resolve_track(available, factor, dest)
418
+ if not src or not dst:
419
+ out.warn(f'{factor}: no {"source" if not src else "destination"} track, skipped')
420
+ continue
421
+ found = _source_release(edit.track(src).get('releases', []))
422
+ if not found:
423
+ out.warn(f'{factor}: nothing on {src} to promote')
424
+ continue
425
+ release = {'versionCodes': found['versionCodes'], 'status': 'completed'}
426
+ for field in ('releaseNotes', 'name'):
427
+ if field in found:
428
+ release[field] = found[field]
429
+ if 'releaseNotes' not in release:
430
+ out.warn(f'{factor}: no release notes on {src}; {dst} will show none')
431
+ if rollout is not None and dst.endswith('production'):
432
+ release.update(status='inProgress', userFraction=rollout)
433
+ status = _stage(edit, dst, release)
434
+ out.detail(f'{factor}: versionCode {", ".join(found["versionCodes"])} {src} -> {dst} ({status})')
435
+ moved.append({'factor': factor, 'from': src, 'to': dst,
436
+ 'versionCodes': found['versionCodes'], 'status': status,
437
+ 'name': found.get('name')})
438
+ if not moved:
439
+ raise NotFound(f'nothing on {source} to promote', hint='`mkit status` shows every track.')
440
+ if not dry_run:
441
+ edit.commit()
442
+ return moved
443
+ except HttpError as e:
444
+ raise auth.translate(e, 'Play') from None
445
+
446
+
447
+ def rollout(ctx, track, action, fraction=None, dry_run=False):
448
+ """Change a staged rollout: set a fraction, halt, resume, or complete."""
449
+ from googleapiclient.errors import HttpError
450
+ target = track_id(track)
451
+ try:
452
+ with Edit(ctx) as edit:
453
+ releases = edit.track(target).get('releases', [])
454
+ staged = next((r for r in releases if r.get('status') in ('inProgress', 'halted')), None)
455
+ if not staged:
456
+ raise NotFound(f'no staged rollout on {target}',
457
+ hint='Only a production release promoted with --rollout is staged.')
458
+ release = {k: v for k, v in staged.items() if k in ('versionCodes', 'name', 'releaseNotes',
459
+ 'userFraction', 'countryTargeting')}
460
+ if action == 'complete':
461
+ release.pop('userFraction', None)
462
+ release['status'] = 'completed'
463
+ elif action == 'halt':
464
+ release['status'] = 'halted'
465
+ elif action == 'resume':
466
+ release['status'] = 'inProgress'
467
+ else:
468
+ if not 0 < fraction < 1:
469
+ raise UsageError('rollout percentage must be between 0 and 100 (exclusive); '
470
+ 'use `complete` for 100')
471
+ release.update(status='inProgress', userFraction=fraction)
472
+ # Keep the completed release beside the staged one: it is what
473
+ # everyone outside the rollout still gets.
474
+ others = [r for r in releases if r is not staged and r.get('status') == 'completed']
475
+ edit.set_track(target, others + [release] if action != 'complete' else [release])
476
+ edit.finish(dry_run)
477
+ return {'track': target, 'versionCodes': release['versionCodes'],
478
+ 'status': release['status'], 'userFraction': release.get('userFraction')}
479
+ except HttpError as e:
480
+ raise auth.translate(e, 'Play') from None
481
+
482
+
483
+ def status(ctx):
484
+ """Every track and what it holds. Read-only: the edit is always discarded."""
485
+ from googleapiclient.errors import HttpError
486
+ try:
487
+ with Edit(ctx) as edit:
488
+ rows = []
489
+ for t in sorted(edit.tracks(), key=lambda t: (':' in t['track'], t['track'])):
490
+ for r in t.get('releases', []) or [{}]:
491
+ rows.append({'track': t['track'], 'status': r.get('status', 'empty'),
492
+ 'versionCodes': r.get('versionCodes', []), 'name': r.get('name', ''),
493
+ 'userFraction': r.get('userFraction'),
494
+ 'notes': len(r.get('releaseNotes', []))})
495
+ return rows
496
+ except HttpError as e:
497
+ raise auth.translate(e, 'Play') from None