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/datasafety.py ADDED
@@ -0,0 +1,302 @@
1
+ """Build Play's data safety CSV from a few lines of `mkit.toml`.
2
+
3
+ Play's Data safety form is 782 questions. The API takes the same CSV the
4
+ Console's import accepts, and there is no API that hands the current answers
5
+ back - `applications.dataSafety` is POST only - so the declaration has to be
6
+ kept in the repo, next to the code it describes.
7
+
8
+ Every app was doing that by hand: one wrote a script to fill Google's sample
9
+ CSV, two others carry a byte-identical copy of the result. That is the
10
+ duplication the engine exists to remove, so the question set ships here
11
+ (`data/datasafety-questions.csv`, answers stripped) and an app declares only
12
+ what it collects:
13
+
14
+ [datasafety]
15
+ collects = true
16
+ encrypted_in_transit = true
17
+ deletion = "no"
18
+ account_creation = "none"
19
+
20
+ [datasafety.collected]
21
+ crash_logs = ["app_functionality", "analytics"]
22
+ diagnostics = ["app_functionality", "analytics"]
23
+ app_interactions = ["analytics"]
24
+ device_ids = ["app_functionality", "analytics"]
25
+
26
+ `mkit datasafety build` turns that into the CSV at the `datasafety` path under
27
+ `[store]`; `check` validates it and `apply` uploads it. A data type left out of
28
+ [datasafety.collected] is declared as not collected, which is the whole point:
29
+ the file says what the app does, and nothing is true by omission.
30
+
31
+ **This is a declaration, not a description.** Whatever is written here is what
32
+ Play publishes on the store page and holds the developer to, so it follows the
33
+ code: an SDK added is a line added.
34
+ """
35
+ import csv
36
+ import io
37
+ from pathlib import Path
38
+
39
+ from .errors import UsageError
40
+
41
+ QUESTIONS = Path(__file__).parent / 'data' / 'datasafety-questions.csv'
42
+
43
+ COLUMNS = (
44
+ 'Question ID (machine readable)',
45
+ 'Response ID (machine readable)',
46
+ 'Response value',
47
+ 'Answer requirement',
48
+ 'Human-friendly question label',
49
+ )
50
+
51
+ # The questions that are answered once for the whole app rather than per data type.
52
+ COLLECTS = 'PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA'
53
+ ENCRYPTED = 'PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT'
54
+ ACCOUNT_CREATION = 'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS'
55
+ DELETION = 'PSL_SUPPORT_DATA_DELETION_BY_USER'
56
+ OUTSIDE_ACCOUNTS = 'PSL_HAS_OUTSIDE_APP_ACCOUNTS'
57
+ DELETION_URL = 'PSL_DATA_DELETION_URL'
58
+ ACCOUNT_DELETION_URL = 'PSL_ACCOUNT_DELETION_URL'
59
+ USAGE = 'PSL_DATA_USAGE_RESPONSES'
60
+
61
+ # How a purpose is spelled in mkit.toml, and the response id it answers.
62
+ PURPOSES = {
63
+ 'app_functionality': 'PSL_APP_FUNCTIONALITY',
64
+ 'analytics': 'PSL_ANALYTICS',
65
+ 'developer_communications': 'PSL_DEVELOPER_COMMUNICATIONS',
66
+ 'fraud_prevention': 'PSL_FRAUD_PREVENTION_SECURITY',
67
+ 'advertising': 'PSL_ADVERTISING',
68
+ 'personalization': 'PSL_PERSONALIZATION',
69
+ 'account_management': 'PSL_ACCOUNT_MANAGEMENT',
70
+ }
71
+
72
+ ACCOUNT_METHODS = {
73
+ 'none': 'PSL_ACM_NONE',
74
+ 'password': 'PSL_ACM_USER_ID_PASSWORD',
75
+ 'other_auth': 'PSL_ACM_USER_ID_OTHER_AUTH',
76
+ 'password_other_auth': 'PSL_ACM_USER_ID_PASSWORD_OTHER_AUTH',
77
+ 'oauth': 'PSL_ACM_OAUTH',
78
+ 'other': 'PSL_ACM_OTHER',
79
+ }
80
+
81
+ DELETION_CHOICES = {
82
+ 'yes': 'DATA_DELETION_YES',
83
+ 'no': 'DATA_DELETION_NO',
84
+ 'auto': 'DATA_DELETION_NO_AUTO_DELETED',
85
+ }
86
+
87
+ # Short spellings for types whose own label is a mouthful or repeats itself.
88
+ # Every type also answers to the name derived from its label, and to its raw
89
+ # PSL id, so nobody has to guess which of the three was meant.
90
+ ALIASES = {
91
+ 'device_ids': 'PSL_DEVICE_ID',
92
+ 'diagnostics': 'PSL_PERFORMANCE_DIAGNOSTICS',
93
+ 'app_interactions': 'PSL_USER_INTERACTION',
94
+ 'user_ids': 'PSL_USER_ACCOUNT',
95
+ 'installed_apps': 'PSL_APPS_ON_DEVICE',
96
+ 'payment_info': 'PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER',
97
+ 'user_generated_content': 'PSL_USER_GENERATED_CONTENT',
98
+ }
99
+
100
+
101
+ def _slug(text):
102
+ """A label's last segment as a mkit.toml name: 'App activity / App interactions'."""
103
+ tail = (text or '').replace('\n', ' / ').split(' / ')[-1].strip().lower()
104
+ return ''.join(c if c.isalnum() else '_' for c in tail).strip('_')
105
+
106
+
107
+ def question_rows():
108
+ """The shipped question set, as dicts. Read once per call; it is 782 rows."""
109
+ text = QUESTIONS.read_text(encoding='utf-8-sig')
110
+ return list(csv.DictReader(io.StringIO(text)))
111
+
112
+
113
+ def data_types(rows=None):
114
+ """Every data type: its PSL id, the names it answers to, and its label."""
115
+ types = {}
116
+ for row in rows or question_rows():
117
+ if not row[COLUMNS[0]].startswith('PSL_DATA_TYPES_'):
118
+ continue
119
+ psl = row[COLUMNS[1]]
120
+ label = (row[COLUMNS[4]] or '').replace('\n', ' / ')
121
+ names = {psl, _slug(label)}
122
+ names |= {alias for alias, target in ALIASES.items() if target == psl}
123
+ types[psl] = {'id': psl, 'names': sorted(n for n in names if n), 'label': label}
124
+ return types
125
+
126
+
127
+ def _resolve(name, types):
128
+ """The PSL id a mkit.toml name means, or a UsageError naming the near misses."""
129
+ wanted = str(name).strip()
130
+ for psl, info in types.items():
131
+ if wanted == psl or wanted.lower() in info['names']:
132
+ return psl
133
+ close = sorted(n for info in types.values() for n in info['names']
134
+ if wanted.lower()[:4] and wanted.lower()[:4] in n)
135
+ raise UsageError(f'unknown data type "{name}" in [datasafety.collected]',
136
+ hint=('Did you mean: ' + ', '.join(close[:5]) + '?') if close
137
+ else 'Run `mkit datasafety types` for every name.',
138
+ code='DATASAFETY_UNKNOWN_TYPE')
139
+
140
+
141
+ def _purposes(values, where):
142
+ out = set()
143
+ for value in values:
144
+ key = str(value).strip().lower()
145
+ if key not in PURPOSES:
146
+ raise UsageError(f'unknown purpose "{value}" in {where}',
147
+ hint='One of: ' + ', '.join(sorted(PURPOSES)),
148
+ code='DATASAFETY_UNKNOWN_PURPOSE')
149
+ out.add(PURPOSES[key])
150
+ return out
151
+
152
+
153
+ def _collected(config, types):
154
+ """[datasafety.collected] as {psl id: {purposes, collected, shared, ephemeral, optional}}."""
155
+ collected = {}
156
+ for name, value in (config.get('collected') or {}).items():
157
+ psl = _resolve(name, types)
158
+ # Two spellings, because most types only ever need the short one:
159
+ # crash_logs = ["analytics"] purposes, collected, not shared
160
+ # [datasafety.collected.photos] the same plus sharing and controls
161
+ # purposes = [...]
162
+ if isinstance(value, dict):
163
+ entry = value
164
+ elif isinstance(value, (list, tuple)):
165
+ entry = {'purposes': list(value)}
166
+ else:
167
+ raise UsageError(f'[datasafety.collected] {name} must be a list of purposes '
168
+ f'or a table, not {type(value).__name__}',
169
+ code='DATASAFETY_BAD_TYPE')
170
+ purposes = _purposes(entry.get('purposes') or [], f'[datasafety.collected] {name}')
171
+ if not purposes:
172
+ raise UsageError(f'[datasafety.collected] {name} lists no purpose',
173
+ hint='Play requires at least one: ' + ', '.join(sorted(PURPOSES)),
174
+ code='DATASAFETY_NO_PURPOSE')
175
+ shared = _purposes(entry.get('shared') or [], f'[datasafety.collected] {name} shared')
176
+ # Play's "collected, shared, or both" is two checkboxes. Data the app
177
+ # sends off the device is collected; sharing it on is on top of that,
178
+ # so only a type that goes to a third party straight from the device
179
+ # (an SDK the app never sees the data from) sets collected = false.
180
+ is_collected = bool(entry.get('collected', True))
181
+ if not is_collected and not shared:
182
+ raise UsageError(f'[datasafety.collected] {name} is neither collected nor shared',
183
+ hint='Leave the type out instead, or set shared.',
184
+ code='DATASAFETY_NOT_COLLECTED')
185
+ collected[psl] = {
186
+ 'purposes': purposes,
187
+ 'collected': is_collected,
188
+ 'shared': shared,
189
+ 'ephemeral': bool(entry.get('ephemeral', False)),
190
+ 'optional': bool(entry.get('optional', False)),
191
+ }
192
+ return collected
193
+
194
+
195
+ def _flag(value):
196
+ return 'TRUE' if value else 'FALSE'
197
+
198
+
199
+ def _account_methods(config):
200
+ """account_creation as the set of response ids it chooses."""
201
+ methods = config.get('account_creation', 'none')
202
+ methods = [methods] if isinstance(methods, str) else list(methods)
203
+ chosen = set()
204
+ for method in methods:
205
+ key = str(method).strip().lower()
206
+ if key not in ACCOUNT_METHODS:
207
+ raise UsageError(f'unknown account_creation "{method}"',
208
+ hint='One of: ' + ', '.join(ACCOUNT_METHODS),
209
+ code='DATASAFETY_UNKNOWN_ACCOUNT')
210
+ chosen.add(ACCOUNT_METHODS[key])
211
+ return chosen
212
+
213
+
214
+ def _answer(row, config, collected):
215
+ """One row's Response value: TRUE, FALSE, a string, or blank where it does not apply."""
216
+ question, response = row[COLUMNS[0]], row[COLUMNS[1]]
217
+
218
+ if question == COLLECTS:
219
+ return _flag(config.get('collects', bool(collected)))
220
+ if question == ENCRYPTED:
221
+ return _flag(config.get('encrypted_in_transit', True))
222
+ if question == OUTSIDE_ACCOUNTS:
223
+ # Play only asks this of an app with no account creation of its own,
224
+ # and rejects an answer from one that has it.
225
+ if _account_methods(config) != {'PSL_ACM_NONE'}:
226
+ return ''
227
+ return _flag(config.get('outside_app_accounts', False))
228
+ if question == ACCOUNT_CREATION:
229
+ return _flag(response in _account_methods(config))
230
+ if question == DELETION:
231
+ key = str(config.get('deletion', 'no')).strip().lower()
232
+ if key not in DELETION_CHOICES:
233
+ raise UsageError(f'unknown deletion "{key}"',
234
+ hint='One of: ' + ', '.join(DELETION_CHOICES),
235
+ code='DATASAFETY_UNKNOWN_DELETION')
236
+ return _flag(response == DELETION_CHOICES[key])
237
+ if question in (DELETION_URL, ACCOUNT_DELETION_URL):
238
+ key = 'deletion_url' if question == DELETION_URL else 'account_deletion_url'
239
+ return str(config.get(key, '') or '')
240
+
241
+ if question.startswith('PSL_DATA_TYPES_'):
242
+ return _flag(response in collected)
243
+
244
+ if not question.startswith(USAGE + ':'):
245
+ # Everything else - family policy, independent validation, the account
246
+ # types - is left as Play's own default rather than guessed at here.
247
+ return ''
248
+
249
+ _, data_type, part = question.split(':', 2)
250
+ entry = collected.get(data_type)
251
+ if entry is None:
252
+ # A type that is not collected answers nothing further; the Console
253
+ # greys these out for the same reason.
254
+ return ''
255
+ if part == 'PSL_DATA_USAGE_COLLECTION_AND_SHARING':
256
+ if response == 'PSL_DATA_USAGE_ONLY_COLLECTED':
257
+ return _flag(entry['collected'])
258
+ if response == 'PSL_DATA_USAGE_ONLY_SHARED':
259
+ return _flag(bool(entry['shared']))
260
+ return ''
261
+ if part == 'PSL_DATA_USAGE_EPHEMERAL':
262
+ # Asked about collection only; Play rejects it for a type that is
263
+ # only shared.
264
+ return _flag(entry['ephemeral']) if entry['collected'] else ''
265
+ if part == 'DATA_USAGE_USER_CONTROL':
266
+ wanted = ('PSL_DATA_USAGE_USER_CONTROL_OPTIONAL' if entry['optional']
267
+ else 'PSL_DATA_USAGE_USER_CONTROL_REQUIRED')
268
+ return _flag(response == wanted)
269
+ if part == 'DATA_USAGE_COLLECTION_PURPOSE':
270
+ return _flag(response in entry['purposes'])
271
+ if part == 'DATA_USAGE_SHARING_PURPOSE':
272
+ return _flag(response in entry['shared'])
273
+ raise UsageError(f'unhandled data usage question {question}',
274
+ hint='The shipped question set is newer than this engine; '
275
+ 'update mkit, or report it.',
276
+ code='DATASAFETY_UNHANDLED')
277
+
278
+
279
+ def build(config):
280
+ """The whole CSV as text, from a [datasafety] section."""
281
+ if not config:
282
+ raise UsageError('no [datasafety] section in mkit.toml',
283
+ hint='See docs/config.md; `mkit datasafety types` lists the '
284
+ 'data type names.',
285
+ code='NO_DATASAFETY_CONFIG')
286
+ rows = question_rows()
287
+ types = data_types(rows)
288
+ collected = _collected(config, types)
289
+ if config.get('collects', bool(collected)) and not collected:
290
+ raise UsageError('[datasafety] says the app collects data but lists none',
291
+ hint='Add [datasafety.collected], or set collects = false.',
292
+ code='DATASAFETY_NO_TYPES')
293
+
294
+ out = io.StringIO()
295
+ writer = csv.DictWriter(out, fieldnames=list(COLUMNS), lineterminator='\r\n')
296
+ writer.writeheader()
297
+ for row in rows:
298
+ row = dict(row)
299
+ row[COLUMNS[2]] = _answer(row, config, collected)
300
+ writer.writerow(row)
301
+ labels = sorted(types[psl]['label'].split(' / ')[-1] for psl in collected)
302
+ return out.getvalue(), {'rows': len(rows), 'data_types': labels}
mkit/device.py ADDED
@@ -0,0 +1,246 @@
1
+ """adb and emulator helpers: pick a device, install, launch, follow logs."""
2
+ import os
3
+ import re
4
+ import subprocess
5
+ import sys
6
+ import time
7
+
8
+ from . import output, proc, toolchain
9
+ from .errors import MkitError, UsageError
10
+
11
+
12
+ class Adb:
13
+ def __init__(self, root=None):
14
+ self.exe = toolchain.adb(root)
15
+
16
+ def __call__(self, *args, device=None, check=False, timeout=120):
17
+ argv = [self.exe] + (['-s', device] if device else []) + list(args)
18
+ return proc.run(argv, check=check, timeout=timeout)
19
+
20
+ def devices(self):
21
+ found = []
22
+ for line in self('devices').splitlines()[1:]:
23
+ m = re.match(r'^(\S+)\s+(device|unauthorized|offline)$', line.strip())
24
+ if m:
25
+ found.append((m.group(1), m.group(2)))
26
+ return found
27
+
28
+ def model(self, device):
29
+ try:
30
+ return self('shell', 'getprop', 'ro.product.model', device=device).strip() or device
31
+ except Exception:
32
+ return device
33
+
34
+ def app_uid(self, device, package):
35
+ """The Linux uid the package runs as, or None if it is not installed.
36
+
37
+ Used to narrow logcat to one app. The uid rather than the pid because the pid changes
38
+ every time the app restarts, and restarting the app without leaving the log is the whole
39
+ point of the Shift+R in follow_logs.
40
+ """
41
+ try:
42
+ out = self('shell', 'pm', 'list', 'packages', '-U', package, device=device)
43
+ except Exception:
44
+ return None
45
+ for line in out.splitlines():
46
+ line = line.strip()
47
+ if line.startswith(f'package:{package} ') and 'uid:' in line:
48
+ uid = line.rsplit('uid:', 1)[1].strip()
49
+ return uid if uid.isdigit() else None
50
+ return None
51
+
52
+
53
+ def is_emulator(serial):
54
+ return serial.startswith('emulator-')
55
+
56
+
57
+ def pick(adb, want=None, serial=None):
58
+ """One ready device. want: 'phone' | 'emulator' | None. Asks at a terminal
59
+ when several match; anywhere else that is an error naming --device."""
60
+ listed = adb.devices()
61
+ if serial:
62
+ if not any(s == serial and st == 'device' for s, st in listed):
63
+ raise MkitError(f'device {serial} is not attached and ready', code='NO_DEVICE')
64
+ return serial
65
+ for s, state in listed:
66
+ if state == 'unauthorized':
67
+ output.out.warn(f'{s} is unauthorized: accept the USB debugging prompt on the phone')
68
+ ready = [s for s, st in listed if st == 'device']
69
+ if want == 'phone':
70
+ ready = [s for s in ready if not is_emulator(s)]
71
+ elif want == 'emulator':
72
+ ready = [s for s in ready if is_emulator(s)]
73
+ if not ready:
74
+ what = {'phone': 'phone', 'emulator': 'running emulator'}.get(want, 'device')
75
+ raise MkitError(f'no {what} attached', code='NO_DEVICE',
76
+ hint='Plug in a phone with USB debugging on, or start one with `mkit emu`.')
77
+ if len(ready) == 1:
78
+ return ready[0]
79
+ out = output.out
80
+ if out.json or not sys.stdin.isatty():
81
+ raise UsageError(f'{len(ready)} devices attached ({", ".join(ready)}); pass --device SERIAL',
82
+ code='SEVERAL_DEVICES')
83
+ out.line()
84
+ for i, s in enumerate(ready, 1):
85
+ out.line(f' {i}) {adb.model(s)} ({s}, {"emulator" if is_emulator(s) else "phone"})')
86
+ choice = input(f'\nWhich one? (1-{len(ready)}, Enter for 1) ').strip() or '1'
87
+ try:
88
+ return ready[int(choice) - 1]
89
+ except (ValueError, IndexError):
90
+ raise UsageError(f'not one of the choices: {choice}') from None
91
+
92
+
93
+ def clean_state(adb, device, package):
94
+ """Remove the app for every user and wipe its backup, so the next install
95
+ is a true first run (Auto Backup would otherwise restore old prefs)."""
96
+ users = re.findall(r'UserInfo\{(\d+):', adb('shell', 'pm', 'list', 'users', device=device)) or ['0']
97
+ removed = 0
98
+ for user in users:
99
+ adb('shell', 'pm', 'clear', '--user', user, package, device=device)
100
+ if 'Success' in adb('shell', 'pm', 'uninstall', '--user', user, package, device=device):
101
+ removed += 1
102
+ if 'Success' in adb('uninstall', package, device=device):
103
+ removed += 1
104
+ transports = []
105
+ for line in adb('shell', 'bmgr', 'list', 'transports', device=device).splitlines():
106
+ t = re.sub(r'^\s*\*?\s*', '', line).strip()
107
+ if '/' in t:
108
+ transports.append(t)
109
+ for t in transports:
110
+ adb('shell', 'bmgr', 'wipe', t, package, device=device)
111
+ return removed, len(transports)
112
+
113
+
114
+ def launch(adb, device, package, clear_log=True):
115
+ listed = adb('shell', 'cmd', 'package', 'resolve-activity', '--brief', '-a',
116
+ 'android.intent.action.MAIN', '-c', 'android.intent.category.LAUNCHER', package,
117
+ device=device, check=True).splitlines()
118
+ component = next((l.strip() for l in reversed(listed) if l.strip().startswith(package + '/')), None)
119
+ if not component:
120
+ raise MkitError(f'no launcher activity for {package}', code='NO_LAUNCHER')
121
+ adb('shell', 'am', 'force-stop', package, device=device, check=True)
122
+ if clear_log:
123
+ adb('logcat', '-c', device=device)
124
+ result = adb('shell', 'am', 'start', '-n', component, device=device, check=True)
125
+ if 'Error' in result or 'Exception' in result:
126
+ raise MkitError(result.strip(), code='LAUNCH_FAILED')
127
+ return component
128
+
129
+
130
+ def _logcat_argv(adb, device, package, tags, everything):
131
+ """What to follow, resolved fresh each time the stream starts.
132
+
133
+ The uid is looked up here rather than once at the top, because an install can move an app to a
134
+ new uid. A filter worked out before a reinstall goes on matching the old one, and the log then
135
+ runs empty while the app is plainly doing things, which looks like the app broke.
136
+ """
137
+ if tags:
138
+ return [adb.exe, '-s', device, 'logcat', '-s', *tags]
139
+ if everything:
140
+ return [adb.exe, '-s', device, 'logcat']
141
+ uid = adb.app_uid(device, package)
142
+ if uid:
143
+ return [adb.exe, '-s', device, 'logcat', '--uid', uid]
144
+ output.out.line(f'{package} is not installed, so the whole log follows', 'dim')
145
+ return [adb.exe, '-s', device, 'logcat']
146
+
147
+
148
+ def _stop(process):
149
+ if process.poll() is None:
150
+ process.terminate()
151
+ try:
152
+ process.wait(timeout=3)
153
+ except subprocess.TimeoutExpired:
154
+ process.kill()
155
+
156
+
157
+ def _wait_for_restart(process, restart):
158
+ """True when R was pressed, False when the stream ended or Ctrl+C arrived."""
159
+ try:
160
+ if os.name == 'nt':
161
+ import msvcrt
162
+ while process.poll() is None:
163
+ if restart and msvcrt.kbhit() and msvcrt.getwch() == 'R':
164
+ return True
165
+ time.sleep(0.05)
166
+ else:
167
+ import select
168
+ while process.poll() is None:
169
+ ready, _, _ = select.select([sys.stdin], [], [], 0.1)
170
+ if restart and ready and sys.stdin.read(1) == 'R':
171
+ return True
172
+ except KeyboardInterrupt:
173
+ pass
174
+ return False
175
+
176
+
177
+ def follow_logs(adb, device, package, tags, restart=True, everything=False, rebuild=None):
178
+ """logcat until Ctrl+C. Shift+R puts the current code on the device without leaving.
179
+
180
+ Narrowed to the app unless asked otherwise. Named tags win where a project sets them; with
181
+ none, the app's own uid does it, which needs nothing configured and still catches the lines
182
+ an app never chose a tag for, crashes included. A phone's own firmware can write a thousand
183
+ lines a minute, so an unfiltered stream is not a log of your app in any useful sense.
184
+
185
+ Given a [rebuild], Shift+R builds and installs before launching, which is what somebody
186
+ watching their own log almost always means by restart: it used to relaunch whatever was
187
+ already on the device, so the log filled with the code they had just changed away from and
188
+ nothing said why. Without one it relaunches, which is all `mkit logs` can do from outside a
189
+ project.
190
+
191
+ The stream is stopped and started around each restart rather than left running. A rebuild
192
+ prints over the top of a live log otherwise, and after an install the filter has to be worked
193
+ out again; see [_logcat_argv].
194
+ """
195
+ out = output.out
196
+ out.line('following logs; Shift+R rebuilds, installs and restarts, Ctrl+C stops' if rebuild
197
+ else 'following logs; Shift+R restarts the app, Ctrl+C stops' if restart
198
+ else 'following logs; Ctrl+C stops', 'dim')
199
+
200
+ while True:
201
+ process = subprocess.Popen(_logcat_argv(adb, device, package, tags, everything))
202
+ try:
203
+ asked = _wait_for_restart(process, restart)
204
+ finally:
205
+ _stop(process)
206
+ if not asked:
207
+ return
208
+
209
+ if rebuild:
210
+ out.line('rebuilding', 'dim')
211
+ try:
212
+ rebuild()
213
+ except Exception as error:
214
+ # Back to the log rather than out of it. A build breaks constantly and the
215
+ # whole point of this key is not having to leave; the app on the device is
216
+ # simply still the last one that built.
217
+ out.warn(f'rebuild failed, app left as it was: {error}')
218
+ continue
219
+ out.line('restarting app', 'dim')
220
+ launch(adb, device, package, clear_log=False)
221
+
222
+
223
+ def avds(root=None):
224
+ exe = toolchain.emulator(root)
225
+ return exe, [l.strip() for l in proc.run([exe, '-list-avds'], check=False).splitlines()
226
+ if l.strip() and not l.startswith('INFO')]
227
+
228
+
229
+ def start_emulator(adb, exe, avd, root=None, wait=240):
230
+ """Kill running emulators, boot `avd` cold, wait for boot to complete."""
231
+ adb('emu', 'kill')
232
+ if os.name == 'nt':
233
+ for image in ('emulator.exe', 'qemu-system-x86_64.exe'):
234
+ subprocess.run(['taskkill', '/F', '/IM', image, '/T'],
235
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
236
+ flags = getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
237
+ subprocess.Popen([exe, '-avd', avd, '-no-boot-anim', '-no-audio', '-gpu', 'host', '-no-snapshot'],
238
+ cwd=root, creationflags=flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
239
+ deadline = time.time() + wait
240
+ while time.time() < deadline:
241
+ time.sleep(2)
242
+ output.out.tick()
243
+ serial = next((s for s, st in adb.devices() if is_emulator(s) and st == 'device'), None)
244
+ if serial and adb('shell', 'getprop', 'sys.boot_completed', device=serial).strip() == '1':
245
+ return serial
246
+ raise MkitError(f'{avd} did not finish booting in {wait}s', code='EMULATOR_TIMEOUT')
mkit/docgen.py ADDED
@@ -0,0 +1,100 @@
1
+ """docs/commands.md, generated from the command registry.
2
+
3
+ The reference is built from the same declarations the CLI is built from, so
4
+ it cannot describe a flag that does not exist. tests/test_docs.py fails when
5
+ the committed file is stale; `python scripts/gen_docs.py` rewrites it.
6
+ """
7
+ from . import __version__
8
+ from .registry import GROUPS, REGISTRY, RISKS
9
+
10
+ RISK_NOTE = {
11
+ 'read': 'looks only, changes nothing',
12
+ 'plan': 'reads remote state and shows a diff, changes nothing',
13
+ 'write': 'changes local files/git or a non-user-facing remote thing',
14
+ 'publish': 'changes what users, testers or Play review see; always confirmed',
15
+ }
16
+
17
+ SECTIONS = (
18
+ ('Develop', ('run', 'devices', 'logs', 'emu', 'studio', 'version', 'bump', 'gradle')),
19
+ ('Release', ('deploy', 'status', 'promote', 'rollout', 'share')),
20
+ ('Store', ('listing', 'whatsnew', 'datasafety', 'testers')),
21
+ ('Money', ('products', 'prices')),
22
+ ('Insight', ('stats', 'vitals', 'crashes', 'reviews', 'aso')),
23
+ ('Firebase', ('rules', 'config', 'remoteconfig', 'firebase')),
24
+ ('About mkit', ('doctor', 'commands', 'schema', 'manual', 'init', 'self')),
25
+ )
26
+
27
+
28
+ def _usage(cmd):
29
+ parts = [f'mkit {cmd.name}']
30
+ for a in cmd.args:
31
+ flag = f'--{a.name.replace("_", "-")}'
32
+ if a.kind == 'positional':
33
+ parts.append(f'[{a.name}]' if a.nargs in ('?', '*') else f'<{a.name}...>' if a.nargs == '...' else f'<{a.name}>')
34
+ elif a.kind == 'flag':
35
+ parts.append(f'[-{a.short}]' if a.short and not a.long
36
+ else f'[-{a.short}|{flag}]' if a.short else f'[{flag}]')
37
+ else:
38
+ parts.append(f'[{flag} {a.name.upper()}]')
39
+ return ' '.join(parts)
40
+
41
+
42
+ def _section_of(name):
43
+ head = name.split(' ')[0]
44
+ for title, names in SECTIONS:
45
+ if head in names:
46
+ return title
47
+ return 'Other'
48
+
49
+
50
+ def render():
51
+ lines = [
52
+ '# mkit command reference',
53
+ '',
54
+ f'Generated from the command registry (mkit {__version__.rsplit(".", 1)[0]}.x). '
55
+ 'Do not edit by hand: run `python scripts/gen_docs.py`.',
56
+ '',
57
+ 'Global options, accepted before or after any command: `--json`, `--human`, '
58
+ '`--project PATH`, `--dry-run`, `--strict`, `--yes --confirm PACKAGE`, `--no-color`.',
59
+ '',
60
+ '## Risk classes',
61
+ '',
62
+ '| Class | Meaning |',
63
+ '|---|---|',
64
+ ]
65
+ lines += [f'| {r} | {RISK_NOTE[r]} |' for r in RISKS]
66
+ by_section = {}
67
+ for name in sorted(REGISTRY):
68
+ by_section.setdefault(_section_of(name), []).append(REGISTRY[name])
69
+ for title, _ in SECTIONS + (('Other', ()),):
70
+ cmds = by_section.get(title)
71
+ if not cmds:
72
+ continue
73
+ lines += ['', f'## {title}', '']
74
+ lines += ['| Command | Risk | What |', '|---|---|---|']
75
+ lines += [f'| `{c.name}` | {c.risk} | {c.summary} |' for c in cmds]
76
+ for c in cmds:
77
+ lines += ['', f'### mkit {c.name}', '', f'`{c.risk}`'
78
+ + (f', needs `[{"]`, `[".join(c.needs)}]` in mkit.toml' if c.needs else ''), '']
79
+ text = c.description or c.summary
80
+ # Collapse the whitespace rather than swapping newlines for spaces: a
81
+ # source file checked out with CRLF leaves a stray carriage return in
82
+ # the middle of the line, so the same command rendered differently on
83
+ # Windows than on Linux and the committed reference could not be
84
+ # current on both at once. CI found this the day it was switched on.
85
+ summary = ' '.join(text.split('\n\n')[0].split())
86
+ lines += [summary, '', '```', _usage(c), '```']
87
+ if c.args:
88
+ lines += ['', '| Argument | What |', '|---|---|']
89
+ for a in c.args:
90
+ label = a.name if a.kind == 'positional' else f'--{a.name.replace("_", "-")}'
91
+ if a.short:
92
+ label = f'-{a.short}' if not a.long else f'-{a.short}, {label}'
93
+ extra = f' (one of {", ".join(a.choices)})' if a.choices else ''
94
+ extra += f' (default {a.default})' if a.default not in (None, False) else ''
95
+ lines.append(f'| `{label}` | {a.help}{extra} |')
96
+ if c.examples:
97
+ lines += ['', 'Examples:', '', '```'] + list(c.examples) + ['```']
98
+ group_lines = [f'- `{g}`: {s}' for g, s in sorted(GROUPS.items())]
99
+ lines += ['', '## Command groups', ''] + group_lines + ['']
100
+ return '\n'.join(lines)