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/store.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""The store listing as files in the repo, diffed against Play and applied.
|
|
2
|
+
|
|
3
|
+
Layout, all paths from [store] in mkit.toml:
|
|
4
|
+
|
|
5
|
+
listing_dir/<code>.md three fenced blocks: title (30), short (80), full (4000)
|
|
6
|
+
art_dir/feature/<code>.png or art_dir/feature-graphic.png for every language
|
|
7
|
+
art_dir/screenshots/<code>/ frames, or screenshots/<code>.png, a panorama
|
|
8
|
+
sliced into 9:16 frames, or screenshots/ itself
|
|
9
|
+
art_dir/wear/<code>/ Wear OS screenshots (android-wear apps), or wear/
|
|
10
|
+
icon 512x512 PNG (default art_dir/icon.png)
|
|
11
|
+
|
|
12
|
+
[store.locales] file code -> Play locale(s)
|
|
13
|
+
en = ["en-US"]
|
|
14
|
+
es = ["es-419", "es-ES"] one text, two listings
|
|
15
|
+
|
|
16
|
+
[store.details] contactEmail, contactWebsite, contactPhone,
|
|
17
|
+
defaultLanguage
|
|
18
|
+
|
|
19
|
+
Images are compared by sha256 against what Play reports, so a plan knows which
|
|
20
|
+
languages' art actually changed and apply re-uploads only those. Locales that
|
|
21
|
+
exist on Play but not in the files are reported and never deleted.
|
|
22
|
+
"""
|
|
23
|
+
import hashlib
|
|
24
|
+
import io
|
|
25
|
+
import re
|
|
26
|
+
import struct
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from ..errors import UsageError
|
|
30
|
+
|
|
31
|
+
LIMITS = {'title': 30, 'shortDescription': 80, 'fullDescription': 4000}
|
|
32
|
+
FIELDS = ('title', 'shortDescription', 'fullDescription')
|
|
33
|
+
IMAGE_EXT = ('.png', '.jpg', '.jpeg')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def locales(project):
|
|
37
|
+
table = project.section('store').get('locales')
|
|
38
|
+
if not table:
|
|
39
|
+
raise UsageError('no [store.locales] table in mkit.toml',
|
|
40
|
+
hint='Map each listing file to its Play locale(s), e.g. en = ["en-US"].',
|
|
41
|
+
code='NO_LOCALES')
|
|
42
|
+
return {code: [v] if isinstance(v, str) else list(v) for code, v in table.items()}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_listing(path):
|
|
46
|
+
text = path.read_text(encoding='utf-8')
|
|
47
|
+
blocks = re.findall(r'```[^\n]*\r?\n(.*?)\r?\n```', text, re.S)
|
|
48
|
+
if len(blocks) < 3:
|
|
49
|
+
raise UsageError(f'{path}: expected 3 fenced blocks (title, short, full), found {len(blocks)}',
|
|
50
|
+
code='BAD_LISTING')
|
|
51
|
+
values = dict(zip(FIELDS, (b.strip() for b in blocks[:3])))
|
|
52
|
+
for field, value in values.items():
|
|
53
|
+
if len(value) > LIMITS[field]:
|
|
54
|
+
raise UsageError(f'{path.name}: {field} is {len(value)} characters, limit {LIMITS[field]}',
|
|
55
|
+
code='LISTING_TOO_LONG')
|
|
56
|
+
return values
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def desired_text(project):
|
|
60
|
+
"""{play_locale: {title, shortDescription, fullDescription}}"""
|
|
61
|
+
folder = project.path('store.listing_dir', 'docs/store-listing')
|
|
62
|
+
out = {}
|
|
63
|
+
for code, targets in locales(project).items():
|
|
64
|
+
path = folder / f'{code}.md'
|
|
65
|
+
if not path.is_file():
|
|
66
|
+
raise UsageError(f'listing file {path} is missing', code='NO_LISTING')
|
|
67
|
+
values = parse_listing(path)
|
|
68
|
+
for loc in targets:
|
|
69
|
+
out[loc] = values
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------ images
|
|
74
|
+
|
|
75
|
+
def _png_size(data):
|
|
76
|
+
if data[:8] == b'\x89PNG\r\n\x1a\n':
|
|
77
|
+
return struct.unpack('>II', data[16:24])
|
|
78
|
+
from PIL import Image
|
|
79
|
+
with Image.open(io.BytesIO(data)) as im:
|
|
80
|
+
return im.size
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _frames_from(path):
|
|
84
|
+
"""A panorama sliced into 9:16 frames, as PNG bytes."""
|
|
85
|
+
from PIL import Image
|
|
86
|
+
with Image.open(path) as im:
|
|
87
|
+
w, h = im.size
|
|
88
|
+
n = max(1, round(w / (h * 9 / 16)))
|
|
89
|
+
frames = []
|
|
90
|
+
for i in range(n):
|
|
91
|
+
buf = io.BytesIO()
|
|
92
|
+
im.crop((round(i * w / n), 0, round((i + 1) * w / n), h)).save(buf, 'PNG')
|
|
93
|
+
frames.append(buf.getvalue())
|
|
94
|
+
return frames
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _folder_images(folder):
|
|
98
|
+
if not folder.is_dir():
|
|
99
|
+
return None
|
|
100
|
+
files = sorted(p for p in folder.iterdir() if p.suffix.lower() in IMAGE_EXT and p.is_file())
|
|
101
|
+
return [p.read_bytes() for p in files]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _feature_bytes(path):
|
|
105
|
+
data = path.read_bytes()
|
|
106
|
+
if _png_size(data) == (1024, 500):
|
|
107
|
+
return data
|
|
108
|
+
from PIL import Image
|
|
109
|
+
with Image.open(path) as im:
|
|
110
|
+
buf = io.BytesIO()
|
|
111
|
+
im.convert('RGB').resize((1024, 500), Image.LANCZOS).save(buf, 'PNG')
|
|
112
|
+
return buf.getvalue()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def desired_images(project, kind_name, default_language='en-US'):
|
|
116
|
+
"""{play_locale: {imageType: [bytes, ...]}} for whatever art exists.
|
|
117
|
+
|
|
118
|
+
The icon goes with every language. Play shows the default language's icon
|
|
119
|
+
for a language with none of its own, but a language can hold its own (an
|
|
120
|
+
older upload), which then wins on its store page, so plan() compares the
|
|
121
|
+
icon wherever Play reports one and leaves a language without one alone."""
|
|
122
|
+
art = project.path('store.art_dir', 'store')
|
|
123
|
+
icon_path = project.path('store.icon') or art / 'icon.png'
|
|
124
|
+
icon = icon_path.read_bytes() if icon_path.is_file() else None
|
|
125
|
+
if icon and _png_size(icon) != (512, 512):
|
|
126
|
+
raise UsageError(f'{icon_path} must be 512x512, is {"x".join(map(str, _png_size(icon)))}',
|
|
127
|
+
code='BAD_ICON')
|
|
128
|
+
# Explicit single paths win over the per-language layout; art_locales limits
|
|
129
|
+
# which Play locales get art at all (the others inherit the default one's).
|
|
130
|
+
store = project.section('store')
|
|
131
|
+
feature_one = project.path('store.feature')
|
|
132
|
+
shots_one = project.path('store.screenshots')
|
|
133
|
+
wear_one = project.path('store.wear_screenshots')
|
|
134
|
+
art_locales = store.get('art_locales')
|
|
135
|
+
out = {}
|
|
136
|
+
for code, targets in locales(project).items():
|
|
137
|
+
images = {}
|
|
138
|
+
feature = feature_one or art / 'feature' / f'{code}.png'
|
|
139
|
+
feature = feature if feature.is_file() else art / 'feature-graphic.png'
|
|
140
|
+
if feature.is_file():
|
|
141
|
+
images['featureGraphic'] = [_feature_bytes(feature)]
|
|
142
|
+
pano = art / 'screenshots' / f'{code}.png'
|
|
143
|
+
if shots_one:
|
|
144
|
+
shots = _folder_images(shots_one)
|
|
145
|
+
else:
|
|
146
|
+
shots = (_frames_from(pano) if pano.is_file()
|
|
147
|
+
else _folder_images(art / 'screenshots' / code) or _folder_images(art / 'screenshots'))
|
|
148
|
+
if shots:
|
|
149
|
+
images['phoneScreenshots'] = shots
|
|
150
|
+
if kind_name == 'android-wear':
|
|
151
|
+
wear = (_folder_images(wear_one) if wear_one
|
|
152
|
+
else _folder_images(art / 'wear' / code) or _folder_images(art / 'wear'))
|
|
153
|
+
if wear:
|
|
154
|
+
images['wearScreenshots'] = wear
|
|
155
|
+
for loc in targets:
|
|
156
|
+
if art_locales is not None and loc not in art_locales:
|
|
157
|
+
images_here = {}
|
|
158
|
+
else:
|
|
159
|
+
images_here = dict(images)
|
|
160
|
+
if icon:
|
|
161
|
+
images_here['icon'] = [icon]
|
|
162
|
+
if images_here:
|
|
163
|
+
out[loc] = images_here
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def check_screenshot(data, what):
|
|
168
|
+
"""Warn, don't refuse: art already live on Play can sit outside the published
|
|
169
|
+
guideline (tall phones are over 2:1), and Play is the one that decides."""
|
|
170
|
+
from .. import output
|
|
171
|
+
w, h = _png_size(data)
|
|
172
|
+
lo, hi = min(w, h), max(w, h)
|
|
173
|
+
if lo < 320 or hi > 3840 or hi > 2 * lo:
|
|
174
|
+
output.out.warn(f'{what} is {w}x{h}; Play asks for sides 320-3840 and the long side at most '
|
|
175
|
+
'twice the short one')
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def sha256(data):
|
|
179
|
+
return hashlib.sha256(data).hexdigest()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------ diff
|
|
183
|
+
|
|
184
|
+
WORKERS = 6
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _workers(project):
|
|
188
|
+
return max(1, int(project.get('play.upload_workers', WORKERS)))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _each(items, work, workers, progress=None):
|
|
192
|
+
"""Run work(item) on worker threads; results in the items' order.
|
|
193
|
+
progress(done, total, item) after each one."""
|
|
194
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
195
|
+
items = list(items)
|
|
196
|
+
results = [None] * len(items)
|
|
197
|
+
if not items:
|
|
198
|
+
return results
|
|
199
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
200
|
+
futures = {pool.submit(work, it): n for n, it in enumerate(items)}
|
|
201
|
+
done = 0
|
|
202
|
+
for f in as_completed(futures):
|
|
203
|
+
n = futures[f]
|
|
204
|
+
results[n] = f.result()
|
|
205
|
+
done += 1
|
|
206
|
+
if progress:
|
|
207
|
+
progress(done, len(items), items[n])
|
|
208
|
+
return results
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def plan(edit, project, kind_name, with_images=True, with_text=True, progress=None, only=None):
|
|
212
|
+
"""Compare local files with the listing in `edit`. Changes nothing.
|
|
213
|
+
progress(done, total, item) is called as Play's images are read.
|
|
214
|
+
only: a set of Play locales to limit the plan to (a staged rollout); the
|
|
215
|
+
other locales and the app-wide contact details are then left alone."""
|
|
216
|
+
if only:
|
|
217
|
+
configured = {loc for targets in locales(project).values() for loc in targets}
|
|
218
|
+
unknown = sorted(set(only) - configured)
|
|
219
|
+
if unknown:
|
|
220
|
+
raise UsageError(f'not in [store.locales]: {", ".join(unknown)}', code='UNKNOWN_LOCALE',
|
|
221
|
+
hint='Use Play locale codes as mkit.toml maps them, e.g. de-DE,es-ES.')
|
|
222
|
+
listings = {l['language']: l for l in edit.call(
|
|
223
|
+
lambda e, p, i: e.listings().list(packageName=p, editId=i), 'read listings').get('listings', [])}
|
|
224
|
+
result = {'text': [], 'images': [], 'details': {}, 'remote_only': [], 'no_listing': []}
|
|
225
|
+
|
|
226
|
+
if with_text:
|
|
227
|
+
for loc, want in desired_text(project).items():
|
|
228
|
+
if only and loc not in only:
|
|
229
|
+
continue
|
|
230
|
+
have = listings.get(loc, {})
|
|
231
|
+
changed = {f: {'from': have.get(f, ''), 'to': want[f]} for f in FIELDS if have.get(f, '') != want[f]}
|
|
232
|
+
if changed:
|
|
233
|
+
result['text'].append({'locale': loc, 'new': loc not in listings, 'fields': changed})
|
|
234
|
+
configured = {loc for targets in locales(project).values() for loc in targets}
|
|
235
|
+
result['remote_only'] = sorted(set(listings) - configured)
|
|
236
|
+
|
|
237
|
+
details_now = edit.call(lambda e, p, i: e.details().get(packageName=p, editId=i), 'read details')
|
|
238
|
+
default_language = details_now.get('defaultLanguage') or 'en-US'
|
|
239
|
+
|
|
240
|
+
if with_images:
|
|
241
|
+
# A language Play has no listing for has no images, and Play answers a
|
|
242
|
+
# read of them with 404. Its images can go up in the same edit once
|
|
243
|
+
# its text is created; without the text (--images-only) they cannot.
|
|
244
|
+
creating = {c['locale'] for c in result['text'] if c['new']}
|
|
245
|
+
sets = []
|
|
246
|
+
for loc, types in desired_images(project, kind_name, default_language).items():
|
|
247
|
+
if only and loc not in only:
|
|
248
|
+
continue
|
|
249
|
+
exists = loc in listings
|
|
250
|
+
if not exists and loc not in creating:
|
|
251
|
+
if types:
|
|
252
|
+
result['no_listing'].append(loc)
|
|
253
|
+
continue
|
|
254
|
+
for image_type, blobs in types.items():
|
|
255
|
+
if image_type.endswith('Screenshots'):
|
|
256
|
+
for n, b in enumerate(blobs, 1):
|
|
257
|
+
check_screenshot(b, f'{loc} {image_type} #{n}')
|
|
258
|
+
sets.append((loc, image_type, blobs, exists))
|
|
259
|
+
|
|
260
|
+
def read(item):
|
|
261
|
+
loc, image_type, blobs, exists = item
|
|
262
|
+
if not exists:
|
|
263
|
+
return []
|
|
264
|
+
remote = edit.call(lambda e, p, i: e.images().list(
|
|
265
|
+
packageName=p, editId=i, language=loc, imageType=image_type), f'read {loc} {image_type}')
|
|
266
|
+
return [img.get('sha256') for img in remote.get('images', [])]
|
|
267
|
+
|
|
268
|
+
remotes = _each(sets, read, _workers(project),
|
|
269
|
+
progress and (lambda d, t, it: progress(d, t, f'{it[0]} {it[1]}')))
|
|
270
|
+
for (loc, image_type, blobs, _), remote_sha in zip(sets, remotes):
|
|
271
|
+
# No icon of its own: the language shows the default one's.
|
|
272
|
+
if image_type == 'icon' and loc != default_language and not remote_sha:
|
|
273
|
+
continue
|
|
274
|
+
if remote_sha != [sha256(b) for b in blobs]:
|
|
275
|
+
result['images'].append({'locale': loc, 'type': image_type, 'local': len(blobs),
|
|
276
|
+
'remote': len(remote_sha), '_blobs': blobs})
|
|
277
|
+
|
|
278
|
+
details = project.section('store').get('details') or {}
|
|
279
|
+
if details and not only:
|
|
280
|
+
result['details'] = {k: {'from': details_now.get(k, ''), 'to': v} for k, v in details.items()
|
|
281
|
+
if details_now.get(k, '') != v}
|
|
282
|
+
return result
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def plan_size(result):
|
|
286
|
+
return len(result['text']) + len(result['images']) + len(result['details'])
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def upload_count(result):
|
|
290
|
+
"""Files apply will send: every image in every changed set."""
|
|
291
|
+
return sum(c['local'] for c in result['images'])
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def apply(edit, result, workers=WORKERS, on_text=None, on_image=None):
|
|
295
|
+
"""Stage every change from `plan` in the edit (the caller commits).
|
|
296
|
+
|
|
297
|
+
Languages go up in parallel on worker threads; within one image set the
|
|
298
|
+
files go one after another, since Play keeps screenshots in upload order.
|
|
299
|
+
Text first, so a language new to Play exists before its images.
|
|
300
|
+
on_text(done, total, locale) and on_image(done, total, name) report each
|
|
301
|
+
one as it lands."""
|
|
302
|
+
import threading
|
|
303
|
+
from googleapiclient.http import MediaIoBaseUpload
|
|
304
|
+
|
|
305
|
+
def text(change):
|
|
306
|
+
# patch keeps what the files don't hold (a promo video); a brand-new
|
|
307
|
+
# locale has nothing to keep, so it is created whole.
|
|
308
|
+
body = {'language': change['locale'], **{f: v['to'] for f, v in change['fields'].items()}}
|
|
309
|
+
method = 'update' if change['new'] else 'patch'
|
|
310
|
+
edit.call(lambda e, p, i: getattr(e.listings(), method)(
|
|
311
|
+
packageName=p, editId=i, language=body['language'], body=body), f'text {change["locale"]}')
|
|
312
|
+
|
|
313
|
+
_each(result['text'], text, workers, on_text and (lambda d, t, c: on_text(d, t, c['locale'])))
|
|
314
|
+
|
|
315
|
+
total = upload_count(result)
|
|
316
|
+
sent = [0]
|
|
317
|
+
lock = threading.Lock()
|
|
318
|
+
|
|
319
|
+
def images(change):
|
|
320
|
+
loc, image_type = change['locale'], change['type']
|
|
321
|
+
edit.call(lambda e, p, i: e.images().deleteall(packageName=p, editId=i, language=loc,
|
|
322
|
+
imageType=image_type), f'clear {loc} {image_type}')
|
|
323
|
+
for n, blob in enumerate(change['_blobs'], 1):
|
|
324
|
+
mime = 'image/png' if blob[:4] == b'\x89PNG' else 'image/jpeg'
|
|
325
|
+
edit.call(lambda e, p, i: e.images().upload(
|
|
326
|
+
packageName=p, editId=i, language=loc, imageType=image_type,
|
|
327
|
+
media_body=MediaIoBaseUpload(io.BytesIO(blob), mimetype=mime)), f'{loc} {image_type} #{n}')
|
|
328
|
+
with lock:
|
|
329
|
+
sent[0] += 1
|
|
330
|
+
done = sent[0]
|
|
331
|
+
if on_image:
|
|
332
|
+
on_image(done, total, f'{loc} {image_type} #{n}')
|
|
333
|
+
|
|
334
|
+
_each(result['images'], images, workers)
|
|
335
|
+
if result['details']:
|
|
336
|
+
body = {k: v['to'] for k, v in result['details'].items()}
|
|
337
|
+
edit.call(lambda e, p, i: e.details().patch(packageName=p, editId=i, body=body), 'details')
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def public(result):
|
|
341
|
+
"""The plan without image bytes, for JSON output."""
|
|
342
|
+
return {**result, 'images': [{k: v for k, v in c.items() if k != '_blobs'} for c in result['images']]}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ------------------------------------------------------------------ data safety
|
|
346
|
+
|
|
347
|
+
REQUIRED_COLUMNS = ('Question ID (machine readable)', 'Response value', 'Answer requirement')
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def read_datasafety(project):
|
|
351
|
+
import csv
|
|
352
|
+
path = project.path('store.datasafety')
|
|
353
|
+
if not path or not path.is_file():
|
|
354
|
+
raise UsageError(f'data safety CSV not found ({path or "set datasafety under [store]"})',
|
|
355
|
+
hint='Export it once from Play Console > App content > Data safety.',
|
|
356
|
+
code='NO_DATASAFETY')
|
|
357
|
+
text = path.read_text(encoding='utf-8-sig')
|
|
358
|
+
rows = list(csv.DictReader(io.StringIO(text)))
|
|
359
|
+
if not rows or any(c not in rows[0] for c in REQUIRED_COLUMNS):
|
|
360
|
+
raise UsageError(f'{path} is not a Play data safety CSV (columns missing)', code='BAD_DATASAFETY')
|
|
361
|
+
required = [r for r in rows if r['Answer requirement'] == 'REQUIRED']
|
|
362
|
+
blank = [r['Question ID (machine readable)'] for r in required if not r['Response value'].strip()]
|
|
363
|
+
|
|
364
|
+
def answer(question, response=''):
|
|
365
|
+
return next((r['Response value'].strip().upper() for r in rows
|
|
366
|
+
if r['Question ID (machine readable)'] == question
|
|
367
|
+
and (r.get('Response ID (machine readable)') or '') == response), '')
|
|
368
|
+
|
|
369
|
+
# MAYBE_REQUIRED, and Play does require them: an app where people make
|
|
370
|
+
# accounts must link a page for deleting one, and an app that says data
|
|
371
|
+
# can be deleted must link a page for that.
|
|
372
|
+
creates_accounts = any(
|
|
373
|
+
r['Response value'].strip().upper() == 'TRUE'
|
|
374
|
+
for r in rows
|
|
375
|
+
if r['Question ID (machine readable)'] == 'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS'
|
|
376
|
+
and r.get('Response ID (machine readable)') != 'PSL_ACM_NONE')
|
|
377
|
+
for needed, question in ((creates_accounts, 'PSL_ACCOUNT_DELETION_URL'),
|
|
378
|
+
(answer('PSL_SUPPORT_DATA_DELETION_BY_USER', 'DATA_DELETION_YES') == 'TRUE',
|
|
379
|
+
'PSL_DATA_DELETION_URL')):
|
|
380
|
+
if needed and not answer(question):
|
|
381
|
+
blank.append(question)
|
|
382
|
+
collects = next((r['Response value'] for r in rows
|
|
383
|
+
if r['Question ID (machine readable)'] == 'PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA'), '')
|
|
384
|
+
# Labels read "Category / Type" or put the type on a line of its own.
|
|
385
|
+
types = sorted({(r.get('Human-friendly question label') or '').replace('\n', ' / ').split(' / ')[-1].strip()
|
|
386
|
+
for r in rows if r['Question ID (machine readable)'].startswith('PSL_DATA_TYPES_')
|
|
387
|
+
and r['Response value'].strip().upper() == 'TRUE'})
|
|
388
|
+
return {'path': str(path), 'text': text, 'rows': len(rows),
|
|
389
|
+
'answered': sum(1 for r in rows if r['Response value'].strip()),
|
|
390
|
+
'required_blank': blank, 'collects_data': collects, 'data_types': types}
|
mkit/commands/art.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Store art: screenshots and feature graphic from captures and captions (see mkit/art.py)."""
|
|
2
|
+
from .. import art
|
|
3
|
+
from ..registry import Arg, command, group
|
|
4
|
+
|
|
5
|
+
group('art', 'store screenshots and feature graphic, built from captures and captions')
|
|
6
|
+
|
|
7
|
+
CODES = Arg('codes', 'listing codes (default: every captions file)', kind='positional', nargs='*')
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _codes(design, args):
|
|
11
|
+
codes = args.codes or design.codes()
|
|
12
|
+
unknown = [c for c in codes if c not in design.codes()]
|
|
13
|
+
if unknown:
|
|
14
|
+
from ..errors import NotFound
|
|
15
|
+
raise NotFound(f'no captions for {", ".join(unknown)}',
|
|
16
|
+
hint=f'Captions live in {design.dir / "captions"}/<code>.json.', code='NO_CAPTIONS')
|
|
17
|
+
return codes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _coverage(project, design):
|
|
21
|
+
"""Listing codes with no captions, and captions no listing uses."""
|
|
22
|
+
locales = project.section('store').get('locales') or {}
|
|
23
|
+
if not locales:
|
|
24
|
+
return [], []
|
|
25
|
+
have = set(design.codes())
|
|
26
|
+
return sorted(set(locales) - have), sorted(have - set(locales))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@command('art init', 'Set up store art: the art folder, [art] in mkit.toml, .gitignore lines',
|
|
30
|
+
risk='write', examples=('mkit art init',))
|
|
31
|
+
def art_init(ctx, args):
|
|
32
|
+
"""Creates <dir>/shots/, <dir>/captions/en.json and <dir>/art.toml from the
|
|
33
|
+
starter files, adds [art] to mkit.toml and keeps the built images out of
|
|
34
|
+
git. Only what is missing is written."""
|
|
35
|
+
done = art.init(ctx.project)
|
|
36
|
+
if not ctx.out.json:
|
|
37
|
+
for line in done or ['already set up; nothing to do']:
|
|
38
|
+
ctx.out.line(f' {line}')
|
|
39
|
+
ctx.out.line('Next: captures in shots/, words in captions/en.json, slides in art.toml, '
|
|
40
|
+
'then `mkit art check` and `mkit art build`.')
|
|
41
|
+
return {'done': done}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@command('art check', 'Line counts of every language against the reference (text only, no images)',
|
|
45
|
+
risk='read', needs=('art',),
|
|
46
|
+
examples=('mkit art check', 'mkit art check de ar ja'), args=(CODES,))
|
|
47
|
+
def art_check(ctx, args):
|
|
48
|
+
"""Each caption block must wrap to as many lines as in the reference
|
|
49
|
+
language and stay inside its box; each file must have the reference's
|
|
50
|
+
keys, one [accent] per title and no dashes, arrows or bullets. The
|
|
51
|
+
reference itself, when named, is held to its boxes and line caps (a pop
|
|
52
|
+
title is at most two lines; a pill stays on the page). The pages are laid
|
|
53
|
+
out in the browser but no image is made, so a translation can be fitted
|
|
54
|
+
in seconds."""
|
|
55
|
+
design = art.Design(ctx.project)
|
|
56
|
+
codes = _codes(design, args)
|
|
57
|
+
out = ctx.out
|
|
58
|
+
out.start(f'{ctx.project.name} store art check', 1, f'{len(codes)} language(s), against {design.reference}')
|
|
59
|
+
out.step('Lay out every page')
|
|
60
|
+
|
|
61
|
+
def report(code, problems):
|
|
62
|
+
if not out.json:
|
|
63
|
+
if problems:
|
|
64
|
+
out.line(f' {code}:', 'yellow')
|
|
65
|
+
for p in problems:
|
|
66
|
+
out.line(f' {p}', 'yellow')
|
|
67
|
+
else:
|
|
68
|
+
out.line(f' {code}: OK')
|
|
69
|
+
|
|
70
|
+
results = art.check(design, codes, on_result=report)
|
|
71
|
+
out.done()
|
|
72
|
+
ref = results.pop('_reference')
|
|
73
|
+
bad = {c: p for c, p in results.items() if p}
|
|
74
|
+
no_captions, unused = _coverage(ctx.project, design)
|
|
75
|
+
if not out.json:
|
|
76
|
+
if no_captions:
|
|
77
|
+
out.line(f' listing codes with no captions (they get no art): {", ".join(no_captions)}', 'yellow')
|
|
78
|
+
if unused:
|
|
79
|
+
out.line(f' captions no listing uses: {", ".join(unused)}', 'yellow')
|
|
80
|
+
if bad:
|
|
81
|
+
ctx.exit_code = 1
|
|
82
|
+
out.finish(f'{len(results) - len(bad)} of {len(results)} fit' if results else 'only the reference to check')
|
|
83
|
+
return {'reference': design.reference,
|
|
84
|
+
'reference_lines': {stem: {k: v['lines'] for k, v in blocks.items() if k != 'head'}
|
|
85
|
+
for stem, blocks in ref.items()},
|
|
86
|
+
'languages': results, 'no_captions': no_captions, 'unused_captions': unused}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@command('art build', 'Draw the screenshots and feature graphic for each language',
|
|
90
|
+
risk='write', needs=('art',),
|
|
91
|
+
examples=('mkit art build', 'mkit art build en ar', 'mkit art build en --keep-pages'),
|
|
92
|
+
args=(CODES, Arg('keep_pages', 'keep the HTML pages and print where', kind='flag')))
|
|
93
|
+
def art_build(ctx, args):
|
|
94
|
+
"""Writes <art_dir>/screenshots/<code>/01_<slide>.png ... and
|
|
95
|
+
<art_dir>/feature/<code>.png, the files `mkit listing plan --images-only`
|
|
96
|
+
compares with Play. Nothing is uploaded."""
|
|
97
|
+
design = art.Design(ctx.project)
|
|
98
|
+
codes = _codes(design, args)
|
|
99
|
+
out = ctx.out
|
|
100
|
+
out.start(f'{ctx.project.name} store art', 1, f'{len(codes)} language(s)')
|
|
101
|
+
out.step('Draw the pages')
|
|
102
|
+
|
|
103
|
+
def report(code, files):
|
|
104
|
+
if not out.json:
|
|
105
|
+
out.line(f' {code}: {len(files)} images')
|
|
106
|
+
|
|
107
|
+
written, kept = art.build(design, codes, keep_pages=args.keep_pages, on_result=report)
|
|
108
|
+
out.done()
|
|
109
|
+
if not out.json:
|
|
110
|
+
out.line(f' screenshots: {design.out / "screenshots"}')
|
|
111
|
+
out.line(f' feature graphics: {design.out / "feature"}')
|
|
112
|
+
if kept:
|
|
113
|
+
out.line(f' pages kept in {kept}')
|
|
114
|
+
out.line('Upload: `mkit listing plan --images-only`, then `mkit listing apply --images-only`.')
|
|
115
|
+
out.finish(f'{sum(len(f) for f in written.values())} images for {len(written)} language(s)')
|
|
116
|
+
return {'out': str(design.out), 'languages': written, 'pages': str(kept) if kept else None}
|
mkit/commands/aso.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Search rank checks, keyword research and listing impact (no official API; see mkit/aso.py)."""
|
|
2
|
+
from datetime import date
|
|
3
|
+
|
|
4
|
+
from .. import aso
|
|
5
|
+
from ..cloud import insight
|
|
6
|
+
from ..errors import UsageError
|
|
7
|
+
from ..registry import Arg, command, group
|
|
8
|
+
|
|
9
|
+
group('aso', 'search rank, keyword research and listing impact')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@command('aso rank', "Where the app ranks in Play search for each keyword, vs the last run",
|
|
13
|
+
risk='read', needs=('aso',), args=(Arg('workers', 'parallel requests', type=int, default=6),))
|
|
14
|
+
def aso_rank(ctx, args):
|
|
15
|
+
items, path = aso.load_keywords(ctx.project)
|
|
16
|
+
out = ctx.out
|
|
17
|
+
out.start(f'{ctx.project.name} search rank', 1, f'{len(items)} keywords')
|
|
18
|
+
out.step('Search Play')
|
|
19
|
+
results = aso.rank(ctx.project.package, items, args.workers)
|
|
20
|
+
folder = aso.results_dir(ctx.project, path)
|
|
21
|
+
saved = aso.save(folder, 'results', results)
|
|
22
|
+
out.done(f'saved {saved.name}')
|
|
23
|
+
before_file, before = aso.previous(folder, 'results', saved)
|
|
24
|
+
for r in results:
|
|
25
|
+
r['previous'] = before.get((r['kw'], r['gl']))
|
|
26
|
+
ranked = [r for r in results if r['pos']]
|
|
27
|
+
if not out.json:
|
|
28
|
+
for r in sorted(ranked, key=lambda r: r['pos']):
|
|
29
|
+
move = ''
|
|
30
|
+
if r['previous'] and r['previous'] != r['pos']:
|
|
31
|
+
move = f' (was #{r["previous"]})'
|
|
32
|
+
elif (r['kw'], r['gl']) in before and not r['previous']:
|
|
33
|
+
move = ' (new)'
|
|
34
|
+
out.line(f' #{r["pos"]:>3} {r["gl"]} {r["kw"]}{move}')
|
|
35
|
+
lost = [r for r in results if not r['pos'] and r['previous']]
|
|
36
|
+
for r in lost:
|
|
37
|
+
out.line(f' -- {r["gl"]} {r["kw"]} (was #{r["previous"]})', 'yellow')
|
|
38
|
+
errors = [r for r in results if r.get('error')]
|
|
39
|
+
out.finish(f'ranked for {len(ranked)} of {len(results)} keywords'
|
|
40
|
+
+ (f', {len(errors)} fetch error(s)' if errors else ''))
|
|
41
|
+
return {'file': str(saved), 'previous_file': str(before_file) if before_file else None,
|
|
42
|
+
'ranked': len(ranked), 'total': len(results), 'results': results}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@command('aso research', 'Find phrases people type and score which are worth targeting',
|
|
46
|
+
risk='read', needs=('aso',),
|
|
47
|
+
args=(Arg('market', 'only this country (gl), e.g. DE'),
|
|
48
|
+
Arg('top', 'phrases to show per market (human output)', type=int, default=15),
|
|
49
|
+
Arg('workers', 'parallel requests', type=int, default=3)),
|
|
50
|
+
examples=('mkit aso research', 'mkit aso research --market DE'))
|
|
51
|
+
def aso_research(ctx, args):
|
|
52
|
+
markets, path = aso.load_markets(ctx.project)
|
|
53
|
+
if args.market:
|
|
54
|
+
markets = [m for m in markets if m['gl'].upper() == args.market.upper()]
|
|
55
|
+
if not markets:
|
|
56
|
+
raise UsageError(f'no [[market]] with gl = "{args.market}" in {path.name}', code='NO_MARKET')
|
|
57
|
+
out = ctx.out
|
|
58
|
+
out.start(f'{ctx.project.name} keyword research', 1,
|
|
59
|
+
f'{len(markets)} market(s); slow on purpose, thousands of requests')
|
|
60
|
+
out.step('Ask autocomplete, then score each phrase')
|
|
61
|
+
results = aso.research(ctx.project.package, markets, args.workers,
|
|
62
|
+
progress=lambda d, t, item: out.progress(d, t, item))
|
|
63
|
+
saved = aso.save(aso.results_dir(ctx.project, path), 'research', results)
|
|
64
|
+
out.done(f'saved {saved.name}')
|
|
65
|
+
if not out.json:
|
|
66
|
+
for m in markets:
|
|
67
|
+
rows = [r for r in results if r['gl'] == m['gl']][:args.top]
|
|
68
|
+
out.heading(f'{m["gl"]} ({m["lang"]})')
|
|
69
|
+
out.table([(f'{r["score"]:.0f}', r['kw'], f'{r["demand"]:.2f}', f'{r["median_top5"]:,}',
|
|
70
|
+
r['in_title'], f'#{r["pos"]}' if r['pos'] else '-') for r in rows],
|
|
71
|
+
('score', 'phrase', 'demand', 'median top 5', 'in titles', 'us'))
|
|
72
|
+
out.finish(f'{len(results)} phrase(s) scored; pick from the top')
|
|
73
|
+
return {'file': str(saved), 'results': results}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@command('aso impact', 'What a listing change did: store visitors and keywords before vs after a date',
|
|
77
|
+
risk='read', needs=('aso', 'play'),
|
|
78
|
+
args=(Arg('since', 'the day the change went live, YYYY-MM-DD'),
|
|
79
|
+
Arg('days', 'days to compare on each side', type=int, default=14)),
|
|
80
|
+
examples=('mkit aso impact --since 2026-10-02',))
|
|
81
|
+
def aso_impact(ctx, args):
|
|
82
|
+
if not args.since:
|
|
83
|
+
raise UsageError('pass --since YYYY-MM-DD, the day the listing change went live', code='NO_SINCE')
|
|
84
|
+
try:
|
|
85
|
+
date.fromisoformat(args.since)
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise UsageError(f'--since {args.since!r} is not a date (YYYY-MM-DD)', code='BAD_DATE') from None
|
|
88
|
+
_, path = aso.load_keywords(ctx.project)
|
|
89
|
+
out = ctx.out
|
|
90
|
+
out.start(f'{ctx.project.name} listing impact since {args.since}', 1)
|
|
91
|
+
out.step('Read Play store reports')
|
|
92
|
+
daily = insight.store_daily(ctx)
|
|
93
|
+
out.done(f'{len(daily)} day(s), to {max(daily) if daily else "none"}')
|
|
94
|
+
b_file, b_ranks, a_file, a_ranks = aso.scan_around(aso.results_dir(ctx.project, path), 'results', args.since)
|
|
95
|
+
data = aso.impact(daily, args.since, args.days, b_ranks, a_ranks)
|
|
96
|
+
data['scans'] = {'before': b_file.name if b_file else None, 'after': a_file.name if a_file else None}
|
|
97
|
+
if not out.json:
|
|
98
|
+
for side in ('before', 'after'):
|
|
99
|
+
s = data[side]
|
|
100
|
+
out.line(f' {side:6} {s["days"]:>2} days visitors {s["visitors"]:>4} '
|
|
101
|
+
f'({s["visitors_per_day"]}/day) acquisitions {s["acquisitions"]:>3} '
|
|
102
|
+
f'conversion {s["conversion"]}%')
|
|
103
|
+
if not (b_file and a_file):
|
|
104
|
+
out.line(' rank: needs an `aso rank` scan before the date and one after it', 'yellow')
|
|
105
|
+
else:
|
|
106
|
+
out.line(f' rank: {data["ranked_before"]} -> {data["ranked_after"]} keyword(s) ranked '
|
|
107
|
+
f'({b_file.name} vs {a_file.name})')
|
|
108
|
+
for g in data['gained'][:10]:
|
|
109
|
+
out.line(f' + #{g["pos"]:<3} {g["gl"]} {g["kw"]}', 'green')
|
|
110
|
+
for x in data['lost'][:10]:
|
|
111
|
+
out.line(f' - was #{x["was"]:<3} {x["gl"]} {x["kw"]}', 'yellow')
|
|
112
|
+
out.finish('Play reports lag 3-7 days; small numbers swing, judge over two weeks or more')
|
|
113
|
+
return data
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@command('aso score','How beatable page one is for each keyword (weakest top-5 install count)',
|
|
117
|
+
risk='read', needs=('aso',), args=(Arg('workers', 'parallel requests', type=int, default=6),))
|
|
118
|
+
def aso_score(ctx, args):
|
|
119
|
+
items, path = aso.load_keywords(ctx.project)
|
|
120
|
+
out = ctx.out
|
|
121
|
+
out.start(f'{ctx.project.name} keyword winnability', 1, f'{len(items)} keywords')
|
|
122
|
+
out.step('Search Play and read top-5 installs')
|
|
123
|
+
results = aso.score(ctx.project.package, items, args.workers)
|
|
124
|
+
saved = aso.save(aso.results_dir(ctx.project, path), 'winnability', results)
|
|
125
|
+
out.done(f'saved {saved.name}')
|
|
126
|
+
if not out.json:
|
|
127
|
+
out.table([(r['gl'], r['kw'], f'{r["weakest"]:,}', f'#{r["pos"]}' if r['pos'] else '-')
|
|
128
|
+
for r in results[:30]], ('market', 'keyword', 'weakest top-5', 'us'))
|
|
129
|
+
out.finish(f'{sum(1 for r in results if r["weakest"] < 10_000)} keyword(s) with a top-5 app under 10k installs')
|
|
130
|
+
return {'file': str(saved), 'results': results}
|