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/commands/store.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Store listing, what's new, data safety and testers."""
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from .. import datasafety, kinds, safety
|
|
6
|
+
from ..cloud import auth, play, store
|
|
7
|
+
from ..errors import DRIFT, UsageError
|
|
8
|
+
from ..git import Repo
|
|
9
|
+
from ..registry import Arg, command, group
|
|
10
|
+
|
|
11
|
+
group('listing', 'store listing text, images and contact details')
|
|
12
|
+
group('whatsnew', "release notes (what's new) files")
|
|
13
|
+
group('datasafety', 'the data safety declaration')
|
|
14
|
+
group('testers', 'Google Groups on testing tracks')
|
|
15
|
+
|
|
16
|
+
SCOPE_ARGS = (Arg('text_only', 'only the text', kind='flag'),
|
|
17
|
+
Arg('images_only', 'only the images', kind='flag'))
|
|
18
|
+
LOCALES_ARG = Arg('locales', 'only these Play locales, comma separated, e.g. de-DE,es-ES (a staged rollout)')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _only(args):
|
|
22
|
+
if not getattr(args, 'locales', None):
|
|
23
|
+
return None
|
|
24
|
+
return {loc.strip() for loc in args.locales.split(',') if loc.strip()}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _scope(args):
|
|
28
|
+
if args.text_only and args.images_only:
|
|
29
|
+
raise UsageError('pass only one of --text-only / --images-only')
|
|
30
|
+
return not args.images_only, not args.text_only
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _show_plan(out, result):
|
|
34
|
+
if out.json:
|
|
35
|
+
return
|
|
36
|
+
for c in result['text']:
|
|
37
|
+
out.line(f' text {c["locale"]:8} {"new locale" if c["new"] else ", ".join(c["fields"])}')
|
|
38
|
+
for c in result['images']:
|
|
39
|
+
out.line(f' images {c["locale"]:8} {c["type"]}: {c["remote"]} on Play -> {c["local"]} local')
|
|
40
|
+
for k, v in result['details'].items():
|
|
41
|
+
out.line(f' detail {k}: {v["from"] or "(empty)"} -> {v["to"]}')
|
|
42
|
+
if result['remote_only']:
|
|
43
|
+
out.line(f' on Play but not in the files (left alone): {", ".join(result["remote_only"])}', 'dim')
|
|
44
|
+
if result.get('no_listing'):
|
|
45
|
+
out.line(f' images not planned, no listing on Play yet (apply the text first): '
|
|
46
|
+
f'{", ".join(result["no_listing"])}', 'yellow')
|
|
47
|
+
if not store.plan_size(result):
|
|
48
|
+
out.line(' no changes: Play matches the files', 'green')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _compare(ctx, edit, text, images, only=None):
|
|
52
|
+
out = ctx.out
|
|
53
|
+
out.step('Compare with Play' + (f' ({len(only)} locale(s) only)' if only else ''))
|
|
54
|
+
result = store.plan(edit, ctx.project, ctx.project.kind, with_images=images, with_text=text,
|
|
55
|
+
progress=lambda d, t, item: out.progress(d, t, item), only=only)
|
|
56
|
+
out.done(f'{store.plan_size(result)} change(s), {store.upload_count(result)} image(s) to send')
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@command('listing plan', 'Show how the listing files differ from Play (changes nothing)',
|
|
61
|
+
risk='plan', needs=('play', 'store'), args=SCOPE_ARGS + (LOCALES_ARG,),
|
|
62
|
+
examples=('mkit listing plan', 'mkit listing plan --text-only',
|
|
63
|
+
'mkit listing plan --locales de-DE,es-ES'))
|
|
64
|
+
def listing_plan(ctx, args):
|
|
65
|
+
text, images = _scope(args)
|
|
66
|
+
ctx.out.start(f'{ctx.project.name} listing plan', 1)
|
|
67
|
+
with play.Edit(ctx) as edit:
|
|
68
|
+
result = _compare(ctx, edit, text, images, _only(args))
|
|
69
|
+
_show_plan(ctx.out, result)
|
|
70
|
+
if store.plan_size(result):
|
|
71
|
+
ctx.exit_code = DRIFT
|
|
72
|
+
return store.public(result)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@command('listing apply', 'Push listing changes (only what differs) to Play', risk='publish',
|
|
76
|
+
needs=('play', 'store'),
|
|
77
|
+
args=SCOPE_ARGS + (LOCALES_ARG,
|
|
78
|
+
Arg('resubmit', 'cancel a review already running and resubmit everything',
|
|
79
|
+
kind='flag')),
|
|
80
|
+
examples=('mkit listing apply', 'mkit listing apply --text-only --dry-run',
|
|
81
|
+
'mkit listing apply --locales de-DE,es-ES,es-419,pt-BR'))
|
|
82
|
+
def listing_apply(ctx, args):
|
|
83
|
+
text, images = _scope(args)
|
|
84
|
+
out = ctx.out
|
|
85
|
+
out.start(f'{ctx.project.name} listing', 4)
|
|
86
|
+
with play.Edit(ctx) as edit:
|
|
87
|
+
result = _compare(ctx, edit, text, images, _only(args))
|
|
88
|
+
_show_plan(out, result)
|
|
89
|
+
if not store.plan_size(result):
|
|
90
|
+
return dict(store.public(result), changed=False)
|
|
91
|
+
safety.confirm(ctx, f'Publish {store.plan_size(result)} listing change(s) for {ctx.project.name}')
|
|
92
|
+
workers = store._workers(ctx.project)
|
|
93
|
+
out.step(f'Send text ({len(result["text"])} language(s))')
|
|
94
|
+
progress = lambda d, t, item: out.progress(d, t, item) # noqa: E731
|
|
95
|
+
store.apply(edit, {**result, 'images': [], 'details': {}}, workers, on_text=progress)
|
|
96
|
+
out.done()
|
|
97
|
+
out.step(f'Send images ({store.upload_count(result)} file(s), {workers} at a time)')
|
|
98
|
+
store.apply(edit, {**result, 'text': []}, workers, on_image=progress)
|
|
99
|
+
out.done()
|
|
100
|
+
out.step('Validate and commit')
|
|
101
|
+
edit.finish(ctx.dry_run, in_review='resubmit' if args.resubmit else 'error')
|
|
102
|
+
out.done()
|
|
103
|
+
if not ctx.out.json:
|
|
104
|
+
if ctx.dry_run:
|
|
105
|
+
ctx.out.line('validated on Play; nothing committed (dry run)', 'green')
|
|
106
|
+
elif edit.manual_review:
|
|
107
|
+
ctx.out.line('committed; send it for review in Play Console (see the warning)', 'green')
|
|
108
|
+
else:
|
|
109
|
+
ctx.out.line('committed; Play reviews listing changes before they show', 'green')
|
|
110
|
+
return dict(store.public(result), changed=not ctx.dry_run, dry_run=ctx.dry_run,
|
|
111
|
+
sent_for_review=not ctx.dry_run and not edit.manual_review)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@command('listing pull', 'Save the live listing (all locales) to a JSON backup', risk='read',
|
|
115
|
+
needs=('play',), args=(Arg('out', 'file to write (default: a timestamped backup)'),))
|
|
116
|
+
def listing_pull(ctx, args):
|
|
117
|
+
with play.Edit(ctx) as edit:
|
|
118
|
+
listings = edit.call(lambda e, p, i: e.listings().list(packageName=p, editId=i),
|
|
119
|
+
'read listings').get('listings', [])
|
|
120
|
+
details = edit.call(lambda e, p, i: e.details().get(packageName=p, editId=i), 'read details')
|
|
121
|
+
data = {'package': ctx.project.package, 'details': details,
|
|
122
|
+
'listings': {l['language']: l for l in listings}}
|
|
123
|
+
if args.out:
|
|
124
|
+
path = ctx.project.root / args.out
|
|
125
|
+
else:
|
|
126
|
+
folder = ctx.project.path('store.backup_dir', 'console')
|
|
127
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
path = folder / f'backup-listings-{datetime.now():%Y%m%d-%H%M%S}.json'
|
|
129
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
130
|
+
if not ctx.out.json:
|
|
131
|
+
ctx.out.line(f'{len(listings)} locale(s) saved to {path}')
|
|
132
|
+
return {'file': str(path), 'locales': sorted(data['listings'])}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@command('whatsnew check', "Check the what's new files: lengths and locales", risk='read', needs=('store',))
|
|
136
|
+
def whatsnew_check(ctx, args):
|
|
137
|
+
notes = play.release_notes(ctx.project)
|
|
138
|
+
configured = set()
|
|
139
|
+
if ctx.project.section('store').get('locales'):
|
|
140
|
+
configured = {loc for t in store.locales(ctx.project).values() for loc in t}
|
|
141
|
+
have = {n['language'] for n in notes}
|
|
142
|
+
missing = sorted(configured - have)
|
|
143
|
+
style = {n['language']: why for n in notes if (why := play.note_problems(n['text']))}
|
|
144
|
+
repo = Repo(ctx.project.root)
|
|
145
|
+
last = repo.last_release() if notes and repo.is_repo() else ''
|
|
146
|
+
unchanged = bool(last) and not repo.changed_since(last, ctx.project.path('store.whatsnew_dir'))
|
|
147
|
+
if not ctx.out.json:
|
|
148
|
+
for n in notes:
|
|
149
|
+
ctx.out.line(f' {n["language"]:8} {len(n["text"]):3}/{play.WHATSNEW_LIMIT} '
|
|
150
|
+
f'{n["text"].splitlines()[0][:60]}')
|
|
151
|
+
for lang, why in sorted(style.items()):
|
|
152
|
+
ctx.out.line(f' {lang}: {", ".join(why)}', 'red')
|
|
153
|
+
if missing:
|
|
154
|
+
ctx.out.line(f' no note for: {", ".join(missing)} (those languages see the default)', 'yellow')
|
|
155
|
+
if unchanged:
|
|
156
|
+
ctx.out.line(f' unchanged since the last release ({last[:7]}): deploy will refuse it '
|
|
157
|
+
'without --same-notes', 'yellow')
|
|
158
|
+
if style:
|
|
159
|
+
ctx.exit_code = 1
|
|
160
|
+
return {'notes': notes, 'missing': missing, 'style': style, 'unchanged_since_release': unchanged}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@command('datasafety check', 'Check the data safety CSV (no upload)', risk='read', needs=('store',))
|
|
164
|
+
def datasafety_check(ctx, args):
|
|
165
|
+
info = store.read_datasafety(ctx.project)
|
|
166
|
+
info.pop('text')
|
|
167
|
+
if not ctx.out.json:
|
|
168
|
+
ctx.out.line(f'{info["path"]}: {info["rows"]} rows, {info["answered"]} answered')
|
|
169
|
+
ctx.out.line(f' collects data: {info["collects_data"] or "?"}')
|
|
170
|
+
ctx.out.line(f' data types: {", ".join(info["data_types"]) or "none"}')
|
|
171
|
+
for q in info['required_blank']:
|
|
172
|
+
ctx.out.line(f' REQUIRED but blank: {q}', 'yellow')
|
|
173
|
+
if info['required_blank']:
|
|
174
|
+
ctx.exit_code = 1
|
|
175
|
+
return info
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@command('datasafety build', 'Write the data safety CSV from [datasafety] in mkit.toml',
|
|
179
|
+
risk='write', needs=('store',),
|
|
180
|
+
examples=('mkit datasafety build', 'mkit datasafety build --show'),
|
|
181
|
+
args=(Arg('show', 'print the answers without writing the file', kind='flag'),))
|
|
182
|
+
def datasafety_build(ctx, args):
|
|
183
|
+
"""The 782 questions are the same for every app; only the answers differ.
|
|
184
|
+
|
|
185
|
+
So the question set ships with the engine and an app declares what it
|
|
186
|
+
collects, rather than each repo carrying its own filled copy of Google's
|
|
187
|
+
sample CSV and drifting from the others.
|
|
188
|
+
"""
|
|
189
|
+
text, info = datasafety.build(ctx.project.section('datasafety'))
|
|
190
|
+
path = ctx.project.path('store.datasafety')
|
|
191
|
+
if not path:
|
|
192
|
+
raise UsageError('no datasafety path set under [store]',
|
|
193
|
+
hint='Add datasafety = "docs/data-safety/<app>.csv" under [store].',
|
|
194
|
+
code='NO_DATASAFETY_PATH')
|
|
195
|
+
if not args.show:
|
|
196
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
path.write_text(text, encoding='utf-8', newline='')
|
|
198
|
+
if not ctx.out.json:
|
|
199
|
+
ctx.out.line(f'{"would write" if args.show else "wrote"} {path}: {info["rows"]} rows')
|
|
200
|
+
ctx.out.line(f' collects: {", ".join(info["data_types"]) or "nothing"}')
|
|
201
|
+
return {'path': str(path), 'written': not args.show, **info}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@command('datasafety types', 'Every data type a declaration can name', risk='read')
|
|
205
|
+
def datasafety_types(ctx, args):
|
|
206
|
+
"""What [datasafety.collected] accepts, so nobody has to guess a spelling."""
|
|
207
|
+
types = [{'id': t['id'], 'names': t['names'], 'label': t['label']}
|
|
208
|
+
for t in datasafety.data_types().values()]
|
|
209
|
+
if not ctx.out.json:
|
|
210
|
+
for t in types:
|
|
211
|
+
ctx.out.line(f' {t["names"][0]:26s} {t["label"]}')
|
|
212
|
+
ctx.out.line('')
|
|
213
|
+
ctx.out.line(' purposes: ' + ', '.join(sorted(datasafety.PURPOSES)))
|
|
214
|
+
return {'types': types, 'purposes': sorted(datasafety.PURPOSES)}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@command('datasafety apply', 'Upload the data safety declaration (replaces the one on Play)',
|
|
218
|
+
risk='publish', needs=('play', 'store'))
|
|
219
|
+
def datasafety_apply(ctx, args):
|
|
220
|
+
info = store.read_datasafety(ctx.project)
|
|
221
|
+
if info['required_blank']:
|
|
222
|
+
raise UsageError(f'{len(info["required_blank"])} REQUIRED question(s) are blank; Play would reject it',
|
|
223
|
+
code='DATASAFETY_INCOMPLETE')
|
|
224
|
+
safety.confirm(ctx, f'Replace the data safety declaration of {ctx.project.package}',
|
|
225
|
+
[f'collects data: {info["collects_data"]}',
|
|
226
|
+
f'data types: {", ".join(info["data_types"]) or "none"}'])
|
|
227
|
+
if ctx.dry_run:
|
|
228
|
+
return {'dry_run': True, 'rows': info['rows']}
|
|
229
|
+
svc = auth.play(ctx)
|
|
230
|
+
auth.retry(lambda: svc.applications().dataSafety(packageName=ctx.project.package,
|
|
231
|
+
body={'safetyLabels': info['text']}), 'data safety')
|
|
232
|
+
if not ctx.out.json:
|
|
233
|
+
ctx.out.line('data safety declaration uploaded', 'green')
|
|
234
|
+
return {'uploaded': True, 'rows': info['rows']}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@command('testers show', 'Google Groups on each testing track', risk='read', needs=('play',))
|
|
238
|
+
def testers_show(ctx, args):
|
|
239
|
+
rows = []
|
|
240
|
+
with play.Edit(ctx) as edit:
|
|
241
|
+
for t in edit.tracks():
|
|
242
|
+
if t['track'] == 'production':
|
|
243
|
+
continue
|
|
244
|
+
groups = edit.call(lambda e, p, i, tr=t['track']: e.testers().get(
|
|
245
|
+
packageName=p, editId=i, track=tr), f'testers {t["track"]}').get('googleGroups', [])
|
|
246
|
+
rows.append({'track': t['track'], 'googleGroups': groups})
|
|
247
|
+
if not ctx.out.json:
|
|
248
|
+
ctx.out.table([(r['track'], ', '.join(r['googleGroups']) or '-') for r in rows], ('track', 'groups'))
|
|
249
|
+
ctx.out.line('email tester lists are Console-only (see `mkit manual`)', 'dim')
|
|
250
|
+
return {'tracks': rows}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@command('testers set', 'Set the Google Groups on a testing track', risk='publish', needs=('play',),
|
|
254
|
+
args=(Arg('track', 'testing track', kind='positional'),
|
|
255
|
+
Arg('groups', 'group emails', kind='positional', nargs='+')))
|
|
256
|
+
def testers_set(ctx, args):
|
|
257
|
+
track = play.track_id(args.track)
|
|
258
|
+
safety.confirm(ctx, f'Set testers of {track} to {", ".join(args.groups)}')
|
|
259
|
+
with play.Edit(ctx) as edit:
|
|
260
|
+
edit.call(lambda e, p, i: e.testers().update(packageName=p, editId=i, track=track,
|
|
261
|
+
body={'googleGroups': args.groups}), 'testers')
|
|
262
|
+
edit.finish(ctx.dry_run, in_review='error')
|
|
263
|
+
return {'track': track, 'googleGroups': args.groups, 'dry_run': ctx.dry_run}
|
mkit/config.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Project settings: find mkit.toml, read it, and answer "is this feature on?".
|
|
2
|
+
|
|
3
|
+
A project is the folder holding mkit.toml. mkit walks up from where it was
|
|
4
|
+
started, so it works from any subfolder of the app, the same way git does.
|
|
5
|
+
|
|
6
|
+
Settings are layered, first match wins:
|
|
7
|
+
|
|
8
|
+
command flags > MKIT_* environment > <project>/mkit.toml
|
|
9
|
+
> ~/.mkit/config.toml > built-in defaults
|
|
10
|
+
|
|
11
|
+
A table in mkit.toml switches a feature on. `[firebase]` present means this app
|
|
12
|
+
uses Firebase; absent means every Firebase step is skipped without a word, since
|
|
13
|
+
the app simply does not use it. That is how one pipeline serves apps that need
|
|
14
|
+
different parts of it.
|
|
15
|
+
"""
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import tomllib
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .errors import MkitError, NotFound, UsageError
|
|
24
|
+
|
|
25
|
+
PROJECT_FILE = 'mkit.toml'
|
|
26
|
+
KINDS = ('flutter', 'android', 'android-wear')
|
|
27
|
+
|
|
28
|
+
# Tables that switch a feature on by being present. Order is how doctor lists them.
|
|
29
|
+
FEATURES = ('play', 'store', 'firebase', 'notify.telegram', 'aso', 'art', 'hooks')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def home():
|
|
33
|
+
"""User-level folder shared by every project: config.toml and .env."""
|
|
34
|
+
override = os.environ.get('MKIT_HOME')
|
|
35
|
+
return Path(override) if override else Path.home() / '.mkit'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_root(start=None):
|
|
39
|
+
"""The nearest folder at or above `start` that holds mkit.toml, or None."""
|
|
40
|
+
here = Path(start or os.getcwd()).resolve()
|
|
41
|
+
for folder in (here, *here.parents):
|
|
42
|
+
if (folder / PROJECT_FILE).is_file():
|
|
43
|
+
return folder
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_toml(path):
|
|
48
|
+
try:
|
|
49
|
+
with open(path, 'rb') as f:
|
|
50
|
+
return tomllib.load(f)
|
|
51
|
+
except tomllib.TOMLDecodeError as e:
|
|
52
|
+
raise UsageError(f'{path} is not valid TOML: {e}',
|
|
53
|
+
hint='Fix the file; `mkit doctor` re-checks it.',
|
|
54
|
+
code='BAD_CONFIG') from e
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _version_tuple(text):
|
|
58
|
+
return tuple(int(p) for p in re.findall(r'\d+', text)[:3])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_requires(spec, have=__version__):
|
|
62
|
+
"""True when the running engine satisfies `requires = ">=x.y"`."""
|
|
63
|
+
m = re.fullmatch(r'\s*>=\s*([\d.]+)\s*', spec or '')
|
|
64
|
+
if not m:
|
|
65
|
+
raise UsageError(f'requires = "{spec}" is not understood',
|
|
66
|
+
hint='Use the form requires = ">=0.1".', code='BAD_CONFIG')
|
|
67
|
+
return _version_tuple(have) >= _version_tuple(m.group(1))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _dig(data, dotted):
|
|
71
|
+
node = data
|
|
72
|
+
for part in dotted.split('.'):
|
|
73
|
+
if not isinstance(node, dict) or part not in node:
|
|
74
|
+
return None
|
|
75
|
+
node = node[part]
|
|
76
|
+
return node
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Project:
|
|
81
|
+
root: Path
|
|
82
|
+
data: dict = field(default_factory=dict)
|
|
83
|
+
user: dict = field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def name(self):
|
|
87
|
+
return self.get('app.name') or self.root.name
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def kind(self):
|
|
91
|
+
return self.get('app.kind')
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def package(self):
|
|
95
|
+
return self.get('app.package')
|
|
96
|
+
|
|
97
|
+
def has(self, feature):
|
|
98
|
+
"""Whether the feature's table is in mkit.toml (the app opted in)."""
|
|
99
|
+
return isinstance(_dig(self.data, feature), dict)
|
|
100
|
+
|
|
101
|
+
def section(self, feature):
|
|
102
|
+
found = _dig(self.data, feature)
|
|
103
|
+
return found if isinstance(found, dict) else {}
|
|
104
|
+
|
|
105
|
+
def get(self, dotted, default=None):
|
|
106
|
+
"""Environment first (MKIT_APP_PACKAGE for app.package), then the
|
|
107
|
+
project file, then the user file."""
|
|
108
|
+
env = os.environ.get('MKIT_' + dotted.replace('.', '_').replace('-', '_').upper())
|
|
109
|
+
if env is not None:
|
|
110
|
+
return env
|
|
111
|
+
for source in (self.data, self.user):
|
|
112
|
+
value = _dig(source, dotted)
|
|
113
|
+
if value is not None:
|
|
114
|
+
return value
|
|
115
|
+
return default
|
|
116
|
+
|
|
117
|
+
def path(self, dotted, default=None):
|
|
118
|
+
"""A project-relative path setting, resolved against the project root."""
|
|
119
|
+
value = self.get(dotted, default)
|
|
120
|
+
return (self.root / value) if value else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load(start=None, project=None):
|
|
124
|
+
"""Load the project containing `start` (or the explicit `project` folder)."""
|
|
125
|
+
root = Path(project).resolve() if project else find_root(start)
|
|
126
|
+
if root is None or not (root / PROJECT_FILE).is_file():
|
|
127
|
+
where = project or os.getcwd()
|
|
128
|
+
raise NotFound(f'no {PROJECT_FILE} in {where} or any folder above it',
|
|
129
|
+
hint='Run `mkit init` in the app folder to create one.',
|
|
130
|
+
code='NO_PROJECT')
|
|
131
|
+
data = _read_toml(root / PROJECT_FILE)
|
|
132
|
+
user_file = home() / 'config.toml'
|
|
133
|
+
user = _read_toml(user_file) if user_file.is_file() else {}
|
|
134
|
+
|
|
135
|
+
spec = data.get('requires')
|
|
136
|
+
if spec and not check_requires(spec):
|
|
137
|
+
raise MkitError(f'this project needs mkit {spec}, but {__version__} is installed',
|
|
138
|
+
hint='Run `mkit self update`: it upgrades a git checkout, or names '
|
|
139
|
+
'the command for the package manager that installed mkit.',
|
|
140
|
+
code='ENGINE_TOO_OLD')
|
|
141
|
+
|
|
142
|
+
kind = _dig(data, 'app.kind')
|
|
143
|
+
if kind is not None and kind not in KINDS:
|
|
144
|
+
raise UsageError(f'app.kind = "{kind}" is not one of {", ".join(KINDS)}',
|
|
145
|
+
code='BAD_CONFIG')
|
|
146
|
+
return Project(root=root, data=data, user=user)
|
mkit/data/art/art.toml
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Store art for this app, built by `mkit art build` (docs/store-art.md).
|
|
2
|
+
# Paths are relative to the app folder (where mkit.toml is).
|
|
3
|
+
|
|
4
|
+
layout = "classic" # "classic" (words over one phone, soft gradient) or "pop"
|
|
5
|
+
# (vivid ground, bold underlined titles, big phones, pills; see below)
|
|
6
|
+
reference = "en" # the captions file every language is measured against
|
|
7
|
+
|
|
8
|
+
font_dir = "assets/fonts" # holds <font>-Regular/-Medium/-SemiBold/-Bold.ttf
|
|
9
|
+
font = "Barlow" # the app's own typeface; draws every Latin-script language
|
|
10
|
+
mascot = "assets/icon/icon_foreground.svg" # SVG or PNG: hero brand, feature graphic, notification icon
|
|
11
|
+
decoration = "contour" # classic's backdrop: "contour" (topographic rings), "leaves" (sprigs) or "none"
|
|
12
|
+
|
|
13
|
+
# The status bar. mode = "redraw" (classic's default): the phone's own icons
|
|
14
|
+
# are erased and a clean 9:41 bar is drawn over the capture. Boxes are [left,
|
|
15
|
+
# top, right, bottom] in capture pixels.
|
|
16
|
+
# mode = "clean" (pop's default): the capture keeps its own bar, painted over
|
|
17
|
+
# up to `height` (default 96) in the colour at `sample`, with a 9:41, signal
|
|
18
|
+
# and battery drawn in dark or light ink to suit it.
|
|
19
|
+
[status_bar]
|
|
20
|
+
height = 100
|
|
21
|
+
erase = [[36, 8, 250, 96], [760, 8, 1048, 96]]
|
|
22
|
+
# mode = "clean"
|
|
23
|
+
# sample = [540, 60]
|
|
24
|
+
|
|
25
|
+
# Anything private in a capture (an account name, an email) is erased the same way.
|
|
26
|
+
# [shots.home]
|
|
27
|
+
# erase = [[394, 166, 568, 226]]
|
|
28
|
+
|
|
29
|
+
# Colours, when the app's look differs from the default green. Any key can be left out.
|
|
30
|
+
# [theme.light]
|
|
31
|
+
# bg1 = "#F4F7EF" # background gradient, top
|
|
32
|
+
# bg2 = "#DCEAD4" # background gradient, bottom
|
|
33
|
+
# line = "#4E9150" # contour lines, or the sprigs
|
|
34
|
+
# line_op = 0.20
|
|
35
|
+
# ink = "#1F2A21" # headline
|
|
36
|
+
# sub = "#4D5C50" # subline
|
|
37
|
+
# accent = "#2F7A3E" # [accent] words, brand name
|
|
38
|
+
# tile = "#E3EFDD" # notification icon tile
|
|
39
|
+
# chip_border = "rgba(78,145,80,.28)"
|
|
40
|
+
# [theme.dark] # for slides with dark = true
|
|
41
|
+
# bg1 = "#0E1210"
|
|
42
|
+
# bg2 = "#16261A"
|
|
43
|
+
# ink = "#F3F6F1"
|
|
44
|
+
# accent = "#A5D69B"
|
|
45
|
+
#
|
|
46
|
+
# With layout = "pop" the same tables hold pop's colours instead (defaults shown;
|
|
47
|
+
# [theme.dark] defaults to the light ones with bg1/bg2/bg3 = #1F3D29/#122419/#0B140E,
|
|
48
|
+
# bg_mid = 50 and title_shadow = "none"):
|
|
49
|
+
# [theme.light]
|
|
50
|
+
# bg1 = "#55D27E" # the ground: a radial glow from the top...
|
|
51
|
+
# bg2 = "#26AC52"
|
|
52
|
+
# bg3 = "#168A3E" # ...to the bottom
|
|
53
|
+
# bg_mid = 48 # where bg2 sits, in percent
|
|
54
|
+
# blob_dark = "#0F7A34" # soft shade, bottom left
|
|
55
|
+
# blob_light = "#7BE39B" # soft light, top right
|
|
56
|
+
# glow = "#FFFFFF" # halo and ring behind the phones, sprigs, dots, chip glass
|
|
57
|
+
# ink = "#FFFFFF" # titles, the name, chips
|
|
58
|
+
# title_shadow = "0 4px 18px rgba(5,60,25,.25)"
|
|
59
|
+
# underline = "#FFD95A" # the hand-drawn line under the [word]
|
|
60
|
+
# accent = "#1E9E4A" # ticks, the go pill, the notification tile
|
|
61
|
+
# pill = "#FFFFFF" # pills and the notification card
|
|
62
|
+
# pill_ink = "#1B2A1F"
|
|
63
|
+
# shade = "#043214" # every shadow, at its own strength
|
|
64
|
+
# phone = "#141816" # the phone frame
|
|
65
|
+
|
|
66
|
+
# Fallback fonts for other scripts (Windows names), by listing code:
|
|
67
|
+
# [script_fonts]
|
|
68
|
+
# ka = "Sylfaen"
|
|
69
|
+
|
|
70
|
+
# The screenshots, in Play order (up to 8). Each id needs an entry in every
|
|
71
|
+
# captions file. kind = "landscape" is the 1920x1080 hero with three phones;
|
|
72
|
+
# the rest are 1080x1920 slides with one phone. shot = a file in shots/.
|
|
73
|
+
[[slide]]
|
|
74
|
+
id = "hero"
|
|
75
|
+
kind = "landscape"
|
|
76
|
+
shots = ["detail", "home", "home_dark"] # left, middle (front), right
|
|
77
|
+
|
|
78
|
+
[[slide]]
|
|
79
|
+
id = "home"
|
|
80
|
+
shot = "home"
|
|
81
|
+
|
|
82
|
+
[[slide]]
|
|
83
|
+
id = "alerts"
|
|
84
|
+
shot = "alerts"
|
|
85
|
+
notify = true # a phone notification card over the phone (captions: notify)
|
|
86
|
+
|
|
87
|
+
[[slide]]
|
|
88
|
+
id = "detail"
|
|
89
|
+
shot = "detail"
|
|
90
|
+
|
|
91
|
+
[[slide]]
|
|
92
|
+
id = "home_dark"
|
|
93
|
+
shot = "home_dark"
|
|
94
|
+
dark = true
|
|
95
|
+
|
|
96
|
+
# offset = 520 on a slide scrolls its shot up by that many capture pixels.
|
|
97
|
+
|
|
98
|
+
# Pop only: pills and pictures on a slide, in page pixels (left, top);
|
|
99
|
+
# right-to-left pages mirror them. The hero takes them too, and the feature
|
|
100
|
+
# graphic as [[feature.pill]] / [[feature.art]]. In pop the hero fans two or
|
|
101
|
+
# three `shots`, and a slide may set notify_y (the notification card's top, default 600).
|
|
102
|
+
# [[slide.pill]]
|
|
103
|
+
# text = "Lemons" # literal, never translated (an item named on the capture)...
|
|
104
|
+
# key = "add" # ...or translated: captions <slide>.pills.add
|
|
105
|
+
# go = true # the green action pill
|
|
106
|
+
# x = 40
|
|
107
|
+
# y = 1130
|
|
108
|
+
# size = 50 # font size (default 44)
|
|
109
|
+
# rotate = -5
|
|
110
|
+
# [[slide.art]]
|
|
111
|
+
# src = "store/art/mascot.svg" # PNG or SVG in the app repo
|
|
112
|
+
# x = 760
|
|
113
|
+
# y = 360
|
|
114
|
+
# height = 240
|
|
115
|
+
# rotate = 10
|
|
116
|
+
# z = 6 # stacking (default 4; phones 1-2, pills 5)
|
|
117
|
+
|
|
118
|
+
[feature] # the 1024x500 feature graphic
|
|
119
|
+
shot = "home" # pop: shots = ["back", "front"] for two phones
|
mkit/data/art/en.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"locale": "en-US",
|
|
3
|
+
"dir": "ltr",
|
|
4
|
+
"hero": {
|
|
5
|
+
"name": "My App",
|
|
6
|
+
"title": "The one line that says what the app is [for you]",
|
|
7
|
+
"sub": "What it does for people, in about ten words",
|
|
8
|
+
"chips": ["First feature", "Second feature", "Third feature"]
|
|
9
|
+
},
|
|
10
|
+
"home": {
|
|
11
|
+
"title": "A benefit, not a feature [in bold]",
|
|
12
|
+
"sub": "One sentence that backs the headline up"
|
|
13
|
+
},
|
|
14
|
+
"alerts": {
|
|
15
|
+
"title": "Never miss [the moment] again",
|
|
16
|
+
"sub": "What the notification does for them",
|
|
17
|
+
"notify": {
|
|
18
|
+
"app": "My App",
|
|
19
|
+
"when": "now",
|
|
20
|
+
"title": "The app's real notification title",
|
|
21
|
+
"body": "The app's real notification text."
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"detail": {
|
|
25
|
+
"title": "Everything [in one place]",
|
|
26
|
+
"sub": "What this screen gives them"
|
|
27
|
+
},
|
|
28
|
+
"home_dark": {
|
|
29
|
+
"title": "Easy on the eyes, [day or night]",
|
|
30
|
+
"sub": "A full dark theme with the same clear view"
|
|
31
|
+
},
|
|
32
|
+
"feature": {
|
|
33
|
+
"name": "My App",
|
|
34
|
+
"title": "The promise [in five words]",
|
|
35
|
+
"sub": "The search term people use for this kind of app",
|
|
36
|
+
"chips": ["First feature", "Second feature", "Third feature"]
|
|
37
|
+
}
|
|
38
|
+
}
|