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/aso.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Search rank and keyword winnability, read from Play's public web pages.
|
|
2
|
+
|
|
3
|
+
There is no official API for search rank. This fetches the same search page
|
|
4
|
+
a visitor sees (play.google.com/store/search, per market) and finds the app's
|
|
5
|
+
position; it is a measurement, not a guarantee, and results vary by device and
|
|
6
|
+
account. Keep request volume modest.
|
|
7
|
+
|
|
8
|
+
Keywords file ([aso] keywords in mkit.toml), TOML:
|
|
9
|
+
|
|
10
|
+
[[market]]
|
|
11
|
+
lang = "en" # label only
|
|
12
|
+
hl = "en_US" # Play UI language
|
|
13
|
+
gl = "US" # country
|
|
14
|
+
keywords = ["shopping list", "grocery list"]
|
|
15
|
+
|
|
16
|
+
A JSON list of {"kw", "hl", "gl", "lang"} is accepted too. Each run is saved
|
|
17
|
+
as results-YYYYMMDD.json beside the keywords file (or [aso] results_dir), and
|
|
18
|
+
compared with the previous run.
|
|
19
|
+
"""
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
import re
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import tomllib
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
29
|
+
from datetime import date, timedelta
|
|
30
|
+
|
|
31
|
+
from . import output
|
|
32
|
+
from .errors import UsageError
|
|
33
|
+
|
|
34
|
+
UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
|
35
|
+
'(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36')
|
|
36
|
+
_DETAILS = re.compile(r'/store/apps/details\?id=([A-Za-z0-9_.]+)')
|
|
37
|
+
_NUM = {'K': 1e3, 'M': 1e6, 'B': 1e9}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_keywords(project):
|
|
41
|
+
path = project.path('aso.keywords')
|
|
42
|
+
if not path or not path.is_file():
|
|
43
|
+
raise UsageError(f'keywords file not found ({path or "set keywords under [aso]"})', code='NO_KEYWORDS')
|
|
44
|
+
if path.suffix == '.json':
|
|
45
|
+
items = json.loads(path.read_text(encoding='utf-8'))
|
|
46
|
+
return [(k['kw'], k['hl'], k['gl'], k.get('lang', k['hl'])) for k in items], path
|
|
47
|
+
with open(path, 'rb') as f:
|
|
48
|
+
data = tomllib.load(f)
|
|
49
|
+
items = []
|
|
50
|
+
for m in data.get('market', []):
|
|
51
|
+
for kw in m.get('keywords', []):
|
|
52
|
+
items.append((kw, m['hl'], m['gl'], m.get('lang', m['hl'])))
|
|
53
|
+
return items, path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_markets(project):
|
|
57
|
+
"""The keywords file's markets, for `aso research`: each with its seeds
|
|
58
|
+
(the words autocomplete is asked about; the tracked keywords when none are
|
|
59
|
+
given) and its topic (a found phrase must hold one of these words)."""
|
|
60
|
+
path = project.path('aso.keywords')
|
|
61
|
+
if not path or not path.is_file() or path.suffix != '.toml':
|
|
62
|
+
raise UsageError(f'keywords file not found or not TOML ({path or "set keywords under [aso]"})',
|
|
63
|
+
code='NO_KEYWORDS')
|
|
64
|
+
with open(path, 'rb') as f:
|
|
65
|
+
data = tomllib.load(f)
|
|
66
|
+
markets = []
|
|
67
|
+
for m in data.get('market', []):
|
|
68
|
+
seeds = m.get('seeds') or m.get('keywords', [])
|
|
69
|
+
topic = m.get('topic') or sorted({w for s in seeds for w in s.lower().split() if len(w) > 2})
|
|
70
|
+
markets.append({'lang': m.get('lang', m['hl']), 'hl': m['hl'], 'gl': m['gl'],
|
|
71
|
+
'seeds': list(seeds), 'topic': [t.lower() for t in topic],
|
|
72
|
+
'keywords': list(m.get('keywords', []))})
|
|
73
|
+
return markets, path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def results_dir(project, keywords_path):
|
|
77
|
+
return project.path('aso.results_dir') or keywords_path.parent
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fetch(url, lang='en-US'):
|
|
81
|
+
for attempt in range(3):
|
|
82
|
+
try:
|
|
83
|
+
req = urllib.request.Request(url, headers={'User-Agent': UA, 'Accept-Language': lang + ',en;q=0.5'})
|
|
84
|
+
return urllib.request.urlopen(req, timeout=45).read().decode('utf-8', 'ignore')
|
|
85
|
+
except Exception:
|
|
86
|
+
if attempt == 2:
|
|
87
|
+
return None
|
|
88
|
+
time.sleep(2 + attempt * 3)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _search(word, hl, gl):
|
|
92
|
+
url = f'https://play.google.com/store/search?q={urllib.parse.quote(word)}&c=apps&hl={hl}&gl={gl}'
|
|
93
|
+
html = _fetch(url, hl.replace('_', '-'))
|
|
94
|
+
if html is None:
|
|
95
|
+
return None
|
|
96
|
+
seen = []
|
|
97
|
+
for i in _DETAILS.findall(html):
|
|
98
|
+
if i not in seen:
|
|
99
|
+
seen.append(i)
|
|
100
|
+
return seen
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def rank(package, items, workers=6):
|
|
104
|
+
out = output.out
|
|
105
|
+
done = [0]
|
|
106
|
+
lock = threading.Lock()
|
|
107
|
+
|
|
108
|
+
def one(item):
|
|
109
|
+
word, hl, gl, lang = item
|
|
110
|
+
found = _search(word, hl, gl)
|
|
111
|
+
with lock:
|
|
112
|
+
done[0] += 1
|
|
113
|
+
out.upload(done[0] / len(items) * 100)
|
|
114
|
+
if found is None:
|
|
115
|
+
return {'kw': word, 'lang': lang, 'gl': gl, 'pos': None, 'total': 0, 'error': 'fetch failed'}
|
|
116
|
+
return {'kw': word, 'lang': lang, 'gl': gl, 'pos': found.index(package) + 1 if package in found else None,
|
|
117
|
+
'total': len(found)}
|
|
118
|
+
|
|
119
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
120
|
+
return list(ex.map(one, items))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _to_int(s):
|
|
124
|
+
s = s.replace('+', '').replace(',', '').replace('.', '').strip()
|
|
125
|
+
if s and s[-1] in _NUM:
|
|
126
|
+
return int(float(s[:-1]) * _NUM[s[-1]])
|
|
127
|
+
try:
|
|
128
|
+
return int(s)
|
|
129
|
+
except ValueError:
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def score(package, items, workers=6):
|
|
134
|
+
"""For each keyword, how beatable page one is: the install count of the
|
|
135
|
+
weakest of the top five apps. Under ~10k installs, a new app can get in."""
|
|
136
|
+
cache, lock = {}, threading.Lock()
|
|
137
|
+
|
|
138
|
+
def downloads(pkg):
|
|
139
|
+
with lock:
|
|
140
|
+
if pkg in cache:
|
|
141
|
+
return cache[pkg]
|
|
142
|
+
html = _fetch(f'https://play.google.com/store/apps/details?id={pkg}&hl=en_US&gl=US') or ''
|
|
143
|
+
m = re.search(r'<div class="ClM7O">([0-9.,]+[KMB]?\+?)</div><div class="g1rdde">Downloads', html)
|
|
144
|
+
t = re.search(r'<h1[^>]*><span[^>]*>(.*?)</span>', html)
|
|
145
|
+
info = {'installs': _to_int(m.group(1)) if m else 0, 'label': m.group(1) if m else '?',
|
|
146
|
+
'title': re.sub('<[^>]+>', '', t.group(1)) if t else pkg}
|
|
147
|
+
with lock:
|
|
148
|
+
cache[pkg] = info
|
|
149
|
+
return info
|
|
150
|
+
|
|
151
|
+
def one(item):
|
|
152
|
+
word, hl, gl, lang = item
|
|
153
|
+
found = _search(word, hl, gl) or []
|
|
154
|
+
top = [dict(downloads(p), package=p) for p in found[:5]]
|
|
155
|
+
counts = [t['installs'] for t in top]
|
|
156
|
+
return {'kw': word, 'lang': lang, 'gl': gl,
|
|
157
|
+
'pos': found.index(package) + 1 if package in found else None,
|
|
158
|
+
'weakest': min(counts) if counts else 0, 'strongest': max(counts) if counts else 0,
|
|
159
|
+
'top5': [{'title': t['title'][:40], 'installs': t['label']} for t in top]}
|
|
160
|
+
|
|
161
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
162
|
+
results = list(ex.map(one, items))
|
|
163
|
+
return sorted(results, key=lambda r: r['weakest'])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------ research
|
|
167
|
+
|
|
168
|
+
_pace_lock = threading.Lock()
|
|
169
|
+
_last_request = [0.0]
|
|
170
|
+
PACE = 0.35 # seconds between requests: research makes thousands
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _paced(fn, *a):
|
|
174
|
+
with _pace_lock:
|
|
175
|
+
wait = PACE - (time.time() - _last_request[0])
|
|
176
|
+
if wait > 0:
|
|
177
|
+
time.sleep(wait)
|
|
178
|
+
_last_request[0] = time.time()
|
|
179
|
+
return fn(*a)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _post(url, body, lang):
|
|
183
|
+
for attempt in range(3):
|
|
184
|
+
try:
|
|
185
|
+
req = urllib.request.Request(url, data=body, headers={
|
|
186
|
+
'User-Agent': UA, 'Accept-Language': lang + ',en;q=0.5',
|
|
187
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'})
|
|
188
|
+
return urllib.request.urlopen(req, timeout=45).read().decode('utf-8', 'ignore')
|
|
189
|
+
except Exception:
|
|
190
|
+
if attempt == 2:
|
|
191
|
+
return None
|
|
192
|
+
time.sleep(2 + attempt * 3)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def suggest(term, hl, gl):
|
|
196
|
+
"""Play's own search autocomplete for `term`: the phrases people type,
|
|
197
|
+
most typed first. The same call the Play website makes as you type."""
|
|
198
|
+
inner = json.dumps([[None, [term], [10], [2], 4]])
|
|
199
|
+
body = urllib.parse.urlencode({'f.req': json.dumps([[['IJ4APc', inner, None, 'generic']]])}).encode()
|
|
200
|
+
raw = _paced(_post, f'https://play.google.com/_/PlayStoreUi/data/batchexecute?rpcids=IJ4APc'
|
|
201
|
+
f'&hl={hl}&gl={gl}&authuser&soc-app=121&soc-platform=1&soc-device=1',
|
|
202
|
+
body, hl.replace('_', '-'))
|
|
203
|
+
try:
|
|
204
|
+
data = json.loads(raw[raw.index('['):])
|
|
205
|
+
payload = json.loads(data[0][2])
|
|
206
|
+
return [s[0].lower() for s in payload[0][0]] if payload and payload[0] else []
|
|
207
|
+
except (TypeError, ValueError, IndexError):
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def demand(phrase, hl, gl, suggest_fn=None):
|
|
212
|
+
"""How much people type `phrase`: 1.0 when autocomplete offers it after
|
|
213
|
+
two letters, falling towards 0 the more letters it takes, 0 when never
|
|
214
|
+
offered. Returns (demand, letters needed or None)."""
|
|
215
|
+
suggest_fn = suggest_fn or suggest
|
|
216
|
+
n = len(phrase)
|
|
217
|
+
for k in list(range(2, n, 2)) + [n]:
|
|
218
|
+
if phrase in suggest_fn(phrase[:k], hl, gl):
|
|
219
|
+
return round(1 - (k - 2) / max(n - 1, 1), 2), k
|
|
220
|
+
return 0.0, None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def opportunity(demand_value, median_installs, in_title):
|
|
224
|
+
"""Demand a new app can reach: discounted by how big page one is (median
|
|
225
|
+
of the top five's installs, on a log scale) and by how many of those five
|
|
226
|
+
carry the phrase in their title. 0 to 100."""
|
|
227
|
+
ease = 1 / math.log10(median_installs + 10)
|
|
228
|
+
return round(demand_value * ease * (1 - 0.12 * in_title) * 100, 1)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def research(package, markets, workers=3, *, suggest_fn=None, search_fn=None, details_fn=None,
|
|
232
|
+
progress=None):
|
|
233
|
+
"""Finds phrases through each market's seeds and scores every one.
|
|
234
|
+
|
|
235
|
+
A seed is asked about alone and with each letter after it ("grocery list
|
|
236
|
+
a", "grocery list b", ...), which is how autocomplete gives up its long
|
|
237
|
+
tail. Phrases of one word, or without a topic word, are dropped. The
|
|
238
|
+
tracked keywords are always scored, found or not."""
|
|
239
|
+
suggest_fn, search_fn = suggest_fn or suggest, search_fn or (lambda w, hl, gl: _paced(_search, w, hl, gl))
|
|
240
|
+
cache, lock = {}, threading.Lock()
|
|
241
|
+
|
|
242
|
+
def details(pkg):
|
|
243
|
+
with lock:
|
|
244
|
+
if pkg in cache:
|
|
245
|
+
return cache[pkg]
|
|
246
|
+
info = details_fn(pkg) if details_fn else _details(pkg)
|
|
247
|
+
with lock:
|
|
248
|
+
cache[pkg] = info
|
|
249
|
+
return info
|
|
250
|
+
|
|
251
|
+
results = []
|
|
252
|
+
for m in markets:
|
|
253
|
+
hl, gl = m['hl'], m['gl']
|
|
254
|
+
found = set(k.lower() for k in m['keywords'])
|
|
255
|
+
for seed in m['seeds']:
|
|
256
|
+
tails = [seed] + ([f'{seed} {c}' for c in 'abcdefghijklmnopqrstuvwxyz'] if seed.isascii() else [])
|
|
257
|
+
for q in tails:
|
|
258
|
+
for s in suggest_fn(q, hl, gl):
|
|
259
|
+
if len(s.split()) >= 2 and any(t in s for t in m['topic']):
|
|
260
|
+
found.add(s)
|
|
261
|
+
phrases = sorted(found)
|
|
262
|
+
done = [0]
|
|
263
|
+
|
|
264
|
+
def one(phrase, m=m, phrases=phrases, done=done):
|
|
265
|
+
d, letters = demand(phrase, m['hl'], m['gl'], suggest_fn)
|
|
266
|
+
apps = search_fn(phrase, m['hl'], m['gl']) or []
|
|
267
|
+
top = [dict(details(p), package=p) for p in apps[:5]]
|
|
268
|
+
words = [w for w in phrase.split() if len(w) > 2]
|
|
269
|
+
in_title = sum(1 for t in top if all(w in t['title'].lower() for w in words))
|
|
270
|
+
counts = sorted(t['installs'] for t in top) or [0]
|
|
271
|
+
with lock:
|
|
272
|
+
done[0] += 1
|
|
273
|
+
if progress:
|
|
274
|
+
progress(done[0], len(phrases), f'{m["gl"]} {phrase}')
|
|
275
|
+
return {'kw': phrase, 'lang': m['lang'], 'gl': m['gl'], 'demand': d, 'letters': letters,
|
|
276
|
+
'median_top5': counts[len(counts) // 2], 'weakest_top5': counts[0],
|
|
277
|
+
'in_title': in_title, 'pos': apps.index(package) + 1 if package in apps else None,
|
|
278
|
+
'tracked': phrase in {k.lower() for k in m['keywords']},
|
|
279
|
+
'score': opportunity(d, counts[len(counts) // 2], in_title),
|
|
280
|
+
'top5': [{'title': t['title'][:40], 'installs': t['label']} for t in top]}
|
|
281
|
+
|
|
282
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
283
|
+
results += sorted(ex.map(one, phrases), key=lambda r: -r['score'])
|
|
284
|
+
return results
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _details(pkg):
|
|
288
|
+
html = _paced(_fetch, f'https://play.google.com/store/apps/details?id={pkg}&hl=en_US&gl=US') or ''
|
|
289
|
+
m = re.search(r'<div class="ClM7O">([0-9.,]+[KMB]?\+?)</div><div class="g1rdde">Downloads', html)
|
|
290
|
+
t = re.search(r'<h1[^>]*><span[^>]*>(.*?)</span>', html)
|
|
291
|
+
return {'installs': _to_int(m.group(1)) if m else 0, 'label': m.group(1) if m else '?',
|
|
292
|
+
'title': re.sub('<[^>]+>', '', t.group(1)) if t else pkg}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------------------ impact
|
|
296
|
+
|
|
297
|
+
def impact(daily, since, days, before_ranks, after_ranks):
|
|
298
|
+
"""What a listing change did: store visitors and acquisitions per day in
|
|
299
|
+
the `days` before `since` against the `days` from it, and the keywords
|
|
300
|
+
gained and lost between the rank scans either side.
|
|
301
|
+
|
|
302
|
+
daily: {date 'YYYY-MM-DD': (visitors, acquisitions)}; a day with no row
|
|
303
|
+
counts as zero. *_ranks: {(kw, gl): position or None}."""
|
|
304
|
+
start = date.fromisoformat(since)
|
|
305
|
+
last = max((date.fromisoformat(d) for d in daily), default=start)
|
|
306
|
+
|
|
307
|
+
def window(first, count):
|
|
308
|
+
dates = [first + timedelta(days=i) for i in range(count)]
|
|
309
|
+
dates = [d for d in dates if d <= last]
|
|
310
|
+
v = sum(daily.get(d.isoformat(), (0, 0))[0] for d in dates)
|
|
311
|
+
a = sum(daily.get(d.isoformat(), (0, 0))[1] for d in dates)
|
|
312
|
+
n = len(dates)
|
|
313
|
+
return {'days': n, 'visitors': v, 'acquisitions': a,
|
|
314
|
+
'visitors_per_day': round(v / n, 1) if n else None,
|
|
315
|
+
'conversion': round(a / v * 100, 1) if v else None}
|
|
316
|
+
|
|
317
|
+
before = window(start - timedelta(days=days), days)
|
|
318
|
+
after = window(start, days)
|
|
319
|
+
keys = set(before_ranks) & set(after_ranks)
|
|
320
|
+
gained = sorted((k for k in keys if after_ranks[k] and not before_ranks[k]), key=lambda k: after_ranks[k])
|
|
321
|
+
lost = sorted(k for k in keys if before_ranks[k] and not after_ranks[k])
|
|
322
|
+
moved = sorted(((k, before_ranks[k], after_ranks[k]) for k in keys
|
|
323
|
+
if before_ranks[k] and after_ranks[k] and before_ranks[k] != after_ranks[k]),
|
|
324
|
+
key=lambda x: x[2] - x[1])
|
|
325
|
+
return {'since': since, 'before': before, 'after': after,
|
|
326
|
+
'ranked_before': sum(1 for k in keys if before_ranks[k]),
|
|
327
|
+
'ranked_after': sum(1 for k in keys if after_ranks[k]),
|
|
328
|
+
'gained': [{'kw': k[0], 'gl': k[1], 'pos': after_ranks[k]} for k in gained],
|
|
329
|
+
'lost': [{'kw': k[0], 'gl': k[1], 'was': before_ranks[k]} for k in lost],
|
|
330
|
+
'moved': [{'kw': k[0], 'gl': k[1], 'from': a, 'to': b} for k, a, b in moved]}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def scan_around(folder, prefix, since):
|
|
334
|
+
"""The last rank scan before `since` and the latest one from it on."""
|
|
335
|
+
scans = sorted(folder.glob(f'{prefix}-*.json'))
|
|
336
|
+
tag = since.replace('-', '')
|
|
337
|
+
before = [p for p in scans if p.stem.split('-')[-1] < tag]
|
|
338
|
+
after = [p for p in scans if p.stem.split('-')[-1] >= tag]
|
|
339
|
+
|
|
340
|
+
def ranks(p):
|
|
341
|
+
return {(r['kw'], r['gl']): r.get('pos') for r in json.loads(p.read_text(encoding='utf-8'))}
|
|
342
|
+
|
|
343
|
+
return (before[-1] if before else None, ranks(before[-1]) if before else {},
|
|
344
|
+
after[-1] if after else None, ranks(after[-1]) if after else {})
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def save(folder, prefix, results):
|
|
348
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
path = folder / f'{prefix}-{date.today():%Y%m%d}.json'
|
|
350
|
+
path.write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding='utf-8')
|
|
351
|
+
return path
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def previous(folder, prefix, current):
|
|
355
|
+
older = sorted(p for p in folder.glob(f'{prefix}-*.json') if p != current)
|
|
356
|
+
if not older:
|
|
357
|
+
return None, {}
|
|
358
|
+
data = json.loads(older[-1].read_text(encoding='utf-8'))
|
|
359
|
+
return older[-1], {(r['kw'], r['gl']): r.get('pos') for r in data}
|
mkit/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Entry point: parse, load the project, run one command, report, exit.
|
|
2
|
+
|
|
3
|
+
Global flags work before or after the command name:
|
|
4
|
+
|
|
5
|
+
--json / --human force the output mode (default: human at a terminal,
|
|
6
|
+
JSON anywhere else)
|
|
7
|
+
--project PATH the app folder, instead of searching up from here
|
|
8
|
+
--yes --confirm PKG approve a publish-class command without a prompt
|
|
9
|
+
--dry-run do everything except the change itself
|
|
10
|
+
--strict a skipped pipeline step is a failure, not a warning
|
|
11
|
+
--no-color
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
import traceback
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from . import __version__, config, output
|
|
19
|
+
from .errors import FAILED, INTERRUPTED, MkitError, UsageError
|
|
20
|
+
from .registry import GROUPS, REGISTRY
|
|
21
|
+
from .secrets import Secrets
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Context:
|
|
26
|
+
out: object
|
|
27
|
+
project: object = None
|
|
28
|
+
secrets: Secrets = None
|
|
29
|
+
command: str = ''
|
|
30
|
+
yes: bool = False
|
|
31
|
+
confirm: str | None = None
|
|
32
|
+
dry_run: bool = False
|
|
33
|
+
strict: bool = False
|
|
34
|
+
exit_code: int = 0
|
|
35
|
+
extra: dict = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
def require(self, feature):
|
|
38
|
+
"""For a command asked for directly: the feature must be switched on."""
|
|
39
|
+
if not self.project.has(feature):
|
|
40
|
+
raise UsageError(f'[{feature}] is not in {self.project.root / config.PROJECT_FILE}',
|
|
41
|
+
hint=f'Add a [{feature}] table to switch it on for this app. '
|
|
42
|
+
'`mkit doctor` lists every feature.',
|
|
43
|
+
code='FEATURE_OFF')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _global_flags(parser, suppress):
|
|
47
|
+
default = argparse.SUPPRESS if suppress else None
|
|
48
|
+
flag_default = argparse.SUPPRESS if suppress else False
|
|
49
|
+
g = parser.add_argument_group('global options')
|
|
50
|
+
g.add_argument('--json', dest='output_mode', action='store_const', const='json',
|
|
51
|
+
default=default, help='JSON result on stdout, JSONL events on stderr')
|
|
52
|
+
g.add_argument('--human', dest='output_mode', action='store_const', const='human',
|
|
53
|
+
default=default, help='human output even when not at a terminal')
|
|
54
|
+
g.add_argument('--project', default=default, metavar='PATH',
|
|
55
|
+
help='app folder holding mkit.toml (default: search up from here)')
|
|
56
|
+
g.add_argument('--yes', action='store_true', default=flag_default,
|
|
57
|
+
help='approve a publish without a prompt (needs --confirm)')
|
|
58
|
+
g.add_argument('--confirm', default=default, metavar='PACKAGE',
|
|
59
|
+
help='the package id being changed, required with --yes')
|
|
60
|
+
g.add_argument('--dry-run', action='store_true', default=flag_default,
|
|
61
|
+
help='do everything except the change itself')
|
|
62
|
+
g.add_argument('--strict', action='store_true', default=flag_default,
|
|
63
|
+
help='treat a skipped pipeline step as a failure')
|
|
64
|
+
g.add_argument('--no-color', action='store_true', default=flag_default)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _add_args(parser, cmd):
|
|
68
|
+
for a in cmd.args:
|
|
69
|
+
if a.kind == 'positional':
|
|
70
|
+
kwargs = {'help': a.help, 'type': a.type}
|
|
71
|
+
if a.nargs:
|
|
72
|
+
kwargs['nargs'] = a.nargs
|
|
73
|
+
if a.choices:
|
|
74
|
+
kwargs['choices'] = a.choices
|
|
75
|
+
if a.default is not None:
|
|
76
|
+
kwargs['default'] = a.default
|
|
77
|
+
parser.add_argument(a.name, **kwargs)
|
|
78
|
+
continue
|
|
79
|
+
flags = [] if a.short and not a.long else [f'--{a.name.replace("_", "-")}']
|
|
80
|
+
if a.short:
|
|
81
|
+
flags.insert(0, f'-{a.short}')
|
|
82
|
+
if a.kind == 'flag':
|
|
83
|
+
parser.add_argument(*flags, dest=a.name, help=a.help,
|
|
84
|
+
action='count' if a.repeat else 'store_true',
|
|
85
|
+
default=0 if a.repeat else False)
|
|
86
|
+
else:
|
|
87
|
+
kwargs = {'dest': a.name, 'help': a.help, 'type': a.type, 'default': a.default}
|
|
88
|
+
if a.choices:
|
|
89
|
+
kwargs['choices'] = a.choices
|
|
90
|
+
if a.nargs:
|
|
91
|
+
kwargs['nargs'] = a.nargs
|
|
92
|
+
parser.add_argument(*flags, **kwargs)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_parser():
|
|
96
|
+
from . import commands # noqa: F401 (registers every command)
|
|
97
|
+
|
|
98
|
+
parser = argparse.ArgumentParser(
|
|
99
|
+
prog='mkit', description='Android release, Play Console and Firebase operations.',
|
|
100
|
+
epilog='`mkit commands` lists everything; `mkit <command> -h` explains one.')
|
|
101
|
+
parser.add_argument('--version', action='version', version=f'mkit {__version__}')
|
|
102
|
+
_global_flags(parser, suppress=False)
|
|
103
|
+
sub = parser.add_subparsers(dest='_cmd', metavar='<command>')
|
|
104
|
+
|
|
105
|
+
group_parsers = {}
|
|
106
|
+
for name in sorted(REGISTRY):
|
|
107
|
+
cmd = REGISTRY[name]
|
|
108
|
+
parts = name.split(' ')
|
|
109
|
+
target = sub
|
|
110
|
+
if len(parts) == 2:
|
|
111
|
+
head = parts[0]
|
|
112
|
+
if head not in group_parsers:
|
|
113
|
+
gp = sub.add_parser(head, help=GROUPS.get(head, ''))
|
|
114
|
+
group_parsers[head] = gp.add_subparsers(dest='_sub', metavar='<action>')
|
|
115
|
+
target = group_parsers[head]
|
|
116
|
+
p = target.add_parser(parts[-1], help=cmd.summary, description=cmd.summary,
|
|
117
|
+
epilog='examples:\n ' + '\n '.join(cmd.examples) if cmd.examples else None,
|
|
118
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
119
|
+
_global_flags(p, suppress=True)
|
|
120
|
+
_add_args(p, cmd)
|
|
121
|
+
p.set_defaults(_command=name)
|
|
122
|
+
return parser
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv=None):
|
|
126
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
127
|
+
parser = build_parser()
|
|
128
|
+
args = parser.parse_args(argv)
|
|
129
|
+
|
|
130
|
+
out = output.configure(mode=args.output_mode, color=not args.no_color)
|
|
131
|
+
name = getattr(args, '_command', None)
|
|
132
|
+
if name is None:
|
|
133
|
+
if getattr(args, '_cmd', None):
|
|
134
|
+
# A group given without an action, e.g. `mkit listing`.
|
|
135
|
+
parser.parse_args([args._cmd, '-h'])
|
|
136
|
+
parser.print_help()
|
|
137
|
+
return 2
|
|
138
|
+
|
|
139
|
+
cmd = REGISTRY[name]
|
|
140
|
+
ctx = Context(out=out, command=name, yes=args.yes, confirm=args.confirm,
|
|
141
|
+
dry_run=args.dry_run, strict=args.strict)
|
|
142
|
+
try:
|
|
143
|
+
if cmd.needs_project or args.project:
|
|
144
|
+
ctx.project = config.load(project=args.project)
|
|
145
|
+
else:
|
|
146
|
+
try:
|
|
147
|
+
ctx.project = config.load()
|
|
148
|
+
except MkitError:
|
|
149
|
+
ctx.project = None
|
|
150
|
+
ctx.secrets = Secrets(ctx.project.root if ctx.project else None)
|
|
151
|
+
for feature in cmd.needs:
|
|
152
|
+
ctx.require(feature)
|
|
153
|
+
data = cmd.handler(ctx, args)
|
|
154
|
+
out.result(name, data if data is not None else {}, ctx.exit_code)
|
|
155
|
+
return ctx.exit_code
|
|
156
|
+
except MkitError as e:
|
|
157
|
+
out.failure(name, e, e.exit_code)
|
|
158
|
+
return e.exit_code
|
|
159
|
+
except KeyboardInterrupt:
|
|
160
|
+
err = MkitError('interrupted', code='INTERRUPTED')
|
|
161
|
+
out.failure(name, err, INTERRUPTED)
|
|
162
|
+
return INTERRUPTED
|
|
163
|
+
except Exception as e: # a bug in mkit, not an operational outcome
|
|
164
|
+
err = MkitError(f'internal error: {type(e).__name__}: {e}', code='INTERNAL',
|
|
165
|
+
hint='This is a bug in mkit. The traceback is on stderr.')
|
|
166
|
+
traceback.print_exc(file=sys.stderr)
|
|
167
|
+
out.failure(name, err, FAILED)
|
|
168
|
+
return FAILED
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def run():
|
|
172
|
+
sys.exit(main())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == '__main__':
|
|
176
|
+
run()
|
mkit/cloud/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Google APIs: Play (Android Publisher, Reporting, reports bucket) and Firebase."""
|
mkit/cloud/auth.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Service-account clients, retries, and Google errors turned into MkitErrors.
|
|
2
|
+
|
|
3
|
+
Two credentials, from secrets.py:
|
|
4
|
+
|
|
5
|
+
PLAY_SERVICE_ACCOUNT Android Publisher, Play Developer Reporting, and
|
|
6
|
+
the Play reports bucket. One per developer
|
|
7
|
+
account, so it normally lives in ~/.mkit/.env.
|
|
8
|
+
FIREBASE_SERVICE_ACCOUNT This app's Firebase / Cloud project: Firestore,
|
|
9
|
+
rules, Remote Config, Firebase Management.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import socket
|
|
13
|
+
import ssl
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from .. import output
|
|
17
|
+
from ..errors import Conflict, CredentialMissing, MkitError, NotFound
|
|
18
|
+
|
|
19
|
+
PLAY = 'PLAY_SERVICE_ACCOUNT'
|
|
20
|
+
FIREBASE = 'FIREBASE_SERVICE_ACCOUNT'
|
|
21
|
+
|
|
22
|
+
SCOPE_PUBLISHER = 'https://www.googleapis.com/auth/androidpublisher'
|
|
23
|
+
SCOPE_REPORTING = 'https://www.googleapis.com/auth/playdeveloperreporting'
|
|
24
|
+
SCOPE_STORAGE_READ = 'https://www.googleapis.com/auth/devstorage.read_only'
|
|
25
|
+
SCOPE_CLOUD = 'https://www.googleapis.com/auth/cloud-platform'
|
|
26
|
+
SCOPE_FIREBASE = 'https://www.googleapis.com/auth/firebase'
|
|
27
|
+
SCOPE_DATASTORE = 'https://www.googleapis.com/auth/datastore'
|
|
28
|
+
SCOPE_REMOTE_CONFIG = 'https://www.googleapis.com/auth/firebase.remoteconfig'
|
|
29
|
+
|
|
30
|
+
# A bundle is large and uploads from a home connection; httplib2's default
|
|
31
|
+
# socket timeout kills a release on one slow chunk.
|
|
32
|
+
UPLOAD_TIMEOUT = 600
|
|
33
|
+
|
|
34
|
+
TRANSIENT = (TimeoutError, socket.timeout, ssl.SSLError, ConnectionError)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def credentials(ctx, secret, scopes):
|
|
38
|
+
from google.oauth2 import service_account
|
|
39
|
+
info = ctx.secrets.json(secret)
|
|
40
|
+
return service_account.Credentials.from_service_account_info(info, scopes=list(scopes))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def service_account_project(ctx, secret=FIREBASE):
|
|
44
|
+
project = ctx.secrets.json(secret).get('project_id')
|
|
45
|
+
if not project:
|
|
46
|
+
raise CredentialMissing(f'{secret} names no project_id', code='BAD_CREDENTIAL')
|
|
47
|
+
return project
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def token(ctx, secret, scopes):
|
|
51
|
+
import google.auth.transport.requests
|
|
52
|
+
creds = credentials(ctx, secret, scopes)
|
|
53
|
+
creds.refresh(google.auth.transport.requests.Request())
|
|
54
|
+
return creds.token
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build(ctx, api, version, secret, scopes, timeout=120):
|
|
58
|
+
"""A discovery client. static_discovery=False reads the live discovery
|
|
59
|
+
document, so methods Google added after this library was released (the
|
|
60
|
+
monetization and changesInReviewBehavior additions, for example) exist."""
|
|
61
|
+
import google_auth_httplib2
|
|
62
|
+
import httplib2
|
|
63
|
+
from googleapiclient.discovery import build as _build
|
|
64
|
+
|
|
65
|
+
creds = credentials(ctx, secret, scopes)
|
|
66
|
+
transport = httplib2.Http(timeout=timeout)
|
|
67
|
+
# A resumable upload's "308 Resume Incomplete" is progress, not a redirect.
|
|
68
|
+
transport.redirect_codes = set(range(300, 400)) - {308}
|
|
69
|
+
http = google_auth_httplib2.AuthorizedHttp(creds, http=transport)
|
|
70
|
+
return _build(api, version, http=http, cache_discovery=False, static_discovery=False)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def play(ctx, timeout=None):
|
|
74
|
+
upload_timeout = int(ctx.project.get('play.upload_timeout', UPLOAD_TIMEOUT))
|
|
75
|
+
return build(ctx, 'androidpublisher', 'v3', PLAY, [SCOPE_PUBLISHER],
|
|
76
|
+
timeout=timeout or upload_timeout)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def status_of(error):
|
|
80
|
+
return getattr(getattr(error, 'resp', None), 'status', 0) or 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def api_message(error):
|
|
84
|
+
"""The sentence out of a Google API error, not googleapiclient's repr of
|
|
85
|
+
the whole request. Play's rejections are one actionable line."""
|
|
86
|
+
try:
|
|
87
|
+
return json.loads(error.content.decode('utf-8', 'ignore'))['error']['message']
|
|
88
|
+
except Exception:
|
|
89
|
+
return str(error)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def translate(error, what='Google API'):
|
|
93
|
+
"""An HttpError as the MkitError a caller can branch on."""
|
|
94
|
+
from googleapiclient.errors import HttpError
|
|
95
|
+
if not isinstance(error, HttpError):
|
|
96
|
+
return error
|
|
97
|
+
status, message = status_of(error), api_message(error)
|
|
98
|
+
if status in (401, 403):
|
|
99
|
+
return CredentialMissing(
|
|
100
|
+
f'{what} refused the service account ({status}): {message}',
|
|
101
|
+
hint='In Play Console > Users and permissions (or the Cloud/Firebase IAM page), give '
|
|
102
|
+
"the service account's email access to this app, then retry.",
|
|
103
|
+
code='PERMISSION_DENIED')
|
|
104
|
+
if status == 404:
|
|
105
|
+
return NotFound(f'{what}: {message}', code='NOT_FOUND')
|
|
106
|
+
if status == 409 or 'expired' in message.lower() or 'in review' in message.lower():
|
|
107
|
+
return Conflict(f'{what}: {message}', retryable=True)
|
|
108
|
+
return MkitError(f'{what} rejected the request ({status}): {message}',
|
|
109
|
+
code=f'HTTP_{status}', retryable=status >= 500)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def retry(call, what, tries=5):
|
|
113
|
+
"""Run one API call, retrying 5xx and dropped connections with backoff.
|
|
114
|
+
Anything below 500 is Google telling us something real and is raised at
|
|
115
|
+
once. `call` is a zero-arg function returning an executable request."""
|
|
116
|
+
from googleapiclient.errors import HttpError
|
|
117
|
+
for attempt in range(tries):
|
|
118
|
+
try:
|
|
119
|
+
return call().execute()
|
|
120
|
+
except HttpError as error:
|
|
121
|
+
if status_of(error) < 500 or attempt == tries - 1:
|
|
122
|
+
raise
|
|
123
|
+
reason = f'HTTP {status_of(error)}'
|
|
124
|
+
except TRANSIENT as error:
|
|
125
|
+
if attempt == tries - 1:
|
|
126
|
+
raise
|
|
127
|
+
reason = type(error).__name__
|
|
128
|
+
wait = 2 ** attempt
|
|
129
|
+
output.out.warn(f'{what}: {reason}, retrying in {wait}s ({attempt + 1}/{tries - 1})')
|
|
130
|
+
time.sleep(wait)
|