Extensions: refactor CommandBatch.exec_non_blocking return value
[blender-addons-contrib.git] / bl_pkg / bl_extension_ui.py
blobcc6f43ee00a5352bb056e7ded8993469e549535f
1 # SPDX-FileCopyrightText: 2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 """
6 GUI (WARNING) this is a hack!
7 Written to allow a UI without modifying Blender.
8 """
10 __all__ = (
11 "display_errors",
12 "register",
13 "unregister",
16 import bpy
18 from bpy.types import (
19 Menu,
20 Panel,
23 from bl_ui.space_userpref import (
24 USERPREF_PT_addons,
27 from . import repo_status_text
30 # -----------------------------------------------------------------------------
31 # Generic Utilities
34 def size_as_fmt_string(num: float, *, precision: int = 1) -> str:
35 for unit in ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB"):
36 if abs(num) < 1024.0:
37 return "{:3.{:d}f}{:s}".format(num, precision, unit)
38 num /= 1024.0
39 unit = "yb"
40 return "{:.{:d}f}{:s}".format(num, precision, unit)
43 def sizes_as_percentage_string(size_partial: int, size_final: int) -> str:
44 if size_final == 0:
45 percent = 0.0
46 else:
47 size_partial = min(size_partial, size_final)
48 percent = size_partial / size_final
50 return "{:-6.2f}%".format(percent * 100)
53 def license_info_to_text(license_list):
54 # See: https://spdx.org/licenses/
55 # - Note that we could include all, for now only common, GPL compatible licenses.
56 # - Note that many of the human descriptions are not especially more humanly readable
57 # than the short versions, so it's questionable if we should attempt to add all of these.
58 _spdx_id_to_text = {
59 "GPL-2.0-only": "GNU General Public License v2.0 only",
60 "GPL-2.0-or-later": "GNU General Public License v2.0 or later",
61 "GPL-3.0-only": "GNU General Public License v3.0 only",
62 "GPL-3.0-or-later": "GNU General Public License v3.0 or later",
64 result = []
65 for item in license_list:
66 if item.startswith("SPDX:"):
67 item = item[5:]
68 item = _spdx_id_to_text.get(item, item)
69 result.append(item)
70 return ", ".join(result)
73 def pkg_repo_and_id_from_theme_path(repos_all, filepath):
74 import os
75 if not filepath:
76 return None
78 # Strip the `theme.xml` filename.
79 dirpath = os.path.dirname(filepath)
80 repo_directory, pkg_id = os.path.split(dirpath)
81 for repo_index, repo in enumerate(repos_all):
82 if not os.path.samefile(repo_directory, repo.directory):
83 continue
84 return repo_index, pkg_id
85 return None
88 # -----------------------------------------------------------------------------
89 # Extensions UI (Legacy)
91 def extensions_panel_draw_legacy_addons(
92 layout,
93 context,
95 search_lower,
96 enabled_only,
97 installed_only,
98 used_addon_module_name_map,
100 # NOTE: this duplicates logic from `USERPREF_PT_addons` eventually this logic should be used instead.
101 # Don't de-duplicate the logic as this is a temporary state - as long as extensions remains experimental.
102 import addon_utils
103 from bpy.app.translations import (
104 pgettext_iface as iface_,
106 from .bl_extension_ops import (
107 pkg_info_check_exclude_filter_ex,
110 addons = [
111 (mod, addon_utils.module_bl_info(mod))
112 for mod in addon_utils.modules(refresh=False)
115 # Initialized on demand.
116 user_addon_paths = []
118 for mod, bl_info in addons:
119 module_name = mod.__name__
120 is_extension = addon_utils.check_extension(module_name)
121 if is_extension:
122 continue
124 if search_lower and (
125 not pkg_info_check_exclude_filter_ex(
126 bl_info["name"],
127 bl_info["description"],
128 search_lower,
131 continue
133 is_enabled = module_name in used_addon_module_name_map
134 if enabled_only and (not is_enabled):
135 continue
137 col_box = layout.column()
138 box = col_box.box()
139 colsub = box.column()
140 row = colsub.row(align=True)
142 row.operator(
143 "preferences.addon_expand",
144 icon='DISCLOSURE_TRI_DOWN' if bl_info["show_expanded"] else 'DISCLOSURE_TRI_RIGHT',
145 emboss=False,
146 ).module = module_name
148 row.operator(
149 "preferences.addon_disable" if is_enabled else "preferences.addon_enable",
150 icon='CHECKBOX_HLT' if is_enabled else 'CHECKBOX_DEHLT', text="",
151 emboss=False,
152 ).module = module_name
154 sub = row.row()
155 sub.active = is_enabled
156 sub.label(text="Legacy: " + bl_info["name"])
158 if bl_info["warning"]:
159 sub.label(icon='ERROR')
161 row_right = row.row()
162 row_right.alignment = 'RIGHT'
164 row_right.label(text="Installed ")
165 row_right.active = False
167 if bl_info["show_expanded"]:
168 split = box.split(factor=0.15)
169 col_a = split.column()
170 col_b = split.column()
171 if value := bl_info["description"]:
172 col_a.label(text="Description:")
173 col_b.label(text=iface_(value))
175 col_a.label(text="File:")
176 col_b.label(text=mod.__file__, translate=False)
178 if value := bl_info["author"]:
179 col_a.label(text="Author:")
180 col_b.label(text=value.split("<", 1)[0].rstrip(), translate=False)
181 if value := bl_info["version"]:
182 col_a.label(text="Version:")
183 col_b.label(text=".".join(str(x) for x in value), translate=False)
184 if value := bl_info["warning"]:
185 col_a.label(text="Warning:")
186 col_b.label(text=" " + iface_(value), icon='ERROR')
187 del value
189 # Include for consistency.
190 col_a.label(text="Type:")
191 col_b.label(text="add-on")
193 user_addon = USERPREF_PT_addons.is_user_addon(mod, user_addon_paths)
195 if bl_info["doc_url"] or bl_info.get("tracker_url"):
196 split = box.row().split(factor=0.15)
197 split.label(text="Internet:")
198 sub = split.row()
199 if bl_info["doc_url"]:
200 sub.operator(
201 "wm.url_open", text="Documentation", icon='HELP',
202 ).url = bl_info["doc_url"]
203 # Only add "Report a Bug" button if tracker_url is set
204 # or the add-on is bundled (use official tracker then).
205 if bl_info.get("tracker_url"):
206 sub.operator(
207 "wm.url_open", text="Report a Bug", icon='URL',
208 ).url = bl_info["tracker_url"]
209 elif not user_addon:
210 addon_info = (
211 "Name: %s %s\n"
212 "Author: %s\n"
213 ) % (bl_info["name"], str(bl_info["version"]), bl_info["author"])
214 props = sub.operator(
215 "wm.url_open_preset", text="Report a Bug", icon='URL',
217 props.type = 'BUG_ADDON'
218 props.id = addon_info
220 if user_addon:
221 rowsub = col_b.row()
222 rowsub.alignment = 'RIGHT'
223 rowsub.operator(
224 "preferences.addon_remove", text="Uninstall", icon='CANCEL',
225 ).module = module_name
227 if is_enabled:
228 if (addon_preferences := used_addon_module_name_map[module_name].preferences) is not None:
229 USERPREF_PT_addons.draw_addon_preferences(layout, context, addon_preferences)
232 # -----------------------------------------------------------------------------
233 # Extensions UI
235 class display_errors:
237 This singleton class is used to store errors which are generated while drawing,
238 note that these errors are reasonably obscure, examples are:
239 - Failure to parse the repository JSON file.
240 - Failure to access the file-system for reading where the repository is stored.
242 The current and previous state are compared, when they match no drawing is done,
243 this allows the current display errors to be dismissed.
245 errors_prev = []
246 errors_curr = []
248 @staticmethod
249 def clear():
250 display_errors.errors_prev = display_errors.errors_curr
252 @staticmethod
253 def draw(layout):
254 if display_errors.errors_curr == display_errors.errors_prev:
255 return
256 box_header = layout.box()
257 # Don't clip longer names.
258 row = box_header.split(factor=0.9)
259 row.label(text="Repository Access Errors:", icon='ERROR')
260 rowsub = row.row(align=True)
261 rowsub.alignment = 'RIGHT'
262 rowsub.operator("bl_pkg.pkg_display_errors_clear", text="", icon='X', emboss=False)
264 box_contents = box_header.box()
265 for err in display_errors.errors_curr:
266 box_contents.label(text=err)
269 def extensions_panel_draw_impl(
270 self,
271 context,
272 search_lower,
273 filter_by_type,
274 enabled_only,
275 installed_only,
276 show_legacy_addons,
277 show_development,
280 Show all the items... we may want to paginate at some point.
282 import os
283 from .bl_extension_ops import (
284 blender_extension_mark,
285 blender_extension_show,
286 extension_repos_read,
287 pkg_info_check_exclude_filter,
288 repo_cache_store_refresh_from_prefs,
291 from . import repo_cache_store
293 # This isn't elegant, but the preferences aren't available on registration.
294 if not repo_cache_store.is_init():
295 repo_cache_store_refresh_from_prefs()
297 layout = self.layout
299 # Define a top-most column to place warnings (if-any).
300 # Needed so the warnings aren't mixed in with other content.
301 layout_topmost = layout.column()
303 repos_all = extension_repos_read()
305 # To access enabled add-ons.
306 show_addons = filter_by_type in {"", "add-on"}
307 show_themes = filter_by_type in {"", "theme"}
308 if show_addons:
309 used_addon_module_name_map = {addon.module: addon for addon in context.preferences.addons}
310 if show_themes:
311 active_theme_info = pkg_repo_and_id_from_theme_path(repos_all, context.preferences.themes[0].filepath)
313 # Collect exceptions accessing repositories, and optionally show them.
314 errors_on_draw = []
316 remote_ex = None
317 local_ex = None
319 def error_fn_remote(ex):
320 nonlocal remote_ex
321 remote_ex = ex
323 def error_fn_local(ex):
324 nonlocal remote_ex
325 remote_ex = ex
327 for repo_index, (
328 pkg_manifest_remote,
329 pkg_manifest_local,
330 ) in enumerate(zip(
331 repo_cache_store.pkg_manifest_from_remote_ensure(error_fn=error_fn_remote),
332 repo_cache_store.pkg_manifest_from_local_ensure(error_fn=error_fn_local),
334 # Show any exceptions created while accessing the JSON,
335 # if the JSON has an IO error while being read or if the directory doesn't exist.
336 # In general users should _not_ see these kinds of errors however we cannot prevent
337 # IO errors in general and it is better to show a warning than to ignore the error entirely
338 # or cause a trace-back which breaks the UI.
339 if (remote_ex is not None) or (local_ex is not None):
340 repo = repos_all[repo_index]
341 # NOTE: `FileNotFoundError` occurs when a repository has been added but has not update with its remote.
342 # We may want a way for users to know a repository is missing from the view and they need to run update
343 # to access its extensions.
344 if remote_ex is not None:
345 if isinstance(remote_ex, FileNotFoundError) and (remote_ex.filename == repo.directory):
346 pass
347 else:
348 errors_on_draw.append("Remote of \"{:s}\": {:s}".format(repo.name, str(remote_ex)))
349 remote_ex = None
351 if local_ex is not None:
352 if isinstance(local_ex, FileNotFoundError) and (local_ex.filename == repo.directory):
353 pass
354 else:
355 errors_on_draw.append("Local of \"{:s}\": {:s}".format(repo.name, str(local_ex)))
356 local_ex = None
357 continue
359 if pkg_manifest_remote is None:
360 repo = repos_all[repo_index]
361 has_remote = (repo.repo_url != "")
362 if has_remote:
363 # NOTE: it would be nice to detect when the repository ran sync and it failed.
364 # This isn't such an important distinction though, the main thing users should be aware of
365 # is that a "sync" is required.
366 errors_on_draw.append("Repository: \"{:s}\" must sync with the remote repository.".format(repo.name))
367 del repo
368 continue
369 else:
370 repo = repos_all[repo_index]
371 has_remote = (repo.repo_url != "")
372 del repo
374 for pkg_id, item_remote in pkg_manifest_remote.items():
375 if filter_by_type and (filter_by_type != item_remote["type"]):
376 continue
377 if search_lower and (not pkg_info_check_exclude_filter(item_remote, search_lower)):
378 continue
380 item_local = pkg_manifest_local.get(pkg_id)
381 is_installed = item_local is not None
383 if installed_only and (is_installed == 0):
384 continue
386 is_addon = False
387 is_theme = False
388 match item_remote["type"]:
389 case "add-on":
390 is_addon = True
391 case "theme":
392 is_theme = True
394 if is_addon:
395 if is_installed:
396 # Currently we only need to know the module name once installed.
397 addon_module_name = "bl_ext.{:s}.{:s}".format(repos_all[repo_index].module, pkg_id)
398 is_enabled = addon_module_name in used_addon_module_name_map
400 else:
401 is_enabled = False
402 addon_module_name = None
403 elif is_theme:
404 is_enabled = (repo_index, pkg_id) == active_theme_info
405 addon_module_name = None
406 else:
407 # TODO: ability to disable.
408 is_enabled = is_installed
409 addon_module_name = None
411 if enabled_only and (not is_enabled):
412 continue
414 item_version = item_remote["version"]
415 if item_local is None:
416 item_local_version = None
417 is_outdated = False
418 else:
419 item_local_version = item_local["version"]
420 is_outdated = item_local_version != item_version
422 key = (pkg_id, repo_index)
423 if show_development:
424 mark = key in blender_extension_mark
425 show = key in blender_extension_show
426 del key
428 box = layout.box()
430 # Left align so the operator text isn't centered.
431 colsub = box.column()
432 row = colsub.row(align=True)
433 # row.label
434 if show:
435 props = row.operator("bl_pkg.pkg_show_clear", text="", icon='DISCLOSURE_TRI_DOWN', emboss=False)
436 else:
437 props = row.operator("bl_pkg.pkg_show_set", text="", icon='DISCLOSURE_TRI_RIGHT', emboss=False)
438 props.pkg_id = pkg_id
439 props.repo_index = repo_index
440 del props
442 if is_installed:
443 if is_addon:
444 row.operator(
445 "preferences.addon_disable" if is_enabled else "preferences.addon_enable",
446 icon='CHECKBOX_HLT' if is_enabled else 'CHECKBOX_DEHLT',
447 text="",
448 emboss=False,
449 ).module = addon_module_name
450 elif is_theme:
451 props = row.operator(
452 "bl_pkg.extension_theme_disable" if is_enabled else "bl_pkg.extension_theme_enable",
453 icon='CHECKBOX_HLT' if is_enabled else 'CHECKBOX_DEHLT',
454 text="",
455 emboss=False,
457 props.repo_index = repo_index
458 props.pkg_id = pkg_id
459 del props
460 else:
461 # Use a place-holder checkbox icon to avoid odd text alignment when mixing with installed add-ons.
462 # Non add-ons have no concept of "enabled" right now, use installed.
463 row.operator(
464 "bl_pkg.extension_disable",
465 text="",
466 icon='CHECKBOX_HLT',
467 emboss=False,
469 else:
470 # Not installed, always placeholder.
471 row.operator("bl_pkg.extensions_enable_not_installed", text="", icon='CHECKBOX_DEHLT', emboss=False)
473 if show_development:
474 if mark:
475 props = row.operator("bl_pkg.pkg_mark_clear", text="", icon='RADIOBUT_ON', emboss=False)
476 else:
477 props = row.operator("bl_pkg.pkg_mark_set", text="", icon='RADIOBUT_OFF', emboss=False)
478 props.pkg_id = pkg_id
479 props.repo_index = repo_index
480 del props
482 sub = row.row()
483 sub.active = is_enabled
484 sub.label(text=item_remote["name"])
485 del sub
487 row_right = row.row()
488 row_right.alignment = 'RIGHT'
490 if has_remote:
491 if is_installed:
492 # Include uninstall below.
493 if is_outdated:
494 props = row_right.operator("bl_pkg.pkg_install", text="Update")
495 props.repo_index = repo_index
496 props.pkg_id = pkg_id
497 del props
498 else:
499 # Right space for alignment with the button.
500 row_right.label(text="Installed ")
501 row_right.active = False
502 else:
503 props = row_right.operator("bl_pkg.pkg_install", text="Install")
504 props.repo_index = repo_index
505 props.pkg_id = pkg_id
506 del props
507 else:
508 # Right space for alignment with the button.
509 row_right.label(text="Installed ")
510 row_right.active = False
512 if show:
513 split = box.split(factor=0.15)
514 col_a = split.column()
515 col_b = split.column()
517 col_a.label(text="Description:")
518 # The full description may be multiple lines (not yet supported by Blender's UI).
519 col_b.label(text=item_remote["tagline"])
521 if is_installed:
522 col_a.label(text="Path:")
523 col_b.label(text=os.path.join(repos_all[repo_index].directory, pkg_id), translate=False)
525 # Remove the maintainers email while it's not private, showing prominently
526 # could cause maintainers to get direct emails instead of issue tracking systems.
527 col_a.label(text="Maintainer:")
528 col_b.label(text=item_remote["maintainer"].split("<", 1)[0].rstrip(), translate=False)
530 col_a.label(text="License:")
531 col_b.label(text=license_info_to_text(item_remote["license"]))
533 col_a.label(text="Version:")
534 if is_outdated:
535 col_b.label(text="{:s} ({:s} available)".format(item_local_version, item_version))
536 else:
537 col_b.label(text=item_version)
539 if has_remote:
540 col_a.label(text="Size:")
541 col_b.label(text=size_as_fmt_string(item_remote["archive_size"]))
543 if not filter_by_type:
544 col_a.label(text="Type:")
545 col_b.label(text=item_remote["type"])
547 if len(repos_all) > 1:
548 col_a.label(text="Repository:")
549 col_b.label(text=repos_all[repo_index].name)
551 if value := item_remote.get("website"):
552 col_a.label(text="Internet:")
553 # Use half size button, for legacy add-ons there are two, here there is one
554 # however one large button looks silly, so use a half size still.
555 col_b.split(factor=0.5).operator("wm.url_open", text="Website", icon='HELP').url = value
556 del value
558 # Note that we could allow removing extensions from non-remote extension repos
559 # although this is destructive, so don't enable this right now.
560 if is_installed:
561 rowsub = col_b.row()
562 rowsub.alignment = 'RIGHT'
563 props = rowsub.operator("bl_pkg.pkg_uninstall", text="Uninstall")
564 props.repo_index = repo_index
565 props.pkg_id = pkg_id
566 del props, rowsub
568 # Show addon user preferences.
569 if is_enabled and is_addon:
570 if (addon_preferences := used_addon_module_name_map[addon_module_name].preferences) is not None:
571 USERPREF_PT_addons.draw_addon_preferences(layout, context, addon_preferences)
573 if show_addons and show_legacy_addons:
574 extensions_panel_draw_legacy_addons(
575 layout,
576 context,
577 search_lower=search_lower,
578 enabled_only=enabled_only,
579 installed_only=installed_only,
580 used_addon_module_name_map=used_addon_module_name_map,
583 # Finally show any errors in a single panel which can be dismissed.
584 display_errors.errors_curr = errors_on_draw
585 if errors_on_draw:
586 display_errors.draw(layout_topmost)
589 class USERPREF_PT_extensions_bl_pkg_filter(Panel):
590 bl_label = "Extensions Filter"
592 bl_space_type = 'TOPBAR' # dummy.
593 bl_region_type = 'HEADER'
594 bl_ui_units_x = 13
596 def draw(self, context):
597 layout = self.layout
599 wm = context.window_manager
600 col = layout.column(heading="Show")
601 col.use_property_split = True
602 col.prop(wm, "extension_show_legacy_addons", text="Legacy Add-ons")
604 col = layout.column(heading="Only")
605 col.use_property_split = True
606 col.prop(wm, "extension_enabled_only", text="Enabled Extensions")
607 sub = col.column()
608 sub.active = not wm.extension_enabled_only
609 sub.prop(wm, "extension_installed_only", text="Installed Extensions")
612 class USERPREF_MT_extensions_bl_pkg_settings(Menu):
613 bl_label = "Extension Settings"
615 def draw(self, context):
616 layout = self.layout
618 addon_prefs = context.preferences.addons[__package__].preferences
620 layout.operator("bl_pkg.repo_sync_all", text="Check for Updates", icon='FILE_REFRESH')
622 layout.separator()
624 layout.operator("bl_pkg.pkg_upgrade_all", text="Install Available Updates", icon='IMPORT')
625 layout.operator("bl_pkg.pkg_install_files", text="Install from Disk")
626 layout.operator("preferences.addon_install", text="Install Legacy Add-on")
628 if context.preferences.experimental.use_extension_utils:
629 layout.separator()
631 layout.prop(addon_prefs, "show_development_reports")
633 layout.separator()
635 # We might want to expose this for all users, the purpose of this
636 # is to refresh after changes have been made to the repos outside of Blender
637 # it's disputable if this is a common case.
638 layout.operator("preferences.addon_refresh", text="Refresh (file-system)", icon='FILE_REFRESH')
639 layout.separator()
641 layout.operator("bl_pkg.pkg_install_marked", text="Install Marked", icon='IMPORT')
642 layout.operator("bl_pkg.pkg_uninstall_marked", text="Uninstall Marked", icon='X')
643 layout.operator("bl_pkg.obsolete_marked")
645 layout.separator()
647 layout.operator("bl_pkg.repo_lock")
648 layout.operator("bl_pkg.repo_unlock")
651 def extensions_panel_draw(panel, context):
652 prefs = context.preferences
653 if not prefs.experimental.use_extension_repos:
654 # Unexpected, the extension is disabled but this add-on is.
655 # In this case don't show the UI as it is confusing.
656 return
658 from .bl_extension_ops import (
659 blender_filter_by_type_map,
662 addon_prefs = prefs.addons[__package__].preferences
664 show_development = context.preferences.experimental.use_extension_utils
665 show_development_reports = show_development and addon_prefs.show_development_reports
667 wm = context.window_manager
668 layout = panel.layout
670 row = layout.split(factor=0.5)
671 row_a = row.row()
672 row_a.prop(wm, "extension_search", text="", icon='VIEWZOOM')
673 row_b = row.row(align=True)
674 row_b.prop(wm, "extension_type", text="")
675 row_b.popover("USERPREF_PT_extensions_bl_pkg_filter", text="", icon='FILTER')
677 row_b.separator()
678 row_b.popover("USERPREF_PT_extensions_repos", text="Repositories")
680 row_b.separator()
681 row_b.menu("USERPREF_MT_extensions_bl_pkg_settings", text="", icon='DOWNARROW_HLT')
682 del row, row_a, row_b
684 if show_development_reports:
685 show_status = bool(repo_status_text.log)
686 else:
687 # Only show if running and there is progress to display.
688 show_status = bool(repo_status_text.log) and repo_status_text.running
689 if show_status:
690 show_status = False
691 for ty, msg in repo_status_text.log:
692 if ty == 'PROGRESS':
693 show_status = True
695 if show_status:
696 box = layout.box()
697 # Don't clip longer names.
698 row = box.split(factor=0.9, align=True)
699 if repo_status_text.running:
700 row.label(text=repo_status_text.title + "...", icon='INFO')
701 else:
702 row.label(text=repo_status_text.title, icon='INFO')
703 if show_development_reports:
704 rowsub = row.row(align=True)
705 rowsub.alignment = 'RIGHT'
706 rowsub.operator("bl_pkg.pkg_status_clear", text="", icon='X', emboss=False)
707 boxsub = box.box()
708 for ty, msg in repo_status_text.log:
709 if ty == 'STATUS':
710 boxsub.label(text=msg)
711 elif ty == 'PROGRESS':
712 msg_str, progress_unit, progress, progress_range = msg
713 if progress <= progress_range:
714 boxsub.progress(
715 factor=progress / progress_range,
716 text="{:s}, {:s}".format(
717 sizes_as_percentage_string(progress, progress_range),
718 msg_str,
721 elif progress_unit == 'BYTE':
722 boxsub.progress(factor=0.0, text="{:s}, {:s}".format(msg_str, size_as_fmt_string(progress)))
723 else:
724 # We might want to support other types.
725 boxsub.progress(factor=0.0, text="{:s}, {:d}".format(msg_str, progress))
726 else:
727 boxsub.label(text="{:s}: {:s}".format(ty, msg))
729 # Hide when running.
730 if repo_status_text.running:
731 return
733 extensions_panel_draw_impl(
734 panel,
735 context,
736 wm.extension_search.lower(),
737 blender_filter_by_type_map[wm.extension_type],
738 wm.extension_enabled_only,
739 wm.extension_installed_only,
740 wm.extension_show_legacy_addons,
741 show_development,
745 classes = (
746 # Pop-overs.
747 USERPREF_PT_extensions_bl_pkg_filter,
748 USERPREF_MT_extensions_bl_pkg_settings,
752 def register():
753 USERPREF_PT_addons.append(extensions_panel_draw)
755 for cls in classes:
756 bpy.utils.register_class(cls)
759 def unregister():
760 USERPREF_PT_addons.remove(extensions_panel_draw)
762 for cls in reversed(classes):
763 bpy.utils.unregister_class(cls)