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