Extensions: refactor CommandBatch.exec_non_blocking return value
[blender-addons-contrib.git] / bl_pkg / __init__.py
blobc538db6e2a124d7b3935d8c434625f5615f757f8
1 # SPDX-FileCopyrightText: 2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 bl_info = {
6 "name": "Blender Extensions",
7 "author": "Campbell Barton",
8 "version": (0, 0, 1),
9 "blender": (4, 0, 0),
10 "location": "Edit -> Preferences -> Extensions",
11 "description": "Extension repository support for remote repositories",
12 "warning": "",
13 # "doc_url": "{BLENDER_MANUAL_URL}/addons/bl_pkg/bl_pkg.html",
14 "support": 'OFFICIAL',
15 "category": "System",
18 if "bpy" in locals():
19 import importlib
20 from . import (
21 bl_extension_ops,
22 bl_extension_ui,
23 bl_extension_utils,
25 importlib.reload(bl_extension_ops)
26 importlib.reload(bl_extension_ui)
27 importlib.reload(bl_extension_utils)
28 del (
29 bl_extension_ops,
30 bl_extension_ui,
31 bl_extension_utils,
33 del importlib
35 import bpy
37 from bpy.props import (
38 BoolProperty,
39 EnumProperty,
40 IntProperty,
41 StringProperty,
44 from bpy.types import (
45 AddonPreferences,
49 class BlExtPreferences(AddonPreferences):
50 bl_idname = __name__
51 timeout: IntProperty(
52 name="Time Out",
53 default=10,
55 show_development_reports: BoolProperty(
56 name="Show Development Reports",
57 description=(
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"
62 default=False,
66 class StatusInfoUI:
67 __slots__ = (
68 # The the title of the status/notification.
69 "title",
70 # The result of an operation.
71 "log",
72 # Set to true when running (via a modal operator).
73 "running",
76 def __init__(self):
77 self.log = []
78 self.title = ""
79 self.running = False
81 def from_message(self, title, text):
82 log_new = []
83 for line in text.split("\n"):
84 if not (line := line.rstrip()):
85 continue
86 # Don't show any prefix for "Info" since this is implied.
87 log_new.append(('STATUS', line.removeprefix("Info: ")))
88 if not log_new:
89 return
91 self.title = title
92 self.running = False
93 self.log = log_new
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) == "":
107 return None, None
108 if repo_item.use_remote_path:
109 if not (remote_path := repo_item.remote_path):
110 return None, None
111 else:
112 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:
119 return
121 paths = prefs.filepaths
122 active_extension_index = paths.active_extension_repo
123 try:
124 active_repo = None if active_extension_index < 0 else paths.extension_repos[active_extension_index]
125 except IndexError:
126 active_repo = None
127 return active_repo
130 def print_debug(*args, **kw):
131 if not bpy.app.debug:
132 return
133 print(*args, **kw)
136 use_repos_to_notify = False
139 def repos_to_notify():
140 repos_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:
154 continue
155 if not repo_item.use_sync_on_startup:
156 continue
157 if not repo_item.use_remote_path:
158 continue
159 # Invalid, if there is no remote path this can't update.
160 if not repo_item.remote_path:
161 continue
162 repos_notify.append(repo_item)
163 return repos_notify
166 # -----------------------------------------------------------------------------
167 # Handlers
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:
174 return
176 print_debug("SYNC:", active_repo.name)
177 # There may be nothing to upgrade.
179 from contextlib import redirect_stdout
180 import io
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:
195 return
197 print_debug("UPGRADE:", active_repo.name)
199 from contextlib import redirect_stdout
200 import io
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.
217 import shutil
218 import os
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):
223 return
225 if os.path.isdir(path := os.path.join(directory, ".blender_ext")):
226 try:
227 shutil.rmtree(path)
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():
233 continue
234 path = entry.path
235 if not os.path.exists(os.path.join(path, "blender_manifest.toml")):
236 continue
237 try:
238 shutil.rmtree(path)
239 except BaseException as ex:
240 print("Failed to remove files", ex)
243 # -----------------------------------------------------------------------------
244 # Wrap Handlers
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:
255 continue
256 directory, _repo_path = repo_paths_or_none(repo_item)
257 if directory is None:
258 continue
260 _monkeypatch_extenions_repos_update_dirs.add(directory)
263 def monkeypatch_extenions_repos_update_post_impl():
264 import os
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:
273 continue
274 directory, _repo_path = repo_paths_or_none(repo_item)
275 if directory is None:
276 continue
277 # Happens for newly added extension directories.
278 if not os.path.exists(directory):
279 continue
280 if directory in _monkeypatch_extenions_repos_update_dirs:
281 continue
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(*_):
291 print_debug("PRE:")
292 try:
293 monkeypatch_extenions_repos_update_pre_impl()
294 except BaseException as ex:
295 print_debug("ERROR", str(ex))
296 try:
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(*_):
304 print_debug("POST:")
305 try:
306 monkeypatch_extenions_repos_update_post._fn_orig()
307 except BaseException as ex:
308 print_debug("ERROR", str(ex))
309 try:
310 monkeypatch_extenions_repos_update_post_impl()
311 except BaseException as ex:
312 print_debug("ERROR", str(ex))
315 def monkeypatch_install():
316 import addon_utils
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):
322 if fn is fn_orig:
323 handlers[i] = fn_override
324 fn_override._fn_orig = fn_orig
325 break
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):
331 if fn is fn_orig:
332 handlers[i] = fn_override
333 fn_override._fn_orig = fn_orig
334 break
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)):
341 fn = handlers[i]
342 if fn is fn_override:
343 handlers[i] = fn_override._fn_orig
344 del fn_override._fn_orig
345 break
347 handlers = bpy.app.handlers._extension_repos_update_post
348 fn_override = monkeypatch_extenions_repos_update_post
349 for i in range(len(handlers)):
350 fn = handlers[i]
351 if fn is fn_override:
352 handlers[i] = fn_override._fn_orig
353 del fn_override._fn_orig
354 break
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 # -----------------------------------------------------------------------------
365 # Theme Integration
367 def theme_preset_draw(menu, context):
368 from .bl_extension_utils import (
369 pkg_theme_file_list,
371 layout = menu.layout
372 repos_all = [
373 repo_item for repo_item in context.preferences.filepaths.extension_repos
374 if repo_item.enabled
376 if not repos_all:
377 return
378 import os
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:
382 continue
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":
387 continue
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 # -----------------------------------------------------------------------------
402 # Registration
404 classes = (
405 BlExtPreferences,
408 cli_commands = []
411 def register():
412 # pylint: disable-next=global-statement
413 global repo_cache_store
415 from bpy.types import WindowManager
416 from . import (
417 bl_extension_ops,
418 bl_extension_ui,
419 bl_extension_utils,
422 if repo_cache_store is None:
423 repo_cache_store = bl_extension_utils.RepoCacheStore()
424 else:
425 repo_cache_store.clear()
426 bl_extension_ops.repo_cache_store_refresh_from_prefs()
428 for cls in classes:
429 bpy.utils.register_class(cls)
431 bl_extension_ops.register()
432 bl_extension_ui.register()
434 WindowManager.extension_search = StringProperty(
435 name="Filter",
436 description="Filter by extension name, author & category",
437 options={'TEXTEDIT_UPDATE'},
439 WindowManager.extension_type = EnumProperty(
440 items=(
441 ('ALL', "All", "Show all extensions"),
442 None,
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",
448 default='ALL',
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",
461 default=True,
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)
483 del repos_notify
485 monkeypatch_install()
488 def unregister():
489 # pylint: disable-next=global-statement
490 global repo_cache_store
492 from bpy.types import WindowManager
493 from . import (
494 bl_extension_ops,
495 bl_extension_ui,
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
507 for cls in classes:
508 bpy.utils.unregister_class(cls)
510 if repo_cache_store is None:
511 pass
512 else:
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)
533 cli_commands.clear()
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()