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/errors.py ADDED
@@ -0,0 +1,78 @@
1
+ """Errors and exit codes.
2
+
3
+ Every failure mkit reports is an MkitError: a stable machine code, a sentence a
4
+ human can read, and a hint that says what to do about it. The exit code is part
5
+ of the contract, so an agent can branch on it without parsing text.
6
+ """
7
+
8
+ OK = 0
9
+ FAILED = 1
10
+ USAGE = 2
11
+ AUTH = 3
12
+ NOT_FOUND = 4
13
+ CONFLICT = 5
14
+ NEEDS_CONFIRM = 6
15
+ PARTIAL = 7
16
+ DRIFT = 10
17
+ INTERRUPTED = 130
18
+
19
+ EXIT_CODES = {
20
+ OK: 'ok',
21
+ FAILED: 'failed',
22
+ USAGE: 'usage error',
23
+ AUTH: 'credential missing or rejected',
24
+ NOT_FOUND: 'not found',
25
+ CONFLICT: 'conflict: remote changed, edit expired, or already in review',
26
+ NEEDS_CONFIRM: 'needs confirmation (--yes --confirm <package>)',
27
+ PARTIAL: 'done, with skipped steps',
28
+ DRIFT: 'plan found differences',
29
+ INTERRUPTED: 'interrupted',
30
+ }
31
+
32
+
33
+ class MkitError(Exception):
34
+ exit_code = FAILED
35
+ code = 'FAILED'
36
+
37
+ def __init__(self, message, hint=None, code=None, retryable=False, lines=None):
38
+ super().__init__(message)
39
+ self.message = message
40
+ self.hint = hint
41
+ self.retryable = retryable
42
+ # The captured tail of a failed tool, shown as-is under the headline.
43
+ self.lines = lines or []
44
+ if code:
45
+ self.code = code
46
+
47
+ def as_dict(self):
48
+ out = {'code': self.code, 'message': self.message, 'retryable': self.retryable}
49
+ if self.hint:
50
+ out['hint'] = self.hint
51
+ if self.lines:
52
+ out['lines'] = self.lines
53
+ return out
54
+
55
+
56
+ class UsageError(MkitError):
57
+ exit_code = USAGE
58
+ code = 'USAGE'
59
+
60
+
61
+ class CredentialMissing(MkitError):
62
+ exit_code = AUTH
63
+ code = 'CREDENTIAL_MISSING'
64
+
65
+
66
+ class NotFound(MkitError):
67
+ exit_code = NOT_FOUND
68
+ code = 'NOT_FOUND'
69
+
70
+
71
+ class Conflict(MkitError):
72
+ exit_code = CONFLICT
73
+ code = 'CONFLICT'
74
+
75
+
76
+ class NeedsConfirm(MkitError):
77
+ exit_code = NEEDS_CONFIRM
78
+ code = 'NEEDS_CONFIRM'
mkit/git.py ADDED
@@ -0,0 +1,95 @@
1
+ """The git safety net around a release.
2
+
3
+ deploy commits in two steps before it touches Play: whatever was already
4
+ uncommitted gets a commit of its own (so it is never at risk), then the
5
+ version bump gets its own commit. If anything fails before Play accepts the
6
+ release, exactly that one bump commit is undone -- and only if it is still the
7
+ commit at HEAD, so a failure can never drop someone else's work.
8
+ """
9
+ from . import proc
10
+ from .toolchain import tool
11
+
12
+ SNAPSHOT_MESSAGE = 'chore: snapshot pending changes before release'
13
+ # How deploy names its version bump, so the last release can be found again.
14
+ BUMP_PREFIX = 'chore: bump version to '
15
+
16
+
17
+ class Repo:
18
+ def __init__(self, root):
19
+ self.root = root
20
+
21
+ def _git(self, *args, check=True):
22
+ return proc.run([tool('git'), *args], cwd=self.root, check=check)
23
+
24
+ def read(self, *args):
25
+ try:
26
+ return self._git(*args).strip()
27
+ except Exception:
28
+ return ''
29
+
30
+ def is_repo(self):
31
+ return self.read('rev-parse', '--is-inside-work-tree') == 'true'
32
+
33
+ def branch(self):
34
+ return self.read('rev-parse', '--abbrev-ref', 'HEAD')
35
+
36
+ def head(self, short=True):
37
+ return self.read('rev-parse', '--short' if short else '--verify', 'HEAD')
38
+
39
+ def dirty(self):
40
+ return [l for l in self._git('status', '--porcelain').splitlines() if l.strip()]
41
+
42
+ def snapshot(self):
43
+ """Commit everything already dirty on its own. Returns how many paths."""
44
+ dirty = self.dirty()
45
+ if dirty:
46
+ self._git('add', '-A')
47
+ self._git('commit', '-m', SNAPSHOT_MESSAGE)
48
+ return len(dirty)
49
+
50
+ def commit_files(self, files, message):
51
+ self._git('add', '--', *[str(f) for f in files])
52
+ self._git('commit', '-m', message)
53
+ return self.head(short=False)
54
+
55
+ def undo(self, expected_head):
56
+ """Drop HEAD only if it is still the commit we made."""
57
+ if self.head(short=False) != expected_head:
58
+ return False
59
+ self._git('reset', '--hard', 'HEAD~1')
60
+ return True
61
+
62
+ def last_release(self):
63
+ """The commit of the last version bump deploy made, or "" before the first."""
64
+ return self.read('log', '-1', '--format=%H', f'--grep=^{BUMP_PREFIX}')
65
+
66
+ def changed_since(self, commit, path):
67
+ """Whether path differs from commit, counting uncommitted and untracked files."""
68
+ return bool(self.read('diff', '--name-only', commit, '--', str(path))
69
+ or self.read('ls-files', '--others', '--exclude-standard', '--', str(path)))
70
+
71
+ def upstream(self):
72
+ """The branch this one pushes to, or "" when it has none."""
73
+ return self.read('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}')
74
+
75
+ def push(self):
76
+ """Push the current branch. Returns (ok, detail); never raises.
77
+
78
+ By the time a release pushes, the build is already on Play. A push that
79
+ cannot go - no remote, no upstream, a branch someone else moved - is a
80
+ thing to tell the user about, not a reason to end a finished release in
81
+ a traceback. The detail is what to say next, not a diagnosis.
82
+ """
83
+ upstream = self.upstream()
84
+ if not upstream:
85
+ return False, f'no upstream; run: git push -u origin {self.branch()}'
86
+ try:
87
+ self._git('push')
88
+ except Exception as e:
89
+ lines = [l for l in getattr(e, 'lines', []) if l.strip()]
90
+ return False, (lines[-1] if lines else str(e))[:200]
91
+ return True, upstream
92
+
93
+ def restore(self, files):
94
+ """Put files back to HEAD (a dry run's temporary bump)."""
95
+ self._git('checkout', '--', *[str(f) for f in files])
mkit/hooks.py ADDED
@@ -0,0 +1,32 @@
1
+ """Project hooks: a project's own scripts, run at fixed points of a pipeline.
2
+
3
+ [hooks]
4
+ pre_deploy = "python console/check.py"
5
+ post_build = "console/sign_extra.sh"
6
+ post_deploy = "python console/deploy_backend.py"
7
+
8
+ A hook runs in the project root with MKIT_VERSION_NAME / MKIT_VERSION_CODE /
9
+ MKIT_DRY_RUN set. A non-zero exit fails the step it belongs to, like any other
10
+ step. The command is split like a shell would, but runs without a shell.
11
+ """
12
+ import os
13
+ import shlex
14
+
15
+ from . import proc
16
+
17
+ POINTS = ('pre_deploy', 'post_build', 'post_deploy', 'post_promote')
18
+
19
+
20
+ def get(ctx, point):
21
+ return ctx.project.section('hooks').get(point)
22
+
23
+
24
+ def run(ctx, point, **env_values):
25
+ command = get(ctx, point)
26
+ if not command:
27
+ return None
28
+ argv = shlex.split(command, posix=os.name != 'nt')
29
+ env = dict(os.environ, MKIT_DRY_RUN='1' if ctx.dry_run else '0',
30
+ **{f'MKIT_{k.upper()}': str(v) for k, v in env_values.items()})
31
+ proc.stream(argv, f'hook {point}', lambda line: bool(line.strip()), cwd=ctx.project.root, env=env)
32
+ return command
mkit/kinds.py ADDED
@@ -0,0 +1,350 @@
1
+ """The three kinds of app, and the only things that differ between them:
2
+ where the version lives, how a build is run, and where its outputs land.
3
+
4
+ flutter version: x.y.z+n in pubspec.yaml; Gradle project in android/
5
+ android versionCode/versionName in <module>/build.gradle(.kts), or
6
+ version.properties at the root if the project keeps it there
7
+ android-wear android, plus a `wear` module shipped on Play's Wear track
8
+
9
+ Every bump moves the name AND the code, and the code by exactly 1 whatever the
10
+ level: Play only needs it to increase, users only ever see the name, and moving
11
+ one without the other ships a build Play takes but users read as the old one.
12
+
13
+ Files are read and written with newline='' so a bump changes the version and
14
+ nothing else. (Reading in text mode turns CRLF into LF, and the whole file
15
+ would go back out rewritten in the bump commit.)
16
+ """
17
+ import json
18
+ import re
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from . import proc
23
+ from .errors import MkitError, UsageError
24
+ from .toolchain import gradlew, tool
25
+
26
+ LEVELS = ('major', 'minor', 'patch')
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Module:
31
+ label: str # "phone" / "watch": what a person calls it
32
+ factor: str # "phone" / "wear": which Play track family it ships on
33
+ gradle: str # Gradle module name
34
+ aab: Path
35
+ mapping: Path
36
+
37
+
38
+ def bump_name(name, level):
39
+ if level not in LEVELS:
40
+ raise UsageError(f'bump must be one of {", ".join(LEVELS)}, not "{level}"')
41
+ parts = [int(p) for p in re.findall(r'\d+', name)[:3]]
42
+ while len(parts) < 3:
43
+ parts.append(0)
44
+ major, minor, patch = parts
45
+ if level == 'major':
46
+ return f'{major + 1}.0.0'
47
+ if level == 'minor':
48
+ return f'{major}.{minor + 1}.0'
49
+ return f'{major}.{minor}.{patch + 1}'
50
+
51
+
52
+ def _read(path):
53
+ try:
54
+ with open(path, encoding='utf-8', newline='') as f:
55
+ return f.read()
56
+ except FileNotFoundError:
57
+ raise MkitError(f'{path} not found', code='NO_VERSION_FILE') from None
58
+
59
+
60
+ def _write(path, text):
61
+ with open(path, 'w', encoding='utf-8', newline='') as f:
62
+ f.write(text)
63
+
64
+
65
+ # A lookahead for the line end, never a match: trailing spaces and the CR of a
66
+ # CRLF file stay exactly where they were.
67
+ _EOL = r'(?=[ \t]*\r?$)'
68
+
69
+
70
+ class VersionSource:
71
+ """One file holding versionName and versionCode."""
72
+ def __init__(self, path):
73
+ self.path = Path(path)
74
+
75
+ def read(self):
76
+ raise NotImplementedError
77
+
78
+ def write(self, name, code):
79
+ raise NotImplementedError
80
+
81
+
82
+ class PubspecVersion(VersionSource):
83
+ RE = re.compile(r'^(version:[ \t]*)(\d+\.\d+\.\d+)\+(\d+)' + _EOL, re.M)
84
+
85
+ def read(self):
86
+ m = self.RE.search(_read(self.path))
87
+ if not m:
88
+ raise MkitError(f'no "version: x.y.z+n" line in {self.path}', code='NO_VERSION')
89
+ return m.group(2), int(m.group(3))
90
+
91
+ def write(self, name, code):
92
+ text = self.RE.sub(lambda m: f'{m.group(1)}{name}+{code}', _read(self.path), count=1)
93
+ _write(self.path, text)
94
+
95
+
96
+ class GradleVersion(VersionSource):
97
+ CODE = re.compile(r'^([ \t]*versionCode[ \t]*=?[ \t]*)(\d+)' + _EOL, re.M)
98
+ NAME = re.compile(r'''^([ \t]*versionName[ \t]*=?[ \t]*["'])([^"']+)(["'])''' + _EOL, re.M)
99
+
100
+ def read(self):
101
+ text = _read(self.path)
102
+ code, name = self.CODE.search(text), self.NAME.search(text)
103
+ if not code or not name:
104
+ raise MkitError(f'no literal versionCode/versionName in {self.path}', code='NO_VERSION',
105
+ hint='Set version_file under [app] if the version lives elsewhere.')
106
+ return name.group(2), int(code.group(2))
107
+
108
+ def write(self, name, code):
109
+ text = _read(self.path)
110
+ text = self.CODE.sub(lambda m: f'{m.group(1)}{code}', text, count=1)
111
+ text = self.NAME.sub(lambda m: f'{m.group(1)}{name}{m.group(3)}', text, count=1)
112
+ _write(self.path, text)
113
+
114
+
115
+ class PropertiesVersion(VersionSource):
116
+ CODE = re.compile(r'^([ \t]*versionCode[ \t]*=[ \t]*)(\d+)' + _EOL, re.M)
117
+ NAME = re.compile(r'^([ \t]*versionName[ \t]*=[ \t]*)([^\s#]+)' + _EOL, re.M)
118
+
119
+ def read(self):
120
+ text = _read(self.path)
121
+ code, name = self.CODE.search(text), self.NAME.search(text)
122
+ if not code or not name:
123
+ raise MkitError(f'no versionCode/versionName in {self.path}', code='NO_VERSION')
124
+ return name.group(2), int(code.group(2))
125
+
126
+ def write(self, name, code):
127
+ text = _read(self.path)
128
+ text = self.CODE.sub(lambda m: f'{m.group(1)}{code}', text, count=1)
129
+ text = self.NAME.sub(lambda m: f'{m.group(1)}{name}', text, count=1)
130
+ _write(self.path, text)
131
+
132
+
133
+ def version_source(path):
134
+ path = Path(path)
135
+ if path.name == 'pubspec.yaml':
136
+ return PubspecVersion(path)
137
+ if path.suffix == '.properties':
138
+ return PropertiesVersion(path)
139
+ return GradleVersion(path)
140
+
141
+
142
+ _DEBUG_BLOCK = re.compile(r'(?:\bdebug\s*|getByName\(\s*"debug"\s*\)\s*|named\(\s*"debug"\s*\)\s*)\{')
143
+ _ID_SUFFIX = re.compile(r'''applicationIdSuffix\s*=?\s*["']([^"']*)["']''')
144
+
145
+
146
+ def gradle_debug_suffix(text):
147
+ """The debug build type's applicationIdSuffix in a module's Gradle file
148
+ (Groovy or Kotlin DSL), or '' when it sets none.
149
+
150
+ Only the debug block counts: a suffix on another build type or a flavour
151
+ says nothing about where a debug build installs.
152
+ """
153
+ for match in _DEBUG_BLOCK.finditer(text):
154
+ depth, i = 1, match.end()
155
+ while i < len(text) and depth:
156
+ depth += {'{': 1, '}': -1}.get(text[i], 0)
157
+ i += 1
158
+ found = _ID_SUFFIX.search(text, match.end(), i)
159
+ if found:
160
+ return found.group(1)
161
+ return ''
162
+
163
+
164
+ class Kind:
165
+ name = ''
166
+
167
+ def __init__(self, project):
168
+ self.project = project
169
+ self.root = project.root
170
+ self.module = project.get('app.module', 'app')
171
+
172
+ # ---------------------------------------------------------------- version
173
+
174
+ def default_version_file(self):
175
+ raise NotImplementedError
176
+
177
+ @property
178
+ def version(self):
179
+ custom = self.project.get('app.version_file')
180
+ return version_source(self.root / custom if custom else self.default_version_file())
181
+
182
+ def current_version(self):
183
+ return self.version.read()
184
+
185
+ def bump(self, level='patch'):
186
+ """Rewrite the version file. Returns (old_name, old_code, new_name, new_code)."""
187
+ old_name, old_code = self.current_version()
188
+ new_name, new_code = bump_name(old_name, level), old_code + 1
189
+ self.version.write(new_name, new_code)
190
+ return old_name, old_code, new_name, new_code
191
+
192
+ # ---------------------------------------------------------------- packages
193
+
194
+ def module_gradle_file(self):
195
+ for name in ('build.gradle.kts', 'build.gradle'):
196
+ path = self.android_dir / self.module / name
197
+ if path.is_file():
198
+ return path
199
+ return None
200
+
201
+ def debug_package(self):
202
+ """Where a debug build installs.
203
+
204
+ `app.debug_suffix` in mkit.toml wins; otherwise it is read from the
205
+ debug build type in Gradle. It used to be assumed to be ".debug", so on
206
+ an app without a suffix `run -c` cleaned a package that did not exist
207
+ and left the real one alone.
208
+ """
209
+ suffix = self.project.get('app.debug_suffix')
210
+ if suffix is None:
211
+ gradle = self.module_gradle_file()
212
+ suffix = gradle_debug_suffix(gradle.read_text(encoding='utf-8')) if gradle else ''
213
+ return self.project.package + suffix
214
+
215
+ def installed_package(self, release=False):
216
+ """The package a run of that variant puts on the device."""
217
+ return self.project.package if release else self.debug_package()
218
+
219
+ # ---------------------------------------------------------------- build
220
+
221
+ @property
222
+ def android_dir(self):
223
+ return self.root
224
+
225
+ def signing_candidates(self):
226
+ return [self.android_dir / 'key.properties', self.root / 'keystore.properties',
227
+ self.root / 'key.properties']
228
+
229
+ def signing_file(self):
230
+ custom = self.project.get('app.signing')
231
+ if custom:
232
+ path = self.root / custom
233
+ return path if path.is_file() else None
234
+ return next((p for p in self.signing_candidates() if p.is_file()), None)
235
+
236
+ def modules(self):
237
+ base = self.root / self.module / 'build' / 'outputs'
238
+ return [Module('phone', 'phone', self.module,
239
+ base / 'bundle' / 'release' / f'{self.module}-release.aab',
240
+ base / 'mapping' / 'release' / 'mapping.txt')]
241
+
242
+ def prepare(self, variant, env):
243
+ """Anything that must run before Gradle for this variant."""
244
+
245
+ def bundle(self, env):
246
+ """Build every module's release bundle. Returns the modules built."""
247
+ self.prepare('release', env)
248
+ tasks = [f':{m.gradle}:bundleRelease' for m in self.modules()]
249
+ proc.stream([gradlew(self.android_dir), *tasks], 'gradlew ' + ' '.join(tasks),
250
+ cwd=self.android_dir, env=env)
251
+ return self.modules()
252
+
253
+ def apk_dir(self, variant):
254
+ return self.root / self.module / 'build' / 'outputs' / 'apk' / variant
255
+
256
+ def assemble(self, variant, env):
257
+ """Build the phone APK for `variant`; return (applicationId, apk path)."""
258
+ self.prepare(variant, env)
259
+ task = f':{self.module}:assemble{variant.capitalize()}'
260
+ proc.stream([gradlew(self.android_dir), task], f'gradlew {task}',
261
+ cwd=self.android_dir, env=env)
262
+ directory = self.apk_dir(variant)
263
+ meta_file = directory / 'output-metadata.json'
264
+ if not meta_file.is_file():
265
+ raise MkitError(f'no output-metadata.json in {directory}', code='NO_APK')
266
+ meta = json.loads(meta_file.read_text(encoding='utf-8'))
267
+ elements = meta.get('elements', [])
268
+ if len(elements) != 1:
269
+ raise MkitError('expected one APK for this variant; split APKs are not supported by run',
270
+ code='NO_APK')
271
+ return meta['applicationId'], directory / elements[0]['outputFile']
272
+
273
+ def test(self, device, env):
274
+ """Run the project's tests against `device`. Returns the exit code."""
275
+ task = f':{self.module}:connectedDebugAndroidTest'
276
+ return proc.interactive([gradlew(self.android_dir), task], cwd=self.android_dir, env=env)
277
+
278
+
279
+ class Flutter(Kind):
280
+ name = 'flutter'
281
+
282
+ def default_version_file(self):
283
+ return self.root / 'pubspec.yaml'
284
+
285
+ @property
286
+ def android_dir(self):
287
+ return self.root / 'android'
288
+
289
+ def modules(self):
290
+ base = self.root / 'build' / 'app' / 'outputs'
291
+ return [Module('phone', 'phone', 'app',
292
+ base / 'bundle' / 'release' / 'app-release.aab',
293
+ base / 'mapping' / 'release' / 'mapping.txt')]
294
+
295
+ def apk_dir(self, variant):
296
+ return self.root / 'build' / 'app' / 'outputs' / 'apk' / variant
297
+
298
+ def prepare(self, variant, env):
299
+ """`flutter build apk --<variant> --config-only` regenerates the plugin
300
+ registrant and Gradle config for this mode. A registrant last written by
301
+ `flutter test` still names integration_test, which release does not
302
+ compile in. Needs no JDK; the Gradle wrapper then builds with ours."""
303
+ proc.stream([tool('flutter'), 'build', 'apk', f'--{variant}', '--config-only'],
304
+ f'flutter build apk --{variant} --config-only', cwd=self.root, env=env,
305
+ quiet=True)
306
+
307
+ def test(self, device, env):
308
+ flutter = tool('flutter')
309
+ code = proc.interactive([flutter, 'test'], cwd=self.root, env=env)
310
+ if code != 0 or not (self.root / 'integration_test').is_dir():
311
+ return code
312
+ return proc.interactive([flutter, 'test', 'integration_test', '-d', device],
313
+ cwd=self.root, env=env)
314
+
315
+
316
+ class Android(Kind):
317
+ name = 'android'
318
+
319
+ def default_version_file(self):
320
+ if (self.root / 'version.properties').is_file():
321
+ return self.root / 'version.properties'
322
+ for f in ('build.gradle.kts', 'build.gradle'):
323
+ if (self.root / self.module / f).is_file():
324
+ return self.root / self.module / f
325
+ return self.root / self.module / 'build.gradle.kts'
326
+
327
+
328
+ class AndroidWear(Android):
329
+ """A phone module plus a watch module. Play has required the watch to ship on
330
+ its own Wear OS track since August 2023; the upload step resolves that."""
331
+ name = 'android-wear'
332
+
333
+ def modules(self):
334
+ wear = self.project.get('app.wear_module', 'wear')
335
+ base = self.root / wear / 'build' / 'outputs'
336
+ return super().modules() + [
337
+ Module('watch', 'wear', wear,
338
+ base / 'bundle' / 'release' / f'{wear}-release.aab',
339
+ base / 'mapping' / 'release' / 'mapping.txt')]
340
+
341
+
342
+ KINDS = {k.name: k for k in (Flutter, Android, AndroidWear)}
343
+
344
+
345
+ def for_project(project):
346
+ kind = project.kind
347
+ if kind not in KINDS:
348
+ raise UsageError(f'app.kind is "{kind}"; set it to one of {", ".join(KINDS)} in mkit.toml',
349
+ code='BAD_CONFIG')
350
+ return KINDS[kind](project)
mkit/notify.py ADDED
@@ -0,0 +1,49 @@
1
+ """Telegram release notifications. Never fails a release.
2
+
3
+ Credential: TELEGRAM = {"bot_token": "...", "chat_id": "..."}. On only when
4
+ [notify.telegram] is in mkit.toml. Messages are plain HTML: the version and
5
+ what happened first, labelled detail underneath, no decoration.
6
+ """
7
+ import html
8
+ import json
9
+ import urllib.parse
10
+ import urllib.request
11
+
12
+ from . import output
13
+
14
+ API = 'https://api.telegram.org/bot{token}/sendMessage'
15
+
16
+
17
+ def esc(value):
18
+ return html.escape(str(value), quote=False)
19
+
20
+
21
+ def enabled(ctx):
22
+ return ctx.project.has('notify.telegram') and ctx.secrets.has('TELEGRAM')
23
+
24
+
25
+ def message(ctx, subject, headline, fields=()):
26
+ lines = [f'<b>{esc(ctx.project.name)} {esc(subject)}</b>', headline]
27
+ detail = [f'{label}: {esc(value)}' for label, value in fields if value]
28
+ if detail:
29
+ lines += [''] + detail
30
+ return '\n'.join(lines)
31
+
32
+
33
+ def send(ctx, text):
34
+ """True if delivered. Problems only warn: the success path runs when a build
35
+ is already live, and a failure path must not swap its real error for a
36
+ chat-delivery one."""
37
+ if not enabled(ctx):
38
+ return False
39
+ creds = ctx.secrets.json('TELEGRAM')
40
+ fields = {'chat_id': creds['chat_id'], 'text': text, 'parse_mode': 'HTML',
41
+ 'link_preview_options': json.dumps({'is_disabled': True})}
42
+ request = urllib.request.Request(API.format(token=creds['bot_token']),
43
+ data=urllib.parse.urlencode(fields).encode('utf-8'))
44
+ try:
45
+ urllib.request.urlopen(request, timeout=30)
46
+ return True
47
+ except Exception as e:
48
+ output.out.warn(f'Telegram notification failed: {type(e).__name__}')
49
+ return False