Extensions: correct last commit (tsk!)
[blender-addons-contrib.git] / bl_pkg / bl_extension_cli.py
blob94c3dfbc2887e8cccaf7c09b8fd1881c6e76fbb2
1 # SPDX-FileCopyrightText: 2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 """
6 Command line access for extension operations see:
8 blender --command extension --help
9 """
11 __all__ = (
12 "cli_extension_handler",
15 import argparse
16 import os
17 import sys
19 from typing import (
20 Any,
21 Dict,
22 List,
23 Optional,
24 Tuple,
25 Union,
28 show_color = (
29 False if os.environ.get("NO_COLOR") else
30 sys.stdout.isatty()
34 if show_color:
35 color_codes = {
36 'black': '\033[0;30m',
37 'bright_gray': '\033[0;37m',
38 'blue': '\033[0;34m',
39 'white': '\033[1;37m',
40 'green': '\033[0;32m',
41 'bright_blue': '\033[1;34m',
42 'cyan': '\033[0;36m',
43 'bright_green': '\033[1;32m',
44 'red': '\033[0;31m',
45 'bright_cyan': '\033[1;36m',
46 'purple': '\033[0;35m',
47 'bright_red': '\033[1;31m',
48 'yellow': '\033[0;33m',
49 'bright_purple': '\033[1;35m',
50 'dark_gray': '\033[1;30m',
51 'bright_yellow': '\033[1;33m',
52 'normal': '\033[0m',
55 def colorize(text: str, color: str) -> str:
56 return (color_codes[color] + text + color_codes["normal"])
57 else:
58 def colorize(text: str, color: str) -> str:
59 return text
61 # -----------------------------------------------------------------------------
62 # Wrap Operators
65 def blender_preferences_write() -> bool:
66 import bpy # type: ignore
67 try:
68 ok = 'FINISHED' in bpy.ops.wm.save_userpref()
69 except RuntimeError as ex:
70 print("Failed to write preferences: {!r}".format(ex))
71 ok = False
72 return ok
75 # -----------------------------------------------------------------------------
76 # Argument Implementation (Utilities)
78 class subcmd_utils:
80 def __new__(cls) -> Any:
81 raise RuntimeError("{:s} should not be instantiated".format(cls))
83 @staticmethod
84 def sync(
86 show_done: bool = True,
87 ) -> bool:
88 import bpy
89 try:
90 bpy.ops.bl_pkg.repo_sync_all()
91 if show_done:
92 sys.stdout.write("Done...\n\n")
93 except BaseException:
94 print("Error synchronizing")
95 import traceback
96 traceback.print_exc()
97 return False
98 return True
100 @staticmethod
101 def _expand_package_ids(
102 packages: List[str],
104 use_local: bool,
105 ) -> Union[List[Tuple[int, str]], str]:
106 # Takes a terse lists of package names and expands to repo index and name list,
107 # returning an error string if any can't be resolved.
108 from . import repo_cache_store
109 from .bl_extension_ops import extension_repos_read
111 repo_map = {}
112 errors = []
114 repos_all = extension_repos_read()
115 for (
116 repo_index,
117 pkg_manifest,
118 ) in enumerate(
119 repo_cache_store.pkg_manifest_from_local_ensure(error_fn=print)
120 if use_local else
121 repo_cache_store.pkg_manifest_from_remote_ensure(error_fn=print)
123 # Show any exceptions created while accessing the JSON,
124 repo = repos_all[repo_index]
125 repo_map[repo.module] = (repo_index, set(pkg_manifest.keys()))
127 repos_and_packages = []
129 for pkg_id_full in packages:
130 repo_id, pkg_id = pkg_id_full.rpartition(".")[0::2]
131 if not pkg_id:
132 errors.append("Malformed package name \"{:s}\", expected \"repo_id.pkg_id\"!".format(pkg_id_full))
133 continue
134 if repo_id:
135 repo_index, repo_packages = repo_map.get(repo_id, (-1, ()))
136 if repo_index == -1:
137 errors.append("Repository \"{:s}\" not found in [{:s}]!".format(
138 repo_id,
139 ", ".join(sorted("\"{:s}\"".format(x) for x in repo_map.keys()))
141 continue
142 else:
143 repo_index = -1
144 for repo_id_iter, (repo_index_iter, repo_packages_iter) in repo_map.items():
145 if pkg_id in repo_packages_iter:
146 repo_index = repo_index_iter
147 break
148 if repo_index == -1:
149 if use_local:
150 errors.append("Package \"{:s}\" not installed in local repositories!".format(pkg_id))
151 else:
152 errors.append("Package \"{:s}\" not found in remote repositories!".format(pkg_id))
153 continue
154 repos_and_packages.append((repo_index, pkg_id))
156 if errors:
157 return "\n".join(errors)
159 return repos_and_packages
161 @staticmethod
162 def expand_package_ids_from_remote(packages: List[str]) -> Union[List[Tuple[int, str]], str]:
163 return subcmd_utils._expand_package_ids(packages, use_local=False)
165 @staticmethod
166 def expand_package_ids_from_local(packages: List[str]) -> Union[List[Tuple[int, str]], str]:
167 return subcmd_utils._expand_package_ids(packages, use_local=True)
170 # -----------------------------------------------------------------------------
171 # Argument Implementation (Queries)
173 class subcmd_query:
175 def __new__(cls) -> Any:
176 raise RuntimeError("{:s} should not be instantiated".format(cls))
178 @staticmethod
179 def list(
181 sync: bool,
182 ) -> bool:
184 def list_item(
185 pkg_id: str,
186 item_remote: Optional[Dict[str, Any]],
187 item_local: Optional[Dict[str, Any]],
188 ) -> None:
189 # Both can't be None.
190 assert item_remote is not None or item_local is not None
192 if item_remote is not None:
193 item_version = item_remote["version"]
194 if item_local is None:
195 item_local_version = None
196 is_outdated = False
197 else:
198 item_local_version = item_local["version"]
199 is_outdated = item_local_version != item_version
201 if item_local is not None:
202 if is_outdated:
203 status_info = " [{:s}]".format(colorize("outdated: {:s} -> {:s}".format(
204 item_local_version,
205 item_version,
206 ), "red"))
207 else:
208 status_info = " [{:s}]".format(colorize("installed", "green"))
209 else:
210 status_info = ""
211 item = item_remote
212 else:
213 # All local-only packages are installed.
214 status_info = " [{:s}]".format(colorize("installed", "green"))
215 assert isinstance(item_local, dict)
216 item = item_local
218 print(
219 " {:s}{:s}: {:s}".format(
220 pkg_id,
221 status_info,
222 colorize("\"{:s}\", {:s}".format(item["name"], item.get("tagline", "<no tagline>")), "dark_gray"),
225 if sync:
226 if not subcmd_utils.sync():
227 return False
229 # NOTE: exactly how this data is extracted is rather arbitrary.
230 # This uses the same code paths as drawing code.
231 from .bl_extension_ops import extension_repos_read
232 from . import repo_cache_store
234 repos_all = extension_repos_read()
236 for repo_index, (
237 pkg_manifest_remote,
238 pkg_manifest_local,
239 ) in enumerate(zip(
240 repo_cache_store.pkg_manifest_from_remote_ensure(error_fn=print),
241 repo_cache_store.pkg_manifest_from_local_ensure(error_fn=print),
243 # Show any exceptions created while accessing the JSON,
244 repo = repos_all[repo_index]
246 print("Repository: \"{:s}\" (id={:s})".format(repo.name, repo.module))
247 if pkg_manifest_remote is not None:
248 for pkg_id, item_remote in pkg_manifest_remote.items():
249 if pkg_manifest_local is not None:
250 item_local = pkg_manifest_local.get(pkg_id)
251 else:
252 item_local = None
253 list_item(pkg_id, item_remote, item_local)
254 else:
255 for pkg_id, item_local in pkg_manifest_local.items():
256 list_item(pkg_id, None, item_local)
258 return True
261 # -----------------------------------------------------------------------------
262 # Argument Implementation (Packages)
264 class subcmd_pkg:
266 def __new__(cls) -> Any:
267 raise RuntimeError("{:s} should not be instantiated".format(cls))
269 @staticmethod
270 def update(
272 sync: bool,
273 ) -> bool:
274 if sync:
275 if not subcmd_utils.sync():
276 return False
278 import bpy
279 try:
280 bpy.ops.bl_pkg.pkg_upgrade_all()
281 except RuntimeError:
282 return False # The error will have been printed.
283 return True
285 @staticmethod
286 def install(
288 sync: bool,
289 packages: List[str],
290 enable_on_install: bool,
291 no_prefs: bool,
292 ) -> bool:
293 if sync:
294 if not subcmd_utils.sync():
295 return False
297 # Expand all package ID's.
298 repos_and_packages = subcmd_utils.expand_package_ids_from_remote(packages)
299 if isinstance(repos_and_packages, str):
300 sys.stderr.write(repos_and_packages)
301 sys.stderr.write("\n")
302 return False
304 import bpy
305 for repo_index, pkg_id in repos_and_packages:
306 bpy.ops.bl_pkg.pkg_mark_set(
307 repo_index=repo_index,
308 pkg_id=pkg_id,
311 try:
312 bpy.ops.bl_pkg.pkg_install_marked(enable_on_install=enable_on_install)
313 except RuntimeError:
314 return False # The error will have been printed.
316 if not no_prefs:
317 if enable_on_install:
318 blender_preferences_write()
320 return True
322 @staticmethod
323 def remove(
325 packages: List[str],
326 no_prefs: bool,
327 ) -> bool:
328 # Expand all package ID's.
329 repos_and_packages = subcmd_utils.expand_package_ids_from_local(packages)
330 if isinstance(repos_and_packages, str):
331 sys.stderr.write(repos_and_packages)
332 sys.stderr.write("\n")
333 return False
335 import bpy
336 for repo_index, pkg_id in repos_and_packages:
337 bpy.ops.bl_pkg.pkg_mark_set(repo_index=repo_index, pkg_id=pkg_id)
339 try:
340 bpy.ops.bl_pkg.pkg_uninstall_marked()
341 except RuntimeError:
342 return False # The error will have been printed.
344 if not no_prefs:
345 blender_preferences_write()
347 return True
349 @staticmethod
350 def install_file(
352 filepath: str,
353 repo_id: str,
354 enable_on_install: bool,
355 no_prefs: bool,
356 ) -> bool:
357 import bpy
358 try:
359 bpy.ops.bl_pkg.pkg_install_files(
360 filepath=filepath,
361 repo=repo_id,
362 enable_on_install=enable_on_install,
364 except RuntimeError:
365 return False # The error will have been printed.
366 except BaseException as ex:
367 sys.stderr.write(str(ex))
368 sys.stderr.write("\n")
370 if not no_prefs:
371 if enable_on_install:
372 blender_preferences_write()
374 return True
377 # -----------------------------------------------------------------------------
378 # Argument Implementation (Repositories)
380 class subcmd_repo:
382 def __new__(cls) -> Any:
383 raise RuntimeError("{:s} should not be instantiated".format(cls))
385 @staticmethod
386 def list() -> bool:
387 from .bl_extension_ops import extension_repos_read
388 repos_all = extension_repos_read()
389 for repo in repos_all:
390 print("{:s}:".format(repo.module))
391 print(" name: \"{:s}\"".format(repo.name))
392 print(" directory: \"{:s}\"".format(repo.directory))
393 if url := repo.repo_url:
394 print(" url: \"{:s}\"".format(url))
396 return True
398 @staticmethod
399 def add(
401 name: str,
402 id: str,
403 directory: str,
404 url: str,
405 cache: bool,
406 clear_all: bool,
407 no_prefs: bool,
408 ) -> bool:
409 from bpy import context
411 extension_repos = context.preferences.extensions.repos
412 if clear_all:
413 while extension_repos:
414 extension_repos.remove(extension_repos[0])
416 repo = extension_repos.new(
417 name=name,
418 module=id,
419 custom_directory=directory,
420 remote_url=url,
422 repo.use_cache = cache
424 if not no_prefs:
425 blender_preferences_write()
427 return True
429 @staticmethod
430 def remove(
432 id: str,
433 no_prefs: bool,
434 ) -> bool:
435 from bpy import context
436 extension_repos = context.preferences.extensions.repos
437 extension_repos_module_map = {repo.module: repo for repo in extension_repos}
438 repo = extension_repos_module_map.get(id)
439 if repo is None:
440 sys.stderr.write("Repository: \"{:s}\" not found in [{:s}]\n".format(
442 ", ".join(["\"{:s}\"".format(x) for x in sorted(extension_repos_module_map.keys())])
444 return False
445 extension_repos.remove(repo)
446 print("Removed repo \"{:s}\"".format(id))
448 if not no_prefs:
449 blender_preferences_write()
451 return True
454 # -----------------------------------------------------------------------------
455 # Command Line Argument Definitions
457 def arg_handle_int_as_bool(value: str) -> bool:
458 result = int(value)
459 if result not in {0, 1}:
460 raise argparse.ArgumentTypeError("Expected a 0 or 1")
461 return bool(result)
464 def generic_arg_sync(subparse: argparse.ArgumentParser) -> None:
465 subparse.add_argument(
466 "-s",
467 "--sync",
468 dest="sync",
469 action="store_true",
470 default=False,
471 help=(
472 "Sync the remote directory before performing the action."
477 def generic_arg_enable_on_install(subparse: argparse.ArgumentParser) -> None:
478 subparse.add_argument(
479 "-e",
480 "--enable",
481 dest="enable",
482 action="store_true",
483 default=False,
484 help=(
485 "Enable the extension after installation."
490 def generic_arg_no_prefs(subparse: argparse.ArgumentParser) -> None:
491 subparse.add_argument(
492 "--no-prefs",
493 dest="no_prefs",
494 action="store_true",
495 default=False,
496 help=(
497 "Treat the user-preferences as read-only,\n"
498 "preventing updates for operations that would otherwise modify them.\n"
499 "This means removing extensions or repositories for example, wont update the user-preferences."
504 def generic_arg_package_list_positional(subparse: argparse.ArgumentParser) -> None:
505 subparse.add_argument(
506 dest="packages",
507 metavar="PACKAGES",
508 type=str,
509 help=(
510 "The packages to operate on (separated by ``,`` without spaces)."
515 def generic_arg_package_file_positional(subparse: argparse.ArgumentParser) -> None:
516 subparse.add_argument(
517 dest="file",
518 metavar="FILE",
519 type=str,
520 help=(
521 "The packages file."
526 def generic_arg_repo_id(subparse: argparse.ArgumentParser) -> None:
527 subparse.add_argument(
528 "-r",
529 "--repo",
530 dest="repo",
531 type=str,
532 help=(
533 "The repository identifier."
535 required=True,
539 def generic_arg_package_repo_id_positional(subparse: argparse.ArgumentParser) -> None:
540 subparse.add_argument(
541 dest="id",
542 metavar="ID",
543 type=str,
544 help=(
545 "The repository identifier."
550 # -----------------------------------------------------------------------------
551 # Blender Package Manipulation
553 def cli_extension_args_list(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
554 # Implement "list".
555 subparse = subparsers.add_parser(
556 "list",
557 help="List all packages.",
558 description=(
559 "List packages from all enabled repositories."
561 formatter_class=argparse.RawTextHelpFormatter,
563 generic_arg_sync(subparse)
565 subparse.set_defaults(
566 func=lambda args: subcmd_query.list(
567 sync=args.sync,
572 def cli_extension_args_sync(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
573 # Implement "sync".
574 subparse = subparsers.add_parser(
575 "sync",
576 help="Synchronize with remote repositories.",
577 description=(
578 "Download package information for remote repositories."
580 formatter_class=argparse.RawTextHelpFormatter,
582 subparse.set_defaults(
583 func=lambda args: subcmd_utils.sync(show_done=False),
587 def cli_extension_args_upgrade(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
588 # Implement "update".
589 subparse = subparsers.add_parser(
590 "update",
591 help="Upgrade any outdated packages.",
592 description=(
593 "Download and update any outdated packages."
595 formatter_class=argparse.RawTextHelpFormatter,
597 generic_arg_sync(subparse)
599 subparse.set_defaults(
600 func=lambda args: subcmd_pkg.update(sync=args.sync),
604 def cli_extension_args_install(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
605 # Implement "install".
606 subparse = subparsers.add_parser(
607 "install",
608 help="Install packages.",
609 formatter_class=argparse.RawTextHelpFormatter,
611 generic_arg_sync(subparse)
612 generic_arg_package_list_positional(subparse)
614 generic_arg_enable_on_install(subparse)
615 generic_arg_no_prefs(subparse)
617 subparse.set_defaults(
618 func=lambda args: subcmd_pkg.install(
619 sync=args.sync,
620 packages=args.packages.split(","),
621 enable_on_install=args.enable,
622 no_prefs=args.no_prefs,
627 def cli_extension_args_install_file(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
628 # Implement "install-file".
629 subparse = subparsers.add_parser(
630 "install-file",
631 help="Install package from file.",
632 description=(
633 "Install a package file into a local repository."
635 formatter_class=argparse.RawTextHelpFormatter,
638 generic_arg_package_file_positional(subparse)
639 generic_arg_repo_id(subparse)
641 generic_arg_enable_on_install(subparse)
642 generic_arg_no_prefs(subparse)
644 subparse.set_defaults(
645 func=lambda args: subcmd_pkg.install_file(
646 filepath=args.file,
647 repo_id=args.repo,
648 enable_on_install=args.enable,
649 no_prefs=args.no_prefs,
654 def cli_extension_args_remove(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
655 # Implement "remove".
656 subparse = subparsers.add_parser(
657 "remove",
658 help="Remove packages.",
659 description=(
660 "Disable & remove package(s)."
662 formatter_class=argparse.RawTextHelpFormatter,
664 generic_arg_package_list_positional(subparse)
665 generic_arg_no_prefs(subparse)
667 subparse.set_defaults(
668 func=lambda args: subcmd_pkg.remove(
669 packages=args.packages.split(","),
670 no_prefs=args.no_prefs,
675 # -----------------------------------------------------------------------------
676 # Blender Repository Manipulation
678 def cli_extension_args_repo_list(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
679 # Implement "repo-list".
680 subparse = subparsers.add_parser(
681 "repo-list",
682 help="List repositories.",
683 description=(
684 "List all repositories stored in Blender's preferences."
686 formatter_class=argparse.RawTextHelpFormatter,
688 subparse.set_defaults(
689 func=lambda args: subcmd_repo.list(),
693 def cli_extension_args_repo_add(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
694 # Implement "repo-add".
695 subparse = subparsers.add_parser(
696 "repo-add",
697 help="Add repository.",
698 description=(
699 "Add a new local or remote repository."
701 formatter_class=argparse.RawTextHelpFormatter,
703 generic_arg_package_repo_id_positional(subparse)
705 # Optional.
706 subparse.add_argument(
707 "--name",
708 dest="name",
709 type=str,
710 default="",
711 metavar="NAME",
712 help=(
713 "The name to display in the interface (optional)."
717 subparse.add_argument(
718 "--directory",
719 dest="directory",
720 type=str,
721 default="",
722 help=(
723 "The directory where the repository stores local files (optional).\n"
724 "When omitted a directory in the users directory is automatically selected."
727 subparse.add_argument(
728 "--url",
729 dest="url",
730 type=str,
731 default="",
732 metavar="URL",
733 help=(
734 "The URL, for remote repositories (optional).\n"
735 "When omitted the repository is considered \"local\"\n"
736 "as it is not connected to an external repository,\n"
737 "where packages may be installed by file or managed manually."
741 subparse.add_argument(
742 "--cache",
743 dest="cache",
744 metavar="BOOLEAN",
745 type=arg_handle_int_as_bool,
746 default=True,
747 help=(
748 "Use package cache (default=1)."
752 subparse.add_argument(
753 "--clear-all",
754 dest="clear_all",
755 action="store_true",
756 help=(
757 "Clear all repositories before adding, simplifies test setup."
761 generic_arg_no_prefs(subparse)
763 subparse.set_defaults(
764 func=lambda args: subcmd_repo.add(
765 id=args.id,
766 name=args.name,
767 directory=args.directory,
768 url=args.url,
769 cache=args.cache,
770 clear_all=args.clear_all,
771 no_prefs=args.no_prefs,
776 def cli_extension_args_repo_remove(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
777 # Implement "repo-remove".
778 subparse = subparsers.add_parser(
779 "repo-remove",
780 help="Remove repository.",
781 description=(
782 "Remove a repository."
784 formatter_class=argparse.RawTextHelpFormatter,
786 generic_arg_package_repo_id_positional(subparse)
787 generic_arg_no_prefs(subparse)
789 subparse.set_defaults(
790 func=lambda args: subcmd_repo.remove(
791 id=args.id,
792 no_prefs=args.no_prefs,
797 # -----------------------------------------------------------------------------
798 # Implement Additional Arguments
800 def cli_extension_args_extra(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
801 # Package commands.
802 cli_extension_args_list(subparsers)
803 cli_extension_args_sync(subparsers)
804 cli_extension_args_upgrade(subparsers)
805 cli_extension_args_install(subparsers)
806 cli_extension_args_install_file(subparsers)
807 cli_extension_args_remove(subparsers)
809 # Preference commands.
810 cli_extension_args_repo_list(subparsers)
811 cli_extension_args_repo_add(subparsers)
812 cli_extension_args_repo_remove(subparsers)
815 def cli_extension_handler(args: List[str]) -> int:
816 from .cli import blender_ext
817 result = blender_ext.main(
818 args,
819 args_internal=False,
820 args_extra_subcommands_fn=cli_extension_args_extra,
821 prog="blender --command extension",
823 # Needed as the import isn't followed by `mypy`.
824 assert isinstance(result, int)
825 return result