alpyki 0.2.0__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.
alpyki/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """
2
+ .. include:: ../README.md
3
+ """
alpyki/cli.py ADDED
@@ -0,0 +1,671 @@
1
+ import argparse
2
+ import datetime
3
+ import logging
4
+ import plistlib
5
+ import re
6
+ import stat
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Callable, Optional
11
+ from uuid import uuid4
12
+ from zipfile import ZIP_DEFLATED, ZipFile
13
+
14
+ from jinja2 import Environment, PackageLoader
15
+
16
+
17
+ def _info_plist_path() -> Path:
18
+ wf_dir = Path.cwd() / "Workflow"
19
+ info_plist_path = wf_dir / "info.plist"
20
+ return info_plist_path
21
+
22
+
23
+ def _ensure_no_local_changes(
24
+ fn: Callable[[argparse.Namespace], None],
25
+ ) -> Callable[[argparse.Namespace], None]:
26
+ """Ensure that there are no changes to the local directory"""
27
+
28
+ def decorator(args: argparse.Namespace) -> None:
29
+ git_status_command = ["git", "status", "-s"]
30
+ if subprocess.check_output(git_status_command).decode("utf-8").strip():
31
+ logging.critical("Local changes detected. Ensure git history has all changes committed.")
32
+ exit(1)
33
+
34
+ fn(args)
35
+
36
+ return decorator
37
+
38
+
39
+ def _must_be_run_from_workflow_project_root(
40
+ fn: Callable[[argparse.Namespace], None],
41
+ ) -> Callable[[argparse.Namespace], None]:
42
+ """Validates that the command is run from a directory that contains a workflow"""
43
+
44
+ def decorator(args: argparse.Namespace):
45
+ if not _info_plist_path().exists():
46
+ logging.critical("Cannot find workflow. You need to run this command from the root of the project")
47
+ exit(1)
48
+
49
+ fn(args)
50
+
51
+ return decorator
52
+
53
+
54
+ def _get_sync_directory() -> Optional[Path]:
55
+ """
56
+ :return: The path to Alfred's sync directory
57
+ """
58
+ prefs_path = Path.home() / "Library" / "Preferences" / "com.runningwithcrayons.Alfred-Preferences.plist"
59
+
60
+ if not prefs_path.exists():
61
+ raise ValueError("Alfred doesn't appear to be installed")
62
+
63
+ with prefs_path.open("rb") as f:
64
+ pl = plistlib.load(f)
65
+
66
+ if "syncfolder" not in pl:
67
+ logging.debug("Alfred's synchronisation directory not set")
68
+ return None
69
+
70
+ sync_dir = Path(pl["syncfolder"]).expanduser()
71
+
72
+ if not sync_dir.exists():
73
+ raise OSError("Cannot find workflow directory")
74
+
75
+ return sync_dir.expanduser()
76
+
77
+
78
+ def _get_workflows_directory() -> Path:
79
+ """
80
+ Get the directory where Alfred stores workflows
81
+
82
+ Finds the Alfred.alfredpreferences dir either in the sync location set in the Alfred settings or in the default
83
+ location.
84
+
85
+ :return: The path to the directory with Alfred's workflows
86
+ """
87
+
88
+ sync_dir = _get_sync_directory()
89
+ prefs_dir = sync_dir or Path.home() / "Library" / "Application Support" / "Alfred/"
90
+
91
+ return prefs_dir / "Alfred.alfredpreferences" / "workflows"
92
+
93
+
94
+ def _make_plist(
95
+ name: str,
96
+ keyword: str,
97
+ bundle_id: str,
98
+ author: Optional[str],
99
+ website: Optional[str],
100
+ description: Optional[str],
101
+ ) -> dict:
102
+ """
103
+ Create a dictionary representation of the info.plist file describing the workflow
104
+
105
+ :param name:
106
+ The name of the workflow
107
+ :param keyword:
108
+ The keyword to trigger the workflow
109
+ :param bundle_id:
110
+ The bundle ID to identify the workflow. Should use reverse-DNS notation
111
+ :param author:
112
+ The name of the author
113
+ :param website:
114
+ The website of the workflow
115
+ :param description:
116
+ The description of the workflow. Will be shown to the user when importing
117
+ :return: a dictionary representation of the info.plist file
118
+ """
119
+ script_uuid = str(uuid4())
120
+ clipboard_uuid = str(uuid4())
121
+
122
+ return {
123
+ "name": name,
124
+ "description": description or "",
125
+ "bundleid": bundle_id,
126
+ "createdby": author or "",
127
+ "connections": {script_uuid: [{"destinationuid": clipboard_uuid}]},
128
+ "uidata": [],
129
+ # Environment variables
130
+ # Add the vendored directory to the PYTHONPATH so that we're also searching there for dependencies
131
+ "variables": {"PYTHONPATH": ".:vendored"},
132
+ # The workflow version
133
+ "version": "0.0.0",
134
+ # The contact website
135
+ "webaddress": website or "",
136
+ "objects": [
137
+ {
138
+ "uid": clipboard_uuid,
139
+ "type": "alfred.workflow.output.clipboard",
140
+ "config": {"clipboardtext": "{query}"},
141
+ },
142
+ {
143
+ "uid": script_uuid,
144
+ "type": "alfred.workflow.input.scriptfilter",
145
+ "config": {
146
+ "keyword": keyword,
147
+ "scriptfile": "workflow.py",
148
+ # Keyword should be followed by whitespace
149
+ "withspace": True,
150
+ # Argument optional
151
+ "argumenttype": 1,
152
+ # Placeholder title
153
+ "title": "Search",
154
+ # "Please wait" subtext
155
+ "runningsubtext": "Loading...",
156
+ # External script
157
+ "type": 8,
158
+ # Terminate previous script
159
+ "queuemode": 2,
160
+ # Always run immediately for first typed character
161
+ "queuedelayimmediatelyinitially": True,
162
+ # Don't set argv when empty
163
+ "argumenttreatemptyqueryasnil": True,
164
+ },
165
+ },
166
+ ],
167
+ }
168
+
169
+
170
+ def _zip_dir(directory: Path, output_file: Path):
171
+ """
172
+ Zip the contents of the provided directory recursively
173
+
174
+ :param directory: The directory to compress
175
+ :param output_file: The target file
176
+ """
177
+
178
+ with ZipFile(output_file, "w", ZIP_DEFLATED) as zip_file:
179
+ for entry in directory.rglob("**/*"):
180
+ if entry.is_file():
181
+ logging.debug("Adding to package: %s", entry)
182
+ zip_file.write(entry, entry.relative_to(directory))
183
+ logging.info("Produced package at %s", output_file)
184
+
185
+
186
+ def find_workflow_link(target: Path) -> Optional[Path]:
187
+ """
188
+ Finds a link to the workflow in Alfred's workflows directory
189
+
190
+ :param target: The path to the workflow we're looking for
191
+ :return: The path if found; `None` otherwise
192
+ """
193
+ target = target.expanduser()
194
+ workflows = _get_workflows_directory()
195
+
196
+ for wf in workflows.iterdir():
197
+ if wf.is_symlink() and wf.readlink().expanduser() == target:
198
+ return wf
199
+
200
+ return None
201
+
202
+
203
+ def new(args: argparse.Namespace):
204
+ """
205
+ Entry point for the `new` command. Creates a new Alfred workflow.
206
+
207
+ This creates a directory of the name in the `name` argument and links it into Alfred's workflows directory. The
208
+ workflow shows in the Alfred Preferences app and can still be easily edited with an external editor.
209
+
210
+ ```
211
+ usage: alpyki new [-h] -k KEYWORD -b BUNDLE_ID --author AUTHOR
212
+ [--website WEBSITE] [--description DESCRIPTION]
213
+ [--git | --no-git] [--link | --no-link]
214
+ [--vendor | --no-vendor] [--actions {github,gitea,gitlab,none}]
215
+ name
216
+
217
+ positional arguments:
218
+ name Name of the new workflow
219
+
220
+ options:
221
+ -h, --help show this help message and exit
222
+ -k KEYWORD, --keyword KEYWORD
223
+ The keyword to trigger the workflow
224
+ -b BUNDLE_ID, --bundle-id BUNDLE_ID
225
+ The bundle identifier, usually in reverse DNS notation
226
+ --author AUTHOR Name of the author
227
+ --website WEBSITE The workflow website
228
+ --description DESCRIPTION
229
+ A description for the workflow
230
+ --git, --no-git Whether to create a git repository
231
+ --link, --no-link Create a symbolic link to this workflow
232
+ --vendor, --no-vendor
233
+ Install workflow dependencies
234
+ --actions, --ci CI platform for release workflow: github (default), gitea, gitlab, or none
235
+ ```
236
+ """ # noqa: E501
237
+ name = args.name
238
+ logging.info("Creating new workflow: %s", name)
239
+
240
+ root_dir = Path.cwd().joinpath(name)
241
+ wf_dir = root_dir.joinpath("Workflow")
242
+
243
+ context = {
244
+ "year": datetime.datetime.now().year,
245
+ "system_python_version": subprocess.check_output(
246
+ ["/usr/bin/python3", "-c", "print(__import__('platform').python_version())"]
247
+ )
248
+ .decode("utf-8")
249
+ .strip(),
250
+ **vars(args),
251
+ }
252
+ try:
253
+ logging.debug("Copying template")
254
+ env = Environment(loader=PackageLoader("alpyki", "template"))
255
+ logging.debug("Generating templates from %s to %s", env.list_templates(), root_dir)
256
+
257
+ def filter_actions(path: str) -> bool:
258
+ if ".github" not in path and ".gitea" not in path and ".gitlab" not in path:
259
+ return True
260
+ if args.actions == "none":
261
+ return False
262
+ if args.actions == "gitlab":
263
+ return ".gitlab" in path
264
+ return f".{args.actions}" in path
265
+
266
+ for t in [t for t in env.list_templates(filter_func=filter_actions) if "__pycache__" not in t]:
267
+ tmp = env.get_template(t)
268
+ outfile = root_dir.joinpath(t)
269
+ outfile.parent.mkdir(parents=True, exist_ok=True)
270
+ with open(outfile, "w") as fd:
271
+ fd.write(tmp.render(context))
272
+ except OSError as e:
273
+ logging.error("Cannot create workflow: %s", e)
274
+ exit(1)
275
+
276
+ wf_file_path = wf_dir.joinpath("workflow.py")
277
+
278
+ logging.debug("Adding +x permission to workflow")
279
+ wf_file_path.chmod(wf_file_path.stat().st_mode | stat.S_IEXEC)
280
+
281
+ if args.git:
282
+ logging.debug("Initialising git repository")
283
+ if subprocess.call(["git", "init", name]) != 0:
284
+ logging.warning("Failed to create git repository. Ignoring.")
285
+
286
+ logging.debug("Creating info.plist")
287
+ with wf_dir.joinpath("info.plist").open(mode="xb") as f:
288
+ plistlib.dump(
289
+ _make_plist(
290
+ name=name,
291
+ keyword=args.keyword,
292
+ bundle_id=args.bundle_id,
293
+ author=args.author,
294
+ website=args.website,
295
+ description=args.description,
296
+ ),
297
+ f,
298
+ sort_keys=True,
299
+ )
300
+ if args.vendor:
301
+ _vendor(root_dir, upgrade=False)
302
+ if args.link:
303
+ _link(relink=True, same_path=False, wf_dir=wf_dir)
304
+
305
+
306
+ @_must_be_run_from_workflow_project_root
307
+ def link(args: argparse.Namespace):
308
+ """
309
+ Entry point for the `link` command. Links or relinks the workflow into Alfred's workflows directory.
310
+
311
+ ```
312
+ usage: alpyki link [-h] [--relink | --no-relink] [--same-path | --no-same-path]
313
+
314
+ options:
315
+ -h, --help show this help message and exit
316
+ --relink, --no-relink
317
+ Whether to delete (if exists) and recreate the link (default: False)
318
+ --same-path, --no-same-path
319
+ Whether to reuse (if exists) the previous path for the link (default: False)
320
+ ```
321
+ """
322
+ try:
323
+ _link(
324
+ relink=args.relink,
325
+ same_path=args.same_path,
326
+ wf_dir=Path.cwd().joinpath("Workflow"),
327
+ )
328
+ except ValueError as e:
329
+ logging.error("Error creating link: %s", e)
330
+ exit(1)
331
+
332
+
333
+ def _link(relink: bool, same_path: bool, wf_dir: Path):
334
+ """
335
+ Create a link to the workflow in Alfred's workflows directory
336
+
337
+ :param relink:
338
+ Whether to recreate the link if it exists
339
+ :param same_path:
340
+ Whether to reuse the same link if one exists
341
+ :param wf_dir:
342
+ The directory to link to
343
+ :return:
344
+ """
345
+ if not wf_dir.exists():
346
+ raise ValueError(f"{wf_dir} doesn't exist")
347
+
348
+ if not wf_dir.is_dir():
349
+ raise ValueError(f"{wf_dir} is not a directory")
350
+
351
+ existing_link = find_workflow_link(wf_dir)
352
+
353
+ if existing_link:
354
+ if not relink:
355
+ logging.debug("Found link: %s", existing_link)
356
+ return
357
+
358
+ logging.debug("Removing existing link: %s", existing_link)
359
+ existing_link.unlink()
360
+
361
+ logging.info("Creating link to workflow directory %s", wf_dir)
362
+
363
+ if same_path and existing_link:
364
+ source = existing_link
365
+ else:
366
+ workflow_id = str(uuid4()).upper()
367
+ source = _get_workflows_directory().joinpath(f"user.workflow.{workflow_id}")
368
+
369
+ logging.debug("Creating link: %s", source)
370
+ source.symlink_to(wf_dir)
371
+
372
+ if not source.exists():
373
+ logging.error("Error linking from %s to %s", source, wf_dir)
374
+ source.unlink(missing_ok=True)
375
+
376
+
377
+ @_must_be_run_from_workflow_project_root
378
+ def vendor(args: argparse.Namespace):
379
+ """
380
+ Entry point for the `vendor` command
381
+
382
+ Downloads dependencies specified in the `requirements.txt` file into the workflow's `vendored` directory.
383
+ This way, the dependencies don't need to be installed into the system Python interpreter.
384
+
385
+ The workflow sets the `PYTHONPATH` environment variable to `.:vendored`, making the interpreter search for
386
+ dependencies in that directory, in addition to the workflow directory.
387
+
388
+ ```
389
+ usage: alpyki vendor [-h] [--upgrade | --no-upgrade]
390
+
391
+ options:
392
+ -h, --help show this help message and exit
393
+ --upgrade, --no-upgrade
394
+ Whether to pass `--upgrade` to `pip install` when vendoring (default: False)
395
+ ```
396
+ """
397
+ _vendor(root_path=Path.cwd(), upgrade=args.upgrade)
398
+
399
+
400
+ def _vendor(root_path: Path, upgrade: bool) -> bool:
401
+ """
402
+ Download dependencies from `requirements.txt`
403
+
404
+ :param root_path: The root path of the workflow project
405
+ :param upgrade: Whether to pass `--upgrade` to `pip install`
406
+ :return: whether the download was successful
407
+ """
408
+
409
+ vendored_path = root_path / "Workflow" / "vendored"
410
+ vendored_path.mkdir(parents=True, exist_ok=True)
411
+
412
+ import subprocess
413
+
414
+ pip_command = [
415
+ sys.executable,
416
+ "-m",
417
+ "pip",
418
+ "install",
419
+ "-r",
420
+ f"{root_path}/requirements.txt",
421
+ f"--target={vendored_path}",
422
+ ]
423
+
424
+ if upgrade:
425
+ pip_command.append("--upgrade")
426
+
427
+ logging.debug("Running pip: python %s", " ".join(pip_command[1:]))
428
+
429
+ return subprocess.call(pip_command) == 0
430
+
431
+
432
+ @_ensure_no_local_changes
433
+ @_must_be_run_from_workflow_project_root
434
+ def release(args: argparse.Namespace):
435
+ # Update version number in info.plist
436
+ plist_path = _info_plist_path()
437
+ with plist_path.open("rb") as f:
438
+ pl = plistlib.load(f)
439
+
440
+ pl["version"] = args.version
441
+ with plist_path.open(mode="wb") as f:
442
+ plistlib.dump(
443
+ pl,
444
+ f,
445
+ sort_keys=True,
446
+ )
447
+
448
+ # commit changes to git
449
+ commit_command = ["git", "commit", "-a", "-m", f"release {args.version}"]
450
+ if subprocess.call(commit_command) != 0:
451
+ logging.error("Error committing changes")
452
+ exit(1)
453
+
454
+ # create annotated tag with version
455
+ tag_command = ["git", "tag", "-a", args.version, "-m", args.version]
456
+ if subprocess.call(tag_command) != 0:
457
+ logging.error("Error tagging changes")
458
+ exit(1)
459
+
460
+ # push changes & tag
461
+ push_command = ["git", "push", "origin", "--follow-tags"]
462
+ if subprocess.call(push_command) != 0:
463
+ logging.error("Error pushing changes")
464
+ exit(1)
465
+
466
+
467
+ @_must_be_run_from_workflow_project_root
468
+ def package(args: argparse.Namespace):
469
+ """
470
+ Entry point for the `package` command. Creates a package for distribution.
471
+
472
+ Packages the workflow into a `workflow.alfredworkflow` file in the `dist` directory.
473
+ Users can import the package by double-clicking the file.
474
+
475
+ ```
476
+ usage: alpyki package [-h] --name NAME
477
+
478
+ options:
479
+ -h, --help show this help message and exit
480
+ --name NAME The name of the workflow file
481
+ ```
482
+ """
483
+ root_dir = Path.cwd()
484
+
485
+ has_requirements = root_dir.joinpath("requirements.txt").exists()
486
+ logging.debug("requirements.txt exists %s", has_requirements)
487
+ if has_requirements:
488
+ if not _vendor(root_dir, upgrade=True):
489
+ logging.error("Failed to download dependencies. Exiting")
490
+ exit(1)
491
+
492
+ output = root_dir / "dist"
493
+ output.mkdir(exist_ok=True)
494
+
495
+ _zip_dir(root_dir / "Workflow", output / f"{args.name}.alfredworkflow")
496
+
497
+
498
+ @_must_be_run_from_workflow_project_root
499
+ def version(args: argparse.Namespace) -> None:
500
+ with _info_plist_path().open("rb") as f:
501
+ pl = plistlib.load(f)
502
+
503
+ print(pl["version"])
504
+
505
+
506
+ @_must_be_run_from_workflow_project_root
507
+ def name(args: argparse.Namespace) -> None:
508
+ with _info_plist_path().open("rb") as f:
509
+ pl = plistlib.load(f)
510
+
511
+ print(pl["name"])
512
+
513
+
514
+ @_must_be_run_from_workflow_project_root
515
+ def show_link(args: argparse.Namespace) -> None:
516
+ """
517
+ Entry point for the `show-link` command. Displays the link in the installed workflows directory for this project.
518
+
519
+ ```
520
+ usage: alpyki show-link [-h]
521
+
522
+ options:
523
+ -h, --help show this help message and exit
524
+ ```
525
+ """
526
+ wf_dir = Path.cwd().joinpath("Workflow")
527
+ link_path = find_workflow_link(wf_dir)
528
+
529
+ if link_path:
530
+ print(link_path)
531
+ else:
532
+ logging.error("No workflow link found. Use 'alpyki link' to create one.")
533
+ sys.exit(1)
534
+
535
+
536
+ def _cli():
537
+ """
538
+ The entry point for the CLI.
539
+
540
+ ```
541
+ usage: alpyki [-h] {new,vendor,link,package,version,name,show-link} ...
542
+
543
+ Build Python workflows for Alfred with ease
544
+
545
+ positional arguments:
546
+ {new,vendor,link,package,version,name,show-link}
547
+ new Create a new workflow
548
+ vendor Install workflow dependencies
549
+ link Create a symbolic link to this workflow in Alfred
550
+ package Package the workflow for distribution
551
+ version Display the version of the workflow
552
+ name Display the name of the workflow
553
+ show-link Display the link in the installed workflows directory for this project
554
+
555
+ options:
556
+ -h, --help show this help message and exit
557
+ --debug, --no-debug Whether to enable debug logging (default: False)
558
+ ```
559
+
560
+ """
561
+ parser = argparse.ArgumentParser(prog="alpyki", description="Build Python workflows for Alfred with ease")
562
+ parser.add_argument(
563
+ "--debug",
564
+ action=argparse.BooleanOptionalAction,
565
+ default=False,
566
+ help="Whether to enable debug logging",
567
+ )
568
+ subparsers = parser.add_subparsers(required=True)
569
+
570
+ new_parser = subparsers.add_parser("new", help="Create a new workflow")
571
+ new_parser.add_argument("name", type=str, help="Name of the new workflow")
572
+ new_parser.add_argument(
573
+ "-k",
574
+ "--keyword",
575
+ type=str,
576
+ required=True,
577
+ help="The keyword to trigger the workflow",
578
+ )
579
+ new_parser.add_argument(
580
+ "-b",
581
+ "--bundle-id",
582
+ type=str,
583
+ required=True,
584
+ help="The bundle identifier, usually in reverse DNS notation",
585
+ )
586
+ new_parser.add_argument("--author", type=str, required=True, help="Name of the author")
587
+ new_parser.add_argument("--website", type=str, help="The workflow website")
588
+ new_parser.add_argument("--description", type=str, help="A description for the workflow")
589
+ new_parser.add_argument(
590
+ "--git",
591
+ action=argparse.BooleanOptionalAction,
592
+ default=True,
593
+ help="Whether to create a git repository",
594
+ )
595
+ new_parser.add_argument(
596
+ "--link", action=argparse.BooleanOptionalAction, default=True, help="Create a symbolic link to this workflow"
597
+ )
598
+ new_parser.add_argument(
599
+ "--vendor", action=argparse.BooleanOptionalAction, default=True, help="Install workflow dependencies"
600
+ )
601
+ new_parser.add_argument(
602
+ "--actions",
603
+ "--ci",
604
+ type=str,
605
+ choices=["github", "gitea", "gitlab", "none"],
606
+ default="github",
607
+ help="CI platform for release workflow (default: github)",
608
+ )
609
+ new_parser.set_defaults(func=new)
610
+
611
+ vendor_parser = subparsers.add_parser("vendor", help="Install workflow dependencies")
612
+ vendor_parser.add_argument(
613
+ "--upgrade",
614
+ action=argparse.BooleanOptionalAction,
615
+ default=False,
616
+ help="Whether to pass `--upgrade` to `pip install` when vendoring",
617
+ )
618
+ vendor_parser.set_defaults(func=vendor)
619
+
620
+ link_parser = subparsers.add_parser("link", help="Create a symbolic link to this workflow in Alfred")
621
+ link_parser.add_argument(
622
+ "--relink",
623
+ action=argparse.BooleanOptionalAction,
624
+ default=False,
625
+ help="Whether to delete (if exists) and recreate the link",
626
+ )
627
+ link_parser.add_argument(
628
+ "--same-path",
629
+ action=argparse.BooleanOptionalAction,
630
+ default=False,
631
+ help="Whether to reuse (if exists) the previous path for the link",
632
+ )
633
+ link_parser.set_defaults(func=link)
634
+
635
+ def version_str(arg_value: str, pat=re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")) -> str:
636
+ if not pat.match(arg_value):
637
+ raise argparse.ArgumentTypeError("invalid version string")
638
+ return arg_value
639
+
640
+ release_parser = subparsers.add_parser("release", help="Update version & tag for release build")
641
+ release_parser.add_argument("--version", type=version_str, required=True, help="Version to update")
642
+ release_parser.set_defaults(func=release)
643
+
644
+ package_parser = subparsers.add_parser("package", help="Package the workflow for distribution")
645
+ package_parser.add_argument("--name", type=str, required=True, help="The name of the workflow file")
646
+ package_parser.set_defaults(func=package)
647
+
648
+ version_parser = subparsers.add_parser("version", help="Display the version of the workflow")
649
+ version_parser.set_defaults(func=version)
650
+
651
+ name_parser = subparsers.add_parser("name", help="Display the name of the workflow")
652
+ name_parser.set_defaults(func=name)
653
+
654
+ show_link_parser = subparsers.add_parser(
655
+ "show-link", help="Display the link in the installed workflows directory for this project"
656
+ )
657
+ show_link_parser.set_defaults(func=show_link)
658
+
659
+ args = parser.parse_args()
660
+
661
+ logging.basicConfig(
662
+ format="%(asctime)s %(levelname)-8s %(message)s",
663
+ level=logging.DEBUG if args.debug else logging.INFO,
664
+ datefmt="%Y-%m-%d %H:%M:%S",
665
+ )
666
+
667
+ args.func(args)
668
+
669
+
670
+ if __name__ == "__main__":
671
+ _cli()