1 # SPDX-FileCopyrightText: 2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
6 Command line access for extension operations see:
8 blender --command extension --help
12 "cli_extension_handler",
29 False if os
.environ
.get("NO_COLOR") else
36 'black': '\033[0;30m',
37 'bright_gray': '\033[0;37m',
39 'white': '\033[1;37m',
40 'green': '\033[0;32m',
41 'bright_blue': '\033[1;34m',
43 'bright_green': '\033[1;32m',
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',
55 def colorize(text
: str, color
: str) -> str:
56 return (color_codes
[color
] + text
+ color_codes
["normal"])
58 def colorize(text
: str, color
: str) -> str:
61 # -----------------------------------------------------------------------------
65 def blender_preferences_write() -> bool:
66 import bpy
# type: ignore
68 ok
= 'FINISHED' in bpy
.ops
.wm
.save_userpref()
69 except RuntimeError as ex
:
70 print("Failed to write preferences: {!r}".format(ex
))
75 # -----------------------------------------------------------------------------
76 # Argument Implementation (Utilities)
80 def __new__(cls
) -> Any
:
81 raise RuntimeError("{:s} should not be instantiated".format(cls
))
86 show_done
: bool = True,
90 bpy
.ops
.bl_pkg
.repo_sync_all()
92 sys
.stdout
.write("Done...\n\n")
94 print("Error synchronizing")
101 def _expand_package_ids(
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
114 repos_all
= extension_repos_read()
119 repo_cache_store
.pkg_manifest_from_local_ensure(error_fn
=print)
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]
132 errors
.append("Malformed package name \"{:s}\", expected \"repo_id.pkg_id\"!".format(pkg_id_full
))
135 repo_index
, repo_packages
= repo_map
.get(repo_id
, (-1, ()))
137 errors
.append("Repository \"{:s}\" not found in [{:s}]!".format(
139 ", ".join(sorted("\"{:s}\"".format(x
) for x
in repo_map
.keys()))
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
150 errors
.append("Package \"{:s}\" not installed in local repositories!".format(pkg_id
))
152 errors
.append("Package \"{:s}\" not found in remote repositories!".format(pkg_id
))
154 repos_and_packages
.append((repo_index
, pkg_id
))
157 return "\n".join(errors
)
159 return repos_and_packages
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)
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)
175 def __new__(cls
) -> Any
:
176 raise RuntimeError("{:s} should not be instantiated".format(cls
))
186 item_remote
: Optional
[Dict
[str, Any
]],
187 item_local
: Optional
[Dict
[str, Any
]],
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
198 item_local_version
= item_local
["version"]
199 is_outdated
= item_local_version
!= item_version
201 if item_local
is not None:
203 status_info
= " [{:s}]".format(colorize("outdated: {:s} -> {:s}".format(
208 status_info
= " [{:s}]".format(colorize("installed", "green"))
213 # All local-only packages are installed.
214 status_info
= " [{:s}]".format(colorize("installed", "green"))
215 assert isinstance(item_local
, dict)
219 " {:s}{:s}: {:s}".format(
222 colorize("\"{:s}\", {:s}".format(item
["name"], item
.get("tagline", "<no tagline>")), "dark_gray"),
226 if not subcmd_utils
.sync():
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()
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
)
253 list_item(pkg_id
, item_remote
, item_local
)
255 for pkg_id
, item_local
in pkg_manifest_local
.items():
256 list_item(pkg_id
, None, item_local
)
261 # -----------------------------------------------------------------------------
262 # Argument Implementation (Packages)
266 def __new__(cls
) -> Any
:
267 raise RuntimeError("{:s} should not be instantiated".format(cls
))
275 if not subcmd_utils
.sync():
280 bpy
.ops
.bl_pkg
.pkg_upgrade_all()
282 return False # The error will have been printed.
290 enable_on_install
: bool,
294 if not subcmd_utils
.sync():
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")
305 for repo_index
, pkg_id
in repos_and_packages
:
306 bpy
.ops
.bl_pkg
.pkg_mark_set(
307 repo_index
=repo_index
,
312 bpy
.ops
.bl_pkg
.pkg_install_marked(enable_on_install
=enable_on_install
)
314 return False # The error will have been printed.
317 if enable_on_install
:
318 blender_preferences_write()
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")
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
)
340 bpy
.ops
.bl_pkg
.pkg_uninstall_marked()
342 return False # The error will have been printed.
345 blender_preferences_write()
354 enable_on_install
: bool,
359 bpy
.ops
.bl_pkg
.pkg_install_files(
362 enable_on_install
=enable_on_install
,
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")
371 if enable_on_install
:
372 blender_preferences_write()
377 # -----------------------------------------------------------------------------
378 # Argument Implementation (Repositories)
382 def __new__(cls
) -> Any
:
383 raise RuntimeError("{:s} should not be instantiated".format(cls
))
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
))
409 from bpy
import context
411 extension_repos
= context
.preferences
.extensions
.repos
413 while extension_repos
:
414 extension_repos
.remove(extension_repos
[0])
416 repo
= extension_repos
.new(
419 custom_directory
=directory
,
422 repo
.use_cache
= cache
425 blender_preferences_write()
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)
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())])
445 extension_repos
.remove(repo
)
446 print("Removed repo \"{:s}\"".format(id))
449 blender_preferences_write()
454 # -----------------------------------------------------------------------------
455 # Command Line Argument Definitions
457 def arg_handle_int_as_bool(value
: str) -> bool:
459 if result
not in {0, 1}:
460 raise argparse
.ArgumentTypeError("Expected a 0 or 1")
464 def generic_arg_sync(subparse
: argparse
.ArgumentParser
) -> None:
465 subparse
.add_argument(
472 "Sync the remote directory before performing the action."
477 def generic_arg_enable_on_install(subparse
: argparse
.ArgumentParser
) -> None:
478 subparse
.add_argument(
485 "Enable the extension after installation."
490 def generic_arg_no_prefs(subparse
: argparse
.ArgumentParser
) -> None:
491 subparse
.add_argument(
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(
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(
526 def generic_arg_repo_id(subparse
: argparse
.ArgumentParser
) -> None:
527 subparse
.add_argument(
533 "The repository identifier."
539 def generic_arg_package_repo_id_positional(subparse
: argparse
.ArgumentParser
) -> None:
540 subparse
.add_argument(
545 "The repository identifier."
550 # -----------------------------------------------------------------------------
551 # Blender Package Manipulation
553 def cli_extension_args_list(subparsers
: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
555 subparse
= subparsers
.add_parser(
557 help="List all packages.",
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(
572 def cli_extension_args_sync(subparsers
: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
574 subparse
= subparsers
.add_parser(
576 help="Synchronize with remote repositories.",
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(
591 help="Upgrade any outdated packages.",
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(
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(
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(
631 help="Install package from file.",
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(
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(
658 help="Remove packages.",
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(
682 help="List repositories.",
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(
697 help="Add repository.",
699 "Add a new local or remote repository."
701 formatter_class
=argparse
.RawTextHelpFormatter
,
703 generic_arg_package_repo_id_positional(subparse
)
706 subparse
.add_argument(
713 "The name to display in the interface (optional)."
717 subparse
.add_argument(
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(
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(
745 type=arg_handle_int_as_bool
,
748 "Use package cache (default=1)."
752 subparse
.add_argument(
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(
767 directory
=args
.directory
,
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(
780 help="Remove repository.",
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(
792 no_prefs
=args
.no_prefs
,
797 # -----------------------------------------------------------------------------
798 # Implement Additional Arguments
800 def cli_extension_args_extra(subparsers
: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
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(
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)