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/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)