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/commands/init.py ADDED
@@ -0,0 +1,213 @@
1
+ """`mkit init`: link an app folder to the engine.
2
+
3
+ Writes three small files and nothing else:
4
+
5
+ mkit.toml settings, detected from the project, committed
6
+ mkit.ps1 PowerShell launcher (./mkit): one Ctrl+C stops it
7
+ mkit.bat cmd launcher: runs the installed engine on this folder
8
+ mkit.sh macOS/Linux launcher, same thing
9
+
10
+ and makes sure `.env` (this app's secrets) is gitignored. Existing files are
11
+ left alone unless --force is given.
12
+ """
13
+ import re
14
+ from pathlib import Path
15
+
16
+ from .. import __version__
17
+ from ..errors import UsageError
18
+ from ..registry import Arg, command
19
+
20
+ # No < > | & here: the text is echoed by cmd.exe, which reads those as redirects.
21
+ # The engine that writes a new mkit.toml is the oldest one known to read it.
22
+ REQUIRES = '.'.join(__version__.split('.')[:2])
23
+
24
+ INSTALL_HINT = 'Install it with: uv tool install mkit-cli (or pipx install mkit-cli)'
25
+
26
+ # mkit.exe by full name: plain `mkit` from inside mkit.bat can resolve to this
27
+ # very file (cmd searches the current folder first) and call itself forever.
28
+ BAT = f'''@echo off
29
+ where mkit.exe >nul 2>nul || (echo mkit is not installed. {INSTALL_HINT} 1>&2 & exit /b 9009)
30
+ mkit.exe --project "%~dp0." %*
31
+ '''
32
+
33
+ # PowerShell runs .\mkit.ps1 ahead of .\mkit.bat for `./mkit`, and a script,
34
+ # unlike a batch file, stops on one Ctrl+C: cmd's "Terminate batch job (Y/N)?"
35
+ # only exists for .bat/.cmd.
36
+ PS1 = f'''# mkit launcher for PowerShell: ./mkit run
37
+ if (-not (Get-Command mkit.exe -ErrorAction SilentlyContinue)) {{
38
+ Write-Error 'mkit is not installed. {INSTALL_HINT}'
39
+ exit 9009
40
+ }}
41
+ & mkit.exe --project $PSScriptRoot @args
42
+ exit $LASTEXITCODE
43
+ '''
44
+
45
+ SH = f'''#!/usr/bin/env sh
46
+ set -eu
47
+ command -v mkit >/dev/null 2>&1 || {{ echo "mkit is not installed. {INSTALL_HINT}" >&2; exit 127; }}
48
+ exec mkit --project "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" "$@"
49
+ '''
50
+
51
+ _APP_ID = re.compile(r'''applicationId\s*=?\s*["']([\w.]+)["']''')
52
+
53
+
54
+ def _read(path):
55
+ try:
56
+ return path.read_text(encoding='utf-8', errors='replace')
57
+ except OSError:
58
+ return ''
59
+
60
+
61
+ def detect(root):
62
+ """(kind, package, name) guessed from the files present; any may be None."""
63
+ root = Path(root)
64
+ if (root / 'pubspec.yaml').is_file():
65
+ kind = 'flutter'
66
+ gradle_dir = root / 'android' / 'app'
67
+ m = re.search(r'^name:\s*(\S+)', _read(root / 'pubspec.yaml'), re.M)
68
+ name = m.group(1).replace('_', ' ').title().replace(' ', '') if m else None
69
+ else:
70
+ gradle_dir = root / 'app'
71
+ wear = (root / 'wear').is_dir() and any((root / 'wear' / f).is_file()
72
+ for f in ('build.gradle.kts', 'build.gradle'))
73
+ kind = 'android-wear' if wear else 'android'
74
+ name = None
75
+ for settings in ('settings.gradle.kts', 'settings.gradle'):
76
+ m = re.search(r'rootProject\.name\s*=\s*["\']([^"\']+)', _read(root / settings))
77
+ if m:
78
+ name = m.group(1)
79
+ break
80
+ package = None
81
+ for f in ('build.gradle.kts', 'build.gradle'):
82
+ m = _APP_ID.search(_read(gradle_dir / f))
83
+ if m:
84
+ package = m.group(1)
85
+ break
86
+ if not (gradle_dir / 'build.gradle.kts').is_file() and not (gradle_dir / 'build.gradle').is_file():
87
+ kind = kind if kind == 'flutter' else None
88
+ return kind, package, name or root.name
89
+
90
+
91
+ def render_toml(name, kind, package):
92
+ return f'''# mkit settings for {name}. Secrets never go here: they live in .env
93
+ # (this app) and ~/.mkit/.env (all apps). `mkit doctor` checks everything.
94
+ requires = ">={REQUIRES}"
95
+
96
+ [app]
97
+ name = "{name}"
98
+ kind = "{kind}" # flutter | android | android-wear
99
+ package = "{package}"
100
+ activity = ".MainActivity"
101
+
102
+ # A table switches a feature on. Leave one out and every step that uses it is
103
+ # skipped quietly, because this app does not use it.
104
+
105
+ # [play]
106
+ # internal_testing_url = ""
107
+
108
+ # [store]
109
+ # listing_dir = "store/listing" # everything the store shows lives under store/
110
+ # art_dir = "store"
111
+ # icon = "android/app/src/main/ic_launcher-playstore.png"
112
+ # whatsnew_dir = "distribution/whatsnew"
113
+
114
+ # [firebase]
115
+ # rules = true
116
+ # update_doc = "config/app"
117
+
118
+ # [notify.telegram]
119
+ '''
120
+
121
+
122
+ ENV_TEMPLATE = """\
123
+ # Secrets for this app. Gitignored, and never committed - `mkit doctor` names
124
+ # the key it wants when one is missing, and prints none of them.
125
+ #
126
+ # Each is the service account JSON, base64 encoded, on one line:
127
+ # Windows : [Convert]::ToBase64String([IO.File]::ReadAllBytes("key.json"))
128
+ # macOS : base64 -i key.json
129
+ # Linux : base64 -w0 key.json
130
+ #
131
+ # Anything every app shares goes in ~/.mkit/.env instead of here.
132
+
133
+ # Google Play, for releases, the listing, products, reviews and vitals.
134
+ # Play Console > Users and permissions, invite the service account.
135
+ # MKIT_PLAY_SERVICE_ACCOUNT_JSON_BASE64=
136
+
137
+ # Firebase, for Firestore rules, Remote Config and the update document.
138
+ # Project settings > Service accounts > Generate new private key.
139
+ # MKIT_FIREBASE_SERVICE_ACCOUNT_JSON_BASE64=
140
+
141
+ # A Telegram bot, if this app sends a message after a release.
142
+ # {"bot_token": "...", "chat_id": 123}, base64 encoded.
143
+ # MKIT_TELEGRAM_JSON_BASE64=
144
+ """
145
+
146
+
147
+ def _ensure_gitignored(root, entry='.env'):
148
+ gi = root / '.gitignore'
149
+ text = _read(gi)
150
+ lines = [l.strip() for l in text.splitlines()]
151
+ if entry in lines or f'/{entry}' in lines:
152
+ return False
153
+ with open(gi, 'a', encoding='utf-8', newline='\n') as f:
154
+ if text and not text.endswith('\n'):
155
+ f.write('\n')
156
+ f.write(f'\n# mkit: this app\'s secrets\n{entry}\n')
157
+ return True
158
+
159
+
160
+ @command('init', 'Link this app folder to mkit (mkit.toml + launchers)', risk='write',
161
+ needs_project=False,
162
+ args=(Arg('path', 'app folder (default: current folder)', kind='positional', nargs='?'),
163
+ Arg('kind', 'override the detected kind', choices=('flutter', 'android', 'android-wear')),
164
+ Arg('package', 'override the detected package id'),
165
+ Arg('name', 'override the detected app name'),
166
+ Arg('force', 'overwrite existing mkit.toml and launchers', kind='flag')),
167
+ examples=('mkit init', 'mkit init H:/flutter/onGoing/listkin --name Listkin'))
168
+ def init(ctx, args):
169
+ root = Path(args.path or '.').resolve()
170
+ if not root.is_dir():
171
+ raise UsageError(f'{root} is not a folder')
172
+ kind, package, name = detect(root)
173
+ kind = args.kind or kind
174
+ package = args.package or package
175
+ name = args.name or name
176
+ if not kind:
177
+ raise UsageError(f'could not tell what kind of app {root} is',
178
+ hint='Pass --kind flutter|android|android-wear.')
179
+
180
+ written, kept = [], []
181
+ files = {'mkit.toml': render_toml(name, kind, package or ''), 'mkit.bat': BAT, 'mkit.ps1': PS1,
182
+ 'mkit.sh': SH}
183
+ # The .env is the step everybody gets stuck on: which keys, in what format,
184
+ # from where. A commented skeleton answers all three in the folder where it
185
+ # is needed. It is never replaced, not even with --force: that file holds
186
+ # the only copy of somebody's credentials.
187
+ if not (root / '.env').exists():
188
+ files['.env'] = ENV_TEMPLATE
189
+ else:
190
+ kept.append('.env (exists; left alone)')
191
+ for fname, content in files.items():
192
+ target = root / fname
193
+ if target.exists() and not args.force:
194
+ kept.append(fname)
195
+ continue
196
+ newline = '\r\n' if fname.endswith('.bat') else '\n'
197
+ with open(target, 'w', encoding='utf-8', newline=newline) as f:
198
+ f.write(content)
199
+ written.append(fname)
200
+ if _ensure_gitignored(root):
201
+ written.append('.gitignore (.env)')
202
+
203
+ out = ctx.out
204
+ if not out.json:
205
+ out.heading(f'{name} {kind} {package or "package not found"}')
206
+ for f in written:
207
+ out.line(f' wrote {f}', 'green')
208
+ for f in kept:
209
+ out.line(f' kept {f} (exists; --force to replace)', 'dim')
210
+ out.line()
211
+ out.line('Next: add secrets to .env, switch features on in mkit.toml, then `mkit doctor`.')
212
+ return {'root': str(root), 'kind': kind, 'package': package, 'name': name,
213
+ 'written': written, 'kept': kept}
@@ -0,0 +1,110 @@
1
+ """How the app is doing: installs, conversion, vitals, crashes, reviews."""
2
+ from datetime import datetime, timezone
3
+
4
+ from .. import safety
5
+ from ..cloud import auth, insight
6
+ from ..registry import Arg, command, group
7
+
8
+ group('reviews', 'Play reviews: read and reply')
9
+
10
+
11
+ def _wrap(fn, what):
12
+ try:
13
+ return fn()
14
+ except Exception as e:
15
+ raise auth.translate(e, what) from None
16
+
17
+
18
+ @command('stats', 'Installs, uninstalls, active devices, store conversion, ratings, countries',
19
+ risk='read', needs=('play',),
20
+ args=(Arg('days', 'daily rows to show (human output)', type=int, default=14),))
21
+ def stats(ctx, args):
22
+ data = _wrap(lambda: insight.stats(ctx), 'Play reports bucket')
23
+ out = ctx.out
24
+ if not out.json:
25
+ if data['window']:
26
+ out.line(f'{ctx.project.name} {data["window"][0]} to {data["window"][1]} '
27
+ '(Play reports lag 3-7 days)', 'dim')
28
+ s, i, r = data['store'], data['installs'], data['ratings']
29
+ out.heading('store listing')
30
+ out.line(f' visitors {s["visitors"]} acquisitions {s["acquisitions"]}'
31
+ + (f' conversion {s["conversion"]}%' if s['conversion'] is not None else ''))
32
+ out.heading('installs')
33
+ out.line(f' installs {i["installs"]} uninstalls {i["uninstalls"]} '
34
+ f'net {i["installs"] - i["uninstalls"]:+d}')
35
+ out.line(f' active devices: latest {i["active_latest"]}, peak {i["active_peak"]}')
36
+ out.heading('ratings')
37
+ out.line(f' {r["count"]} rating(s)' + (f', average {r["average"]}' if r['average'] else ''))
38
+ if data['countries']:
39
+ out.heading('installs by country')
40
+ out.line(' ' + ' '.join(f'{c} {n}' for c, n in list(data['countries'].items())[:12] if n))
41
+ if data['daily']:
42
+ out.heading('daily')
43
+ out.table([(d['date'], d['installs'], d['uninstalls'], d['active'])
44
+ for d in data['daily'][-args.days:]], ('date', 'installs', 'uninstalls', 'active'))
45
+ return data
46
+
47
+
48
+ @command('vitals', 'Crash and ANR rates per version (Play Developer Reporting API)', risk='read',
49
+ needs=('play',), args=(Arg('days', 'window in days', type=int, default=28),))
50
+ def vitals(ctx, args):
51
+ data = insight.vitals(ctx, args.days)
52
+ if not ctx.out.json:
53
+ for key, label, rate, perceived in (('crash', 'crashes', 'crashRate', 'userPerceivedCrashRate'),
54
+ ('anr', 'ANRs', 'anrRate', 'userPerceivedAnrRate')):
55
+ block = data[key]
56
+ ctx.out.heading(f'{label}' + (f' {block["window"][0]} to {block["window"][1]}' if block.get('window') else ''))
57
+ if not block['rows']:
58
+ ctx.out.line(f' {block.get("note", "no data")}')
59
+ continue
60
+ ctx.out.table([(r['versionCode'], r['users'], f'{r.get(rate, 0):.2f}%', f'{r.get(perceived, 0):.2f}%')
61
+ for r in block['rows']], ('versionCode', 'users', label, 'user-perceived'))
62
+ ctx.out.line()
63
+ ctx.out.line("Play's bad-behaviour thresholds: user-perceived crashes 1.09%, ANRs 0.47%", 'dim')
64
+ return data
65
+
66
+
67
+ @command('crashes', 'Top crash and ANR clusters with where they happen', risk='read', needs=('play',),
68
+ args=(Arg('limit', 'how many', type=int, default=10),))
69
+ def crashes(ctx, args):
70
+ issues = insight.crashes(ctx, args.limit)
71
+ if not ctx.out.json:
72
+ if not issues:
73
+ ctx.out.line('no crash or ANR clusters reported')
74
+ for i in issues:
75
+ ctx.out.line(f' {i["reports"]:>5} reports {i["users"]:>4} users {i["type"]} '
76
+ f'v{i["firstVersion"]}-{i["lastVersion"]}')
77
+ ctx.out.line(f' {i["cause"] or ""}', 'bold')
78
+ ctx.out.line(f' {i["location"] or ""}', 'dim')
79
+ return {'issues': issues}
80
+
81
+
82
+ @command('reviews list', 'Reviews with text from the last 7 days (the API limit)', risk='read',
83
+ needs=('play',),
84
+ args=(Arg('limit', 'how many', type=int, default=50),
85
+ Arg('translate', 'translate into this language, e.g. en'),
86
+ Arg('unanswered', 'only reviews without a reply', kind='flag')))
87
+ def reviews_list(ctx, args):
88
+ items = _wrap(lambda: insight.reviews(ctx, args.limit, args.translate), 'Play reviews')
89
+ if args.unanswered:
90
+ items = [r for r in items if not r['replied']]
91
+ if not ctx.out.json:
92
+ if not items:
93
+ ctx.out.line('no reviews with text in the last 7 days (older ones: `mkit stats` has ratings)')
94
+ for r in items:
95
+ when = datetime.fromtimestamp(int(r['date']), timezone.utc).date() if r['date'] else ''
96
+ ctx.out.line(f' {"*" * (r["stars"] or 0):5} {when} {r["author"]} v{r["version"] or "?"} '
97
+ f'{"replied" if r["replied"] else "NO REPLY"} id {r["id"]}')
98
+ ctx.out.line(f' {r["text"]}')
99
+ return {'reviews': items}
100
+
101
+
102
+ @command('reviews reply', 'Reply to a review (public, 350 characters)', risk='publish', needs=('play',),
103
+ args=(Arg('id', 'review id from `mkit reviews list`', kind='positional'),
104
+ Arg('text', 'the reply', kind='positional')))
105
+ def reviews_reply(ctx, args):
106
+ safety.confirm(ctx, 'Post a public reply to a review', [args.text])
107
+ if ctx.dry_run:
108
+ return {'id': args.id, 'text': args.text, 'dry_run': True}
109
+ _wrap(lambda: insight.reply(ctx, args.id, args.text), 'Play reviews')
110
+ return {'id': args.id, 'replied': True}
mkit/commands/meta.py ADDED
@@ -0,0 +1,84 @@
1
+ """Commands that describe mkit itself: for agents first, humans second."""
2
+ from .. import __version__
3
+ from ..errors import EXIT_CODES, NotFound
4
+ from ..registry import GROUPS, REGISTRY, RISKS, Arg, command
5
+
6
+
7
+ @command('commands', 'List every command with its risk class and what it needs',
8
+ risk='read', needs_project=False,
9
+ examples=('mkit commands', 'mkit commands --json'))
10
+ def commands(ctx, args):
11
+ """Every registered command. In JSON mode each entry carries a JSON Schema of
12
+ its input, so an agent can build a valid call without reading help text."""
13
+ entries = [REGISTRY[n].describe() for n in sorted(REGISTRY)]
14
+ out = ctx.out
15
+ if not out.json:
16
+ for risk in RISKS:
17
+ rows = [(e['name'], e['summary']) for e in entries if e['risk'] == risk]
18
+ if rows:
19
+ out.heading(f'{risk}')
20
+ out.table(rows, ('command', 'what'))
21
+ out.line()
22
+ out.line('publish commands always ask first; agents pass --yes --confirm <package>.', 'dim')
23
+ return {'version': __version__, 'groups': GROUPS, 'commands': entries,
24
+ 'exit_codes': {str(k): v for k, v in EXIT_CODES.items()}}
25
+
26
+
27
+ @command('schema', 'Input schema and details of one command', risk='read', needs_project=False,
28
+ args=(Arg('name', 'command name, e.g. "deploy" or "listing plan"', kind='positional', nargs='+'),),
29
+ examples=('mkit schema deploy --json', 'mkit schema listing plan'))
30
+ def schema(ctx, args):
31
+ name = ' '.join(args.name)
32
+ if name not in REGISTRY:
33
+ raise NotFound(f'no command "{name}"', hint='`mkit commands` lists them all.')
34
+ entry = REGISTRY[name].describe()
35
+ entry['description'] = REGISTRY[name].description
36
+ if not ctx.out.json:
37
+ ctx.out.heading(f'mkit {name} [{entry["risk"]}]')
38
+ ctx.out.line(entry['description'] or entry['summary'])
39
+ props = entry['input']['properties']
40
+ if props:
41
+ ctx.out.table([(k, v.get('type'), v.get('description', '')) for k, v in props.items()],
42
+ ('argument', 'type', 'what'))
43
+ return entry
44
+
45
+
46
+ # What the Play Developer API cannot do, checked against the API reference on
47
+ # 2026-09-18. `applies` narrows the list to what matters for this app.
48
+ MANUAL = (
49
+ {'id': 'create-app', 'what': 'Create the app and make its first upload',
50
+ 'where': 'Play Console > Create app', 'applies': 'new apps only'},
51
+ {'id': 'privacy-policy', 'what': 'Set the privacy policy URL (needed before data safety)',
52
+ 'where': 'App content > Privacy policy', 'applies': 'every app'},
53
+ {'id': 'datasafety-export', 'what': 'Read back the data safety answers Play holds (the API only uploads; `mkit datasafety build` writes the CSV from mkit.toml, `apply` uploads it)',
54
+ 'where': 'App content > Data safety > Export to CSV', 'applies': 'only to check what is declared now'},
55
+ {'id': 'declarations', 'what': 'App content declarations other than data safety: ads, target '
56
+ 'audience, content rating, permissions and foreground services, account deletion URL',
57
+ 'where': 'App content', 'applies': 'every app, and again when the app changes'},
58
+ {'id': 'production-access', 'what': 'Production access for a new personal developer '
59
+ 'account: 12 testers opted in for 14 days of closed testing, then apply',
60
+ 'where': 'Play Console > Testing > Closed testing, then Production', 'applies': 'new personal accounts'},
61
+ {'id': 'app-access', 'what': 'Test credentials for an app behind a sign-in, so Play review '
62
+ 'can get past the login screen', 'where': 'App content > App access', 'applies': 'apps that need an account'},
63
+ {'id': 'managed-publishing', 'what': 'Turn managed publishing on/off, press Publish, and "Send '
64
+ 'for review" for changes held back', 'where': 'Publishing overview', 'applies': 'when used'},
65
+ {'id': 'tester-emails', 'what': 'Tester email lists (the API only takes Google Groups)',
66
+ 'where': 'Testing > Testers', 'applies': 'closed testing with email lists'},
67
+ {'id': 'track-countries', 'what': 'Countries for a testing track (the API can only read them)',
68
+ 'where': 'Testing > track > Countries/regions', 'applies': 'testing tracks'},
69
+ {'id': 'custom-listings', 'what': 'Custom store listings and store listing experiments',
70
+ 'where': 'Grow users > Store presence', 'applies': 'when used'},
71
+ {'id': 'crashlytics', 'what': 'Crashlytics issues (no stable service-account API; `mkit crashes` '
72
+ 'reads Play vitals instead)', 'where': 'Firebase console > Crashlytics', 'applies': 'firebase apps'},
73
+ )
74
+
75
+
76
+ @command('manual', 'What only Play Console can do (no API), as a checklist', risk='read',
77
+ needs_project=False, examples=('mkit manual',))
78
+ def manual(ctx, args):
79
+ if not ctx.out.json:
80
+ ctx.out.heading('Only in Play Console (no API)')
81
+ for m in MANUAL:
82
+ ctx.out.line(f' - {m["what"]}')
83
+ ctx.out.line(f' {m["where"]} ({m["applies"]})', 'dim')
84
+ return {'manual': list(MANUAL)}
mkit/commands/money.py ADDED
@@ -0,0 +1,152 @@
1
+ """In-app products, subscriptions and prices."""
2
+ from .. import safety
3
+ from ..cloud import auth, money
4
+ from ..errors import DRIFT
5
+ from ..registry import Arg, command, group
6
+
7
+ group('products', 'one-time products, their offers, and subscriptions')
8
+ group('prices', "Google Play's regional prices")
9
+
10
+
11
+ def _svc(ctx):
12
+ return auth.play(ctx, timeout=120), ctx.project.get('play.package') or ctx.project.package
13
+
14
+
15
+ @command('products list', 'What Play holds: every one-time product and subscription', risk='read',
16
+ needs=('play',))
17
+ def products_list(ctx, args):
18
+ svc, package = _svc(ctx)
19
+ try:
20
+ products, subs = money.list_remote(svc, package)
21
+ except Exception as e:
22
+ raise auth.translate(e, 'Play') from None
23
+ rows = []
24
+ for p in products:
25
+ for o in p.get('purchaseOptions', []) or [{}]:
26
+ rows.append({'kind': 'product', 'id': p['productId'], 'part': o.get('purchaseOptionId', ''),
27
+ 'state': o.get('state', ''), 'title': (p.get('listings') or [{}])[0].get('title', '')})
28
+ for s in subs:
29
+ for b in s.get('basePlans', []) or [{}]:
30
+ rows.append({'kind': 'subscription', 'id': s['productId'], 'part': b.get('basePlanId', ''),
31
+ 'state': b.get('state', ''), 'title': (s.get('listings') or [{}])[0].get('title', '')})
32
+ if not ctx.out.json:
33
+ if rows:
34
+ ctx.out.table([(r['kind'], r['id'], r['part'], r['state'], r['title']) for r in rows],
35
+ ('kind', 'id', 'option/plan', 'state', 'title'))
36
+ else:
37
+ ctx.out.line('no products or subscriptions on Play')
38
+ return {'items': rows}
39
+
40
+
41
+ def _show(out, changes):
42
+ if out.json:
43
+ return
44
+ if not changes:
45
+ out.line(' no changes: Play matches the products file', 'green')
46
+ for c in changes:
47
+ detail = ''
48
+ if c['kind'] == 'offer':
49
+ detail = _offer_detail(c)
50
+ elif c['kind'] == 'trial':
51
+ detail = {'create': f'free for {c["trial"]}, then activate',
52
+ 'update': f'length {c.get("from")} -> {c["trial"]}',
53
+ 'activate': f'state {c.get("state")} -> ACTIVE'}[c['action']]
54
+ elif c['action'] == 'update':
55
+ parts = []
56
+ if c['prices']:
57
+ parts.append('prices ' + ', '.join(f'{r} {v["from"]}->{v["to"]}' for r, v in sorted(c['prices'].items())))
58
+ if c['listings']:
59
+ parts.append('listing ' + ', '.join(c['listings']))
60
+ if c.get('state') != 'ACTIVE':
61
+ parts.append(f'state {c.get("state")} -> ACTIVE')
62
+ detail = '; '.join(parts)
63
+ out.line(f' {c["action"]:8} {c["kind"]:12} {c["id"]} {detail}')
64
+
65
+
66
+ def _day(value):
67
+ """2026-12-31T00:00:00Z reads as 2026-12-31; any other time stays whole."""
68
+ if isinstance(value, str) and value.endswith('T00:00:00Z'):
69
+ return value[:10]
70
+ return value
71
+
72
+
73
+ def _offer_detail(c):
74
+ window = c['body']['discountedOffer']
75
+ if c['action'] == 'create':
76
+ when = f'until {_day(window["endTime"])}'
77
+ if 'startTime' in window:
78
+ when = f'{_day(window["startTime"])} to {_day(window["endTime"])}'
79
+ return f'{c["discount_pct"]}% off in {c["regions"]} region(s), {when}, then activate'
80
+ parts = []
81
+ for field, v in c['changes'].items():
82
+ if field == 'regions':
83
+ parts.append(f'add region(s) {", ".join(v["added"])}')
84
+ elif field == 'discount_pct':
85
+ fix = f' (fix {", ".join(v["regions"])})' if v.get('regions') else ''
86
+ parts.append(f'discount {v["from"]}% -> {v["to"]}%{fix}')
87
+ elif field == 'tags':
88
+ parts.append(f'tags {v["from"]} -> {v["to"]}')
89
+ else:
90
+ parts.append(f'{field} {_day(v["from"]) or "none"} -> {_day(v["to"])}')
91
+ if c.get('state') != 'ACTIVE':
92
+ parts.append(f'state {c.get("state")} -> ACTIVE')
93
+ return '; '.join(parts)
94
+
95
+
96
+ def _public(changes):
97
+ return [{k: v for k, v in c.items() if k != 'body'} for c in changes]
98
+
99
+
100
+ @command('products plan', 'Compare the products file with Play (changes nothing)', risk='plan',
101
+ needs=('play', 'store'))
102
+ def products_plan(ctx, args):
103
+ data = money.load(ctx.project)
104
+ svc, package = _svc(ctx)
105
+ try:
106
+ changes = money.plan(svc, package, data)
107
+ except Exception as e:
108
+ raise auth.translate(e, 'Play') from None
109
+ _show(ctx.out, changes)
110
+ if changes:
111
+ ctx.exit_code = DRIFT
112
+ return {'changes': _public(changes)}
113
+
114
+
115
+ @command('products apply',
116
+ 'Create or update products, their offers and subscriptions to match the file, and activate them',
117
+ risk='publish', needs=('play', 'store'))
118
+ def products_apply(ctx, args):
119
+ data = money.load(ctx.project)
120
+ svc, package = _svc(ctx)
121
+ try:
122
+ changes = money.plan(svc, package, data)
123
+ _show(ctx.out, changes)
124
+ if not changes:
125
+ return {'changes': [], 'changed': False}
126
+ safety.confirm(ctx, f'Apply {len(changes)} product change(s) to {package}',
127
+ [f'{c["action"]} {c["kind"]} {c["id"]}' for c in changes])
128
+ if ctx.dry_run:
129
+ return {'changes': _public(changes), 'dry_run': True}
130
+ money.apply(svc, package, data, changes)
131
+ except Exception as e:
132
+ raise auth.translate(e, 'Play') from None
133
+ if not ctx.out.json:
134
+ ctx.out.line(f'{len(changes)} change(s) applied and activated', 'green')
135
+ return {'changes': _public(changes), 'changed': True}
136
+
137
+
138
+ @command('prices convert', "Google Play's own local price for every region, from one price",
139
+ risk='read', needs=('play',),
140
+ args=(Arg('amount', 'price, tax exclusive, e.g. 4.99', kind='positional', type=float),
141
+ Arg('currency', 'currency of the amount', default='USD')),
142
+ examples=('mkit prices convert 4.99',))
143
+ def prices_convert(ctx, args):
144
+ svc, package = _svc(ctx)
145
+ try:
146
+ result = money.convert(svc, package, args.amount, args.currency)
147
+ except Exception as e:
148
+ raise auth.translate(e, 'Play') from None
149
+ if not ctx.out.json:
150
+ ctx.out.table([(r, v['currency'], v['amount']) for r, v in sorted(result.items())],
151
+ ('region', 'currency', 'amount'))
152
+ return {'amount': args.amount, 'currency': args.currency, 'regions': result}