1 # SPDX-FileCopyrightText: 2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
6 "name": "Blender Extensions",
7 "author": "Campbell Barton",
10 "location": "Edit -> Preferences -> Extensions",
11 "description": "Extension repository support for remote repositories",
13 # "doc_url": "{BLENDER_MANUAL_URL}/addons/bl_pkg/bl_pkg.html",
14 "support": 'OFFICIAL',
25 importlib
.reload(bl_extension_ops
)
26 importlib
.reload(bl_extension_ui
)
27 importlib
.reload(bl_extension_utils
)
37 from bpy
.props
import (
44 from bpy
.types
import (
49 class BlExtPreferences(AddonPreferences
):
55 show_development_reports
: BoolProperty(
56 name
="Show Development Reports",
58 "Show the result of running commands in the main interface "
59 "this has the advantage that multiple processes that run at once have their errors properly grouped "
60 "which is not the case for reports which are mixed together"
68 # The the title of the status/notification.
70 # The result of an operation.
72 # Set to true when running (via a modal operator).
81 def from_message(self
, title
, text
):
83 for line
in text
.split("\n"):
84 if not (line
:= line
.rstrip()):
86 # Don't show any prefix for "Info" since this is implied.
87 log_new
.append(('STATUS', line
.removeprefix("Info: ")))
96 def cookie_from_session():
97 # This path is a unique string for this session.
98 # Don't use a constant as it may be changed at run-time.
99 return bpy
.app
.tempdir
102 # -----------------------------------------------------------------------------
103 # Shared Low Level Utilities
105 def repo_paths_or_none(repo_item
):
106 if (directory
:= repo_item
.directory
) == "":
108 if repo_item
.use_remote_path
:
109 if not (remote_path
:= repo_item
.remote_path
):
113 return directory
, remote_path
116 def repo_active_or_none():
117 prefs
= bpy
.context
.preferences
118 if not prefs
.experimental
.use_extension_repos
:
121 paths
= prefs
.filepaths
122 active_extension_index
= paths
.active_extension_repo
124 active_repo
= None if active_extension_index
< 0 else paths
.extension_repos
[active_extension_index
]
130 def print_debug(*args
, **kw
):
131 if not bpy
.app
.debug
:
136 use_repos_to_notify
= False
139 def repos_to_notify():
141 if not bpy
.app
.background
:
142 # To use notifications on startup requires:
143 # - The splash displayed.
144 # - The status bar displayed.
146 # Since it's not all that common to disable the status bar just run notifications
147 # if any repositories are marked to run notifications.
149 prefs
= bpy
.context
.preferences
150 if prefs
.experimental
.use_extension_repos
:
151 extension_repos
= bpy
.context
.preferences
.filepaths
.extension_repos
152 for repo_item
in extension_repos
:
153 if not repo_item
.enabled
:
155 if not repo_item
.use_sync_on_startup
:
157 if not repo_item
.use_remote_path
:
159 # Invalid, if there is no remote path this can't update.
160 if not repo_item
.remote_path
:
162 repos_notify
.append(repo_item
)
166 # -----------------------------------------------------------------------------
169 @bpy.app
.handlers
.persistent
170 def extenion_repos_sync(*_
):
171 # This is called from operators (create or an explicit call to sync)
172 # so calling a modal operator is "safe".
173 if (active_repo
:= repo_active_or_none()) is None:
176 print_debug("SYNC:", active_repo
.name
)
177 # There may be nothing to upgrade.
179 from contextlib
import redirect_stdout
181 stdout
= io
.StringIO()
183 with
redirect_stdout(stdout
):
184 bpy
.ops
.bl_pkg
.repo_sync_all('INVOKE_DEFAULT', use_active_only
=True)
186 if text
:= stdout
.getvalue():
187 repo_status_text
.from_message("Sync \"{:s}\"".format(active_repo
.name
), text
)
190 @bpy.app
.handlers
.persistent
191 def extenion_repos_upgrade(*_
):
192 # This is called from operators (create or an explicit call to sync)
193 # so calling a modal operator is "safe".
194 if (active_repo
:= repo_active_or_none()) is None:
197 print_debug("UPGRADE:", active_repo
.name
)
199 from contextlib
import redirect_stdout
201 stdout
= io
.StringIO()
203 with
redirect_stdout(stdout
):
204 bpy
.ops
.bl_pkg
.pkg_upgrade_all('INVOKE_DEFAULT', use_active_only
=True)
206 if text
:= stdout
.getvalue():
207 repo_status_text
.from_message("Upgrade \"{:s}\"".format(active_repo
.name
), text
)
210 @bpy.app
.handlers
.persistent
211 def extenion_repos_files_clear(directory
, _
):
212 # Perform a "safe" file deletion by only removing files known to be either
213 # packages or known extension meta-data.
215 # Safer because removing a repository which points to an arbitrary path
216 # has the potential to wipe user data #119481.
219 from .bl_extension_utils
import scandir_with_demoted_errors
220 # Unlikely but possible a new repository is immediately removed before initializing,
221 # avoid errors in this case.
222 if not os
.path
.isdir(directory
):
225 if os
.path
.isdir(path
:= os
.path
.join(directory
, ".blender_ext")):
228 except BaseException
as ex
:
229 print("Failed to remove files", ex
)
231 for entry
in scandir_with_demoted_errors(directory
):
232 if not entry
.is_dir():
235 if not os
.path
.exists(os
.path
.join(path
, "blender_manifest.toml")):
239 except BaseException
as ex
:
240 print("Failed to remove files", ex
)
243 # -----------------------------------------------------------------------------
246 _monkeypatch_extenions_repos_update_dirs
= set()
249 def monkeypatch_extenions_repos_update_pre_impl():
250 _monkeypatch_extenions_repos_update_dirs
.clear()
252 extension_repos
= bpy
.context
.preferences
.filepaths
.extension_repos
253 for repo_item
in extension_repos
:
254 if not repo_item
.enabled
:
256 directory
, _repo_path
= repo_paths_or_none(repo_item
)
257 if directory
is None:
260 _monkeypatch_extenions_repos_update_dirs
.add(directory
)
263 def monkeypatch_extenions_repos_update_post_impl():
265 from . import bl_extension_ops
267 bl_extension_ops
.repo_cache_store_refresh_from_prefs()
269 # Refresh newly added directories.
270 extension_repos
= bpy
.context
.preferences
.filepaths
.extension_repos
271 for repo_item
in extension_repos
:
272 if not repo_item
.enabled
:
274 directory
, _repo_path
= repo_paths_or_none(repo_item
)
275 if directory
is None:
277 # Happens for newly added extension directories.
278 if not os
.path
.exists(directory
):
280 if directory
in _monkeypatch_extenions_repos_update_dirs
:
282 # Ignore missing because the new repo might not have a JSON file.
283 repo_cache_store
.refresh_remote_from_directory(directory
=directory
, error_fn
=print, force
=True)
284 repo_cache_store
.refresh_local_from_directory(directory
=directory
, error_fn
=print, ignore_missing
=True)
286 _monkeypatch_extenions_repos_update_dirs
.clear()
289 @bpy.app
.handlers
.persistent
290 def monkeypatch_extensions_repos_update_pre(*_
):
293 monkeypatch_extenions_repos_update_pre_impl()
294 except BaseException
as ex
:
295 print_debug("ERROR", str(ex
))
297 monkeypatch_extensions_repos_update_pre
._fn
_orig
()
298 except BaseException
as ex
:
299 print_debug("ERROR", str(ex
))
302 @bpy.app
.handlers
.persistent
303 def monkeypatch_extenions_repos_update_post(*_
):
306 monkeypatch_extenions_repos_update_post
._fn
_orig
()
307 except BaseException
as ex
:
308 print_debug("ERROR", str(ex
))
310 monkeypatch_extenions_repos_update_post_impl()
311 except BaseException
as ex
:
312 print_debug("ERROR", str(ex
))
315 def monkeypatch_install():
318 handlers
= bpy
.app
.handlers
._extension
_repos
_update
_pre
319 fn_orig
= addon_utils
._initialize
_extension
_repos
_pre
320 fn_override
= monkeypatch_extensions_repos_update_pre
321 for i
, fn
in enumerate(handlers
):
323 handlers
[i
] = fn_override
324 fn_override
._fn
_orig
= fn_orig
327 handlers
= bpy
.app
.handlers
._extension
_repos
_update
_post
328 fn_orig
= addon_utils
._initialize
_extension
_repos
_post
329 fn_override
= monkeypatch_extenions_repos_update_post
330 for i
, fn
in enumerate(handlers
):
332 handlers
[i
] = fn_override
333 fn_override
._fn
_orig
= fn_orig
337 def monkeypatch_uninstall():
338 handlers
= bpy
.app
.handlers
._extension
_repos
_update
_pre
339 fn_override
= monkeypatch_extensions_repos_update_pre
340 for i
in range(len(handlers
)):
342 if fn
is fn_override
:
343 handlers
[i
] = fn_override
._fn
_orig
344 del fn_override
._fn
_orig
347 handlers
= bpy
.app
.handlers
._extension
_repos
_update
_post
348 fn_override
= monkeypatch_extenions_repos_update_post
349 for i
in range(len(handlers
)):
351 if fn
is fn_override
:
352 handlers
[i
] = fn_override
._fn
_orig
353 del fn_override
._fn
_orig
357 # Text to display in the UI (while running...).
358 repo_status_text
= StatusInfoUI()
360 # Singleton to cache all repositories JSON data and handles refreshing.
361 repo_cache_store
= None
364 # -----------------------------------------------------------------------------
367 def theme_preset_draw(menu
, context
):
368 from .bl_extension_utils
import (
373 repo_item
for repo_item
in context
.preferences
.filepaths
.extension_repos
379 menu_idname
= type(menu
).__name
__
380 for i
, pkg_manifest_local
in enumerate(repo_cache_store
.pkg_manifest_from_local_ensure(error_fn
=print)):
381 if pkg_manifest_local
is None:
383 repo_item
= repos_all
[i
]
384 directory
= repo_item
.directory
385 for pkg_idname
, value
in pkg_manifest_local
.items():
386 if value
["type"] != "theme":
389 theme_dir
, theme_files
= pkg_theme_file_list(directory
, pkg_idname
)
390 for filename
in theme_files
:
391 props
= layout
.operator(menu
.preset_operator
, text
=bpy
.path
.display_name(filename
))
392 props
.filepath
= os
.path
.join(theme_dir
, filename
)
393 props
.menu_idname
= menu_idname
396 def cli_extension(argv
):
397 from . import bl_extension_cli
398 return bl_extension_cli
.cli_extension_handler(argv
)
401 # -----------------------------------------------------------------------------
412 # pylint: disable-next=global-statement
413 global repo_cache_store
415 from bpy
.types
import WindowManager
422 if repo_cache_store
is None:
423 repo_cache_store
= bl_extension_utils
.RepoCacheStore()
425 repo_cache_store
.clear()
426 bl_extension_ops
.repo_cache_store_refresh_from_prefs()
429 bpy
.utils
.register_class(cls
)
431 bl_extension_ops
.register()
432 bl_extension_ui
.register()
434 WindowManager
.extension_search
= StringProperty(
436 description
="Filter by extension name, author & category",
437 options
={'TEXTEDIT_UPDATE'},
439 WindowManager
.extension_type
= EnumProperty(
441 ('ALL', "All", "Show all extensions"),
443 ('ADDON', "Add-ons", "Only show add-ons"),
444 ('THEME', "Themes", "Only show themes"),
446 name
="Filter by Type",
447 description
="Show extensions by type",
450 WindowManager
.extension_enabled_only
= BoolProperty(
451 name
="Show Enabled Extensions",
452 description
="Only show enabled extensions",
454 WindowManager
.extension_installed_only
= BoolProperty(
455 name
="Show Installed Extensions",
456 description
="Only show installed extensions",
458 WindowManager
.extension_show_legacy_addons
= BoolProperty(
459 name
="Show Legacy Add-Ons",
460 description
="Only show extensions, hiding legacy add-ons",
464 from bl_ui
.space_userpref
import USERPREF_MT_interface_theme_presets
465 USERPREF_MT_interface_theme_presets
.append(theme_preset_draw
)
467 handlers
= bpy
.app
.handlers
._extension
_repos
_sync
468 handlers
.append(extenion_repos_sync
)
470 handlers
= bpy
.app
.handlers
._extension
_repos
_upgrade
471 handlers
.append(extenion_repos_upgrade
)
473 handlers
= bpy
.app
.handlers
._extension
_repos
_files
_clear
474 handlers
.append(extenion_repos_files_clear
)
476 cli_commands
.append(bpy
.utils
.register_cli_command("extension", cli_extension
))
478 global use_repos_to_notify
479 if (repos_notify
:= repos_to_notify()):
480 use_repos_to_notify
= True
481 from . import bl_extension_notify
482 bl_extension_notify
.register(repos_notify
)
485 monkeypatch_install()
489 # pylint: disable-next=global-statement
490 global repo_cache_store
492 from bpy
.types
import WindowManager
498 bl_extension_ops
.unregister()
499 bl_extension_ui
.unregister()
501 del WindowManager
.extension_search
502 del WindowManager
.extension_type
503 del WindowManager
.extension_enabled_only
504 del WindowManager
.extension_installed_only
505 del WindowManager
.extension_show_legacy_addons
508 bpy
.utils
.unregister_class(cls
)
510 if repo_cache_store
is None:
513 repo_cache_store
.clear()
514 repo_cache_store
= None
516 from bl_ui
.space_userpref
import USERPREF_MT_interface_theme_presets
517 USERPREF_MT_interface_theme_presets
.remove(theme_preset_draw
)
519 handlers
= bpy
.app
.handlers
._extension
_repos
_sync
520 if extenion_repos_sync
in handlers
:
521 handlers
.remove(extenion_repos_sync
)
523 handlers
= bpy
.app
.handlers
._extension
_repos
_upgrade
524 if extenion_repos_upgrade
in handlers
:
525 handlers
.remove(extenion_repos_upgrade
)
527 handlers
= bpy
.app
.handlers
._extension
_repos
_files
_clear
528 if extenion_repos_files_clear
in handlers
:
529 handlers
.remove(extenion_repos_files_clear
)
531 for cmd
in cli_commands
:
532 bpy
.utils
.unregister_cli_command(cmd
)
535 global use_repos_to_notify
536 if use_repos_to_notify
:
537 use_repos_to_notify
= False
538 from . import bl_extension_notify
539 bl_extension_notify
.unregister()
541 monkeypatch_uninstall()