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/__init__.py +5 -0
- mkit/art.py +1220 -0
- mkit/aso.py +359 -0
- mkit/cli.py +176 -0
- mkit/cloud/__init__.py +1 -0
- mkit/cloud/auth.py +130 -0
- mkit/cloud/fbproject.py +180 -0
- mkit/cloud/firebase.py +164 -0
- mkit/cloud/insight.py +286 -0
- mkit/cloud/money.py +676 -0
- mkit/cloud/play.py +497 -0
- mkit/cloud/store.py +390 -0
- mkit/commands/__init__.py +2 -0
- mkit/commands/art.py +116 -0
- mkit/commands/aso.py +130 -0
- mkit/commands/dev.py +256 -0
- mkit/commands/doctor.py +191 -0
- mkit/commands/firebase_cmds.py +194 -0
- mkit/commands/init.py +213 -0
- mkit/commands/insight.py +110 -0
- mkit/commands/meta.py +84 -0
- mkit/commands/money.py +152 -0
- mkit/commands/release.py +430 -0
- mkit/commands/selfcmd.py +167 -0
- mkit/commands/store.py +263 -0
- mkit/config.py +146 -0
- mkit/data/art/art.toml +119 -0
- mkit/data/art/en.json +38 -0
- mkit/data/datasafety-questions.csv +783 -0
- mkit/datasafety.py +302 -0
- mkit/device.py +246 -0
- mkit/docgen.py +100 -0
- mkit/errors.py +78 -0
- mkit/git.py +95 -0
- mkit/hooks.py +32 -0
- mkit/kinds.py +350 -0
- mkit/notify.py +49 -0
- mkit/output.py +377 -0
- mkit/proc.py +154 -0
- mkit/registry.py +99 -0
- mkit/safety.py +60 -0
- mkit/secrets.py +148 -0
- mkit/toolchain.py +210 -0
- mkit_cli-0.3.1.dist-info/METADATA +179 -0
- mkit_cli-0.3.1.dist-info/RECORD +48 -0
- mkit_cli-0.3.1.dist-info/WHEEL +4 -0
- mkit_cli-0.3.1.dist-info/entry_points.txt +2 -0
- mkit_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
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
|