diff --git a/oscillode/__init__.py b/oscillode/__init__.py index 597c57b..8df68c7 100644 --- a/oscillode/__init__.py +++ b/oscillode/__init__.py @@ -14,8 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors +# oscillode Copyright (C) 2024 oscillode Project Contributors # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -30,205 +29,76 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -"""A Blender-based system for electromagnetic simulation design and analysis, with deep Tidy3D integration. +"""A visual DSL for electromagnetic simulation design and analysis implemented as a Blender node editor.""" -# `bl_info` -`bl_info` declares information about the addon to Blender. +from . import assets, node_trees, operators, preferences, registration +from . import contracts as ct +from .utils import logger -However, it is not _dynamically_ read: Blender traverses it using `ast.parse`. -This makes it difficult to synchronize `bl_info` with the project's `pyproject.toml`. -As a workaround, **the addon zip-packer will replace `bl_info` entries**. - -The following `bl_info` entries are currently replaced when the ZIP is built: - -- `description`: To match the description in `pyproject.toml`. -- `version`: To match the version in `pyproject.toml`. - -For more information, see `scripts.pack.BL_INFO_REPLACEMENTS`. - -**NOTE**: The find/replace procedure is "dumb" (aka. no regex, no `ast` traversal, etc.). - -This is surprisingly robust, so long as use of the deterministic code-formatter `ruff fmt` is enforced. - -Still. Be careful around `bl_info`. - -Attributes: - bl_info: Information about the addon declared to Blender. - BL_REGISTER_BEFORE_DEPS: Blender classes to register before dependencies are verified as installed. - BL_HOTKEYS: Blender keymap item defs to register before dependencies are verified as installed. -""" - -from pathlib import Path - -from .nodeps.utils import simple_logger - -# Initialize Logging Defaults -## Initial logger settings (ex. log level) must be set somehow. -## The Addon ZIP-packer makes this decision, and packs it into files. -## AddonPreferences will, once loaded, override this. -_PATH_ADDON_ROOT = Path(__file__).resolve().parent -_PATH_BOOTSTRAP_LOG_LEVEL = _PATH_ADDON_ROOT / '.bootstrap_log_level' -with _PATH_BOOTSTRAP_LOG_LEVEL.open('r') as f: - _BOOTSTRAP_LOG_LEVEL = int(f.read().strip()) - -simple_logger.init_simple_logger_defaults(console_level=_BOOTSTRAP_LOG_LEVEL) - -# Import Statements -import bpy # noqa: E402 - -from . import contracts as ct # noqa: E402 -from . import preferences, registration # noqa: E402 -from .nodeps import operators as nodeps_operators # noqa: E402 -from .nodeps.utils import pydeps # noqa: E402 - -log = simple_logger.get(__name__) +log = logger.get(__name__) #################### # - Load and Register Addon #################### -BL_REGISTER_BEFORE_DEPS: list[ct.BLClass] = [ - *nodeps_operators.BL_REGISTER, - *preferences.BL_REGISTER, +BL_REGISTER: list[ct.BLClass] = [ + *operators.BL_REGISTER, + *assets.BL_REGISTER, + *node_trees.BL_REGISTER, +] + +BL_HOTKEYS: list[ct.KeymapItemDef] = [ + *operators.BL_HOTKEYS, + *assets.BL_HOTKEYS, ] ## TODO: BL_HANDLERS and BL_SOCKET_DEFS -BL_HOTKEYS_BEFORE_DEPS: list[ct.KeymapItemDef] = [ - *nodeps_operators.BL_HOTKEYS, -] - - -def load_main_blclasses(path_pydeps: Path) -> list[ct.BLClass]: - """Imports all addon classes that rely on Python dependencies. - - Notes: - `sys.path` is modified while executing this function. - - Parameters: - path_pydeps: The path to the Python dependencies. - - Returns: - An ordered list of Blender classes to register. - """ - with pydeps.importable_addon_deps(path_pydeps): - from . import assets, node_trees, operators - return [ - *operators.BL_REGISTER, - *assets.BL_REGISTER, - *node_trees.BL_REGISTER, - ] - - -def load_main_blhotkeys(path_deps: Path) -> list[ct.KeymapItemDef]: - """Imports all keymap item defs that rely on Python dependencies. - - Notes: - `sys.path` is modified while executing this function. - - Parameters: - path_pydeps: The path to the Python dependencies. - - Returns: - An ordered list of Blender keymap item defs to register. - """ - with pydeps.importable_addon_deps(path_deps): - from . import assets, operators - return [ - *operators.BL_HOTKEYS, - *assets.BL_HOTKEYS, - ] - #################### # - Registration #################### -@bpy.app.handlers.persistent -def manage_pydeps(*_): - # ct.addon.operator( - # ct.OperatorType.ManagePyDeps, - # 'INVOKE_DEFAULT', - # path_addon_pydeps='', - # path_addon_reqs='', - # ) - log.debug('PyDeps: Analyzing Post-File Load') - ct.addon.prefs().on_addon_pydeps_changed(show_popup_if_deps_invalid=True) - - def register() -> None: - """Implements a multi-stage addon registration, which accounts for Python dependency management. - - # Multi-Stage Registration - The trouble is that many classes in our addon might require Python dependencies. - - ## Stage 1: Barebones Addon - Many classes in our addon might require Python dependencies. - However, they may not yet be installed. - - To solve this bootstrapping problem in a streamlined manner, we only **guarantee** the registration of a few key classes, including: - - - `AddonPreferences`: The addon preferences provide an interface for the user to fix Python dependency problems, thereby triggering subsequent stages. - - `InstallPyDeps`: An operator that installs missing Python dependencies, using Blender's embeded `pip`. - - `UninstallPyDeps`: An operator that uninstalls Python dependencies. - - **These classes provide just enough interface to help the user install the missing Python dependencies**. - - ## Stage 2: Declare Delayed Registration - We may not be able to register any classes that rely on Python dependencies. - However, we can use `registration.delay_registration()` to **delay the registration until it is determined that the Python dependencies are satisfied**.` - - For now, we just pass a callback that will import + return a list of classes to register (`load_main_blclasses()`) when the time comes. - - ## Stage 3: Trigger "PyDeps Changed" - The addon preferences is responsible for storing (and exposing to the user) the path to the Python dependencies. - - Thus, the addon preferences method `on_addon_pydeps_changed()` has the responsibility for checking when the dependencies are valid, and running the delayed registrations (and any other delayed setup) in response. - In general, `on_addon_pydeps_changed()` runs whenever the PyDeps path is changed, but it can also be run manually. - - As the last part of this process, that's exactly what `register()` does: Runs `on_addon_pydeps_changed()` manually. - Depending on the addon preferences (which persist), one of two things can happen: - - 1. **Deps Satisfied**: The addon will load without issue: The just-declared "delayed registrations" will run immediately, and all is well. - 2. **Deps Not Satisfied**: The user must take action to fix the conflicts due to Python dependencies, before the addon can load. **A popup will show to help the user do so. - + """Implements addon registration in a way that respects the availability of addon preferences and loggers. Notes: Called by Blender when enabling the addon. + + Raises: + RuntimeError: If addon preferences fail to register. """ - log.info('Commencing Registration of Addon: %s', ct.addon.NAME) - bpy.app.handlers.load_post.append(manage_pydeps) + log.info('Registering Addon Preferences: %s', ct.addon.NAME) + registration.register_classes(preferences.BL_REGISTER) - # Register Barebones Addon - ## Contains all no-dependency BLClasses: - ## - Contains AddonPreferences. - ## Contains all BLClasses from 'nodeps'. - registration.register_classes(BL_REGISTER_BEFORE_DEPS) - registration.register_hotkeys(BL_HOTKEYS_BEFORE_DEPS) + addon_prefs = ct.addon.prefs() + if addon_prefs is not None: + # Update Loggers + # - This updates existing loggers to use settings defined by preferences. + addon_prefs.on_addon_logging_changed() - # Delay Complete Registration until DEPS_SATISFIED - registration.delay_registration_until( - registration.BLRegisterEvent.DepsSatisfied, - then_register_classes=load_main_blclasses, - then_register_hotkeys=load_main_blhotkeys, - ) + log.info('Registering Addon: %s', ct.addon.NAME) - # Trigger PyDeps Check - ## Deps ARE OK: Delayed registration will trigger. - ## Deps NOT OK: User must fix the pydeps, then trigger this method. - ct.addon.prefs().on_addon_pydeps_changed() + registration.register_classes(BL_REGISTER) + registration.register_hotkeys(BL_HOTKEYS) + + log.info('Finished Registration of Addon: %s', ct.addon.NAME) + else: + msg = 'Addon preferences did not register for addon {ct.addon.NAME} - something is very wrong!' + raise RuntimeError(msg) def unregister() -> None: """Unregisters anything that was registered by the addon. Notes: - Run by Blender when disabling the addon. + Run by Blender when disabling and/or uninstalling the addon. This doesn't clean `sys.modules`. - To fully revert to Blender's state before the addon was in use (especially various import-related caches in the Python process), Blender must be restarted. + We rely on the hope that Blender has extension-extension module isolation. """ log.info('Starting %s Unregister', ct.addon.NAME) + registration.unregister_classes() registration.unregister_hotkeys() - registration.clear_delayed_registrations() + log.info('Finished %s Unregister', ct.addon.NAME) diff --git a/oscillode/contracts/addon.py b/oscillode/contracts/addon.py index 0fd4254..74cee30 100644 --- a/oscillode/contracts/addon.py +++ b/oscillode/contracts/addon.py @@ -14,105 +14,74 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . +"""Basic 'single-source-of-truth' static and dynamic information about this addon. + +Information is mined both from `bpy`, and from the addon's bundled manifest file. +""" -import sys import tomllib +import typing as typ from pathlib import Path import bpy -import bpy_restrict_state PATH_ADDON_ROOT = Path(__file__).resolve().parent.parent -with (PATH_ADDON_ROOT / 'pyproject.toml').open('rb') as f: - PROJ_SPEC = tomllib.load(f) - ## bl_info is filled with PROJ_SPEC when packing the .zip. -NAME = PROJ_SPEC['project']['name'] -VERSION = PROJ_SPEC['project']['version'] +with (PATH_ADDON_ROOT / 'blender_manifest.toml').open('rb') as f: + MANIFEST = tomllib.load(f) + +NAME: str = MANIFEST['id'] +VERSION: str = MANIFEST['version'] #################### # - Assets #################### -PATH_ASSETS = PATH_ADDON_ROOT / 'assets' - -#################### -# - PyDeps Info -#################### -PATH_REQS = PATH_ADDON_ROOT / 'requirements.lock' -DEFAULT_PATH_DEPS = PATH_ADDON_ROOT / '.addon_dependencies' -DEFAULT_PATH_DEPS.mkdir(exist_ok=True) -## requirements.lock is written when packing the .zip. -## By default, the addon pydeps are kept in the addon dir. - -ORIGINAL_SYS_PATH = sys.path.copy() - -#################### -# - Local Addon Cache -#################### -DEFAULT_ADDON_CACHE = PATH_ADDON_ROOT / '.addon_cache' -DEFAULT_ADDON_CACHE.mkdir(exist_ok=True) - -PIP_INSTALL_LOG = DEFAULT_ADDON_CACHE / 'pip_install.log' +PATH_ASSETS: Path = PATH_ADDON_ROOT / 'assets' +PATH_CACHE: Path = Path(bpy.utils.extension_path_user(__package__, create=True)) #################### # - Dynamic Addon Information #################### -def is_loading() -> bool: - """Checks whether the addon is currently loading. +def operator( + name: str, *operator_args: list[typ.Any], **operator_kwargs: dict[str, typ.Any] +) -> None: + """Convenienve method to call an operator from this addon. - While an addon is loading, `bpy.context` is temporarily very limited. - For example, operators can't run while the addon is loading. + This avoids having to use `bpy.ops..operator_name`, which isn't generic. - By checking whether `bpy.context` is limited like this, we can determine whether the addon is currently loading. + Only operators from this addon may be called using this method. - Notes: - Since `bpy_restrict_state._RestrictContext` is a very internal thing, this function may be prone to breakage on Blender updates. - - **Keep an eye out**! - - Returns: - Whether the addon has been fully loaded, such that `bpy.context` is fully accessible. + Raises: + ValueError: If an addon from outside this operator is attempted to be used. """ - return isinstance(bpy.context, bpy_restrict_state._RestrictContext) - - -def operator(name: str, *operator_args, **operator_kwargs) -> None: # Parse Operator Name operator_namespace, operator_name = name.split('.') if operator_namespace != NAME: msg = f'Tried to call operator {operator_name}, but addon operators may only use the addon operator namespace "{operator_namespace}."' - raise RuntimeError(msg) + raise ValueError(msg) - # Addon Not Loading: Run Operator - if not is_loading(): - operator = getattr(getattr(bpy.ops, NAME), operator_name) - operator(*operator_args, **operator_kwargs) - else: - msg = f'Tried to call operator "{operator_name}" while addon is loading' - raise RuntimeError(msg) + # Run Operator + operator = getattr(getattr(bpy.ops, NAME), operator_name) + operator(*operator_args, **operator_kwargs) + ## TODO: Can't we constrain 'name' to be an OperatorType somehow? def prefs() -> bpy.types.AddonPreferences | None: - if (addon := bpy.context.preferences.addons.get(NAME)) is None: - msg = 'Addon is not installed' - raise RuntimeError(msg) + """Retrieve the preferences of this addon, if they are available yet. + Notes: + While registering the addon, one may wish to use the addon preferences. + This isn't possible - not even for default values. + + Either a bad idea is at work, or `oscillode.utils.init_settings` should be consulted until the preferences are available. + + Returns: + The addon preferences, if the addon is registered and loaded - otherwise None. + """ + addon = bpy.context.preferences.addons.get(NAME) + if addon is None: + return None return addon.preferences diff --git a/oscillode/contracts/operator_types.py b/oscillode/contracts/operator_types.py index 546153d..ef89753 100644 --- a/oscillode/contracts/operator_types.py +++ b/oscillode/contracts/operator_types.py @@ -34,7 +34,7 @@ import enum -from ..nodeps.utils import blender_type_enum +from ..utils import blender_type_enum from .addon import NAME as ADDON_NAME diff --git a/oscillode/node_trees/maxwell_sim_nodes/contracts/bl_socket_types.py b/oscillode/node_trees/maxwell_sim_nodes/contracts/bl_socket_types.py index d8b0784..6671dcc 100644 --- a/oscillode/node_trees/maxwell_sim_nodes/contracts/bl_socket_types.py +++ b/oscillode/node_trees/maxwell_sim_nodes/contracts/bl_socket_types.py @@ -14,21 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . import enum import functools diff --git a/oscillode/nodeps/__init__.py b/oscillode/nodeps/__init__.py deleted file mode 100644 index 557f9c3..0000000 --- a/oscillode/nodeps/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -# oscillode -# Copyright (C) 2024 oscillode Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -__all__ = ['operators', 'utils'] diff --git a/oscillode/nodeps/operators/__init__.py b/oscillode/nodeps/operators/__init__.py deleted file mode 100644 index b4349af..0000000 --- a/oscillode/nodeps/operators/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -# oscillode -# Copyright (C) 2024 oscillode Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from . import install_deps, manage_pydeps, uninstall_deps - -BL_REGISTER = [ - *install_deps.BL_REGISTER, - *uninstall_deps.BL_REGISTER, - *manage_pydeps.BL_REGISTER, -] - -BL_HOTKEYS = [ - *install_deps.BL_HOTKEYS, - *uninstall_deps.BL_HOTKEYS, - *manage_pydeps.BL_HOTKEYS, -] diff --git a/oscillode/nodeps/operators/install_deps.py b/oscillode/nodeps/operators/install_deps.py deleted file mode 100644 index 64450c7..0000000 --- a/oscillode/nodeps/operators/install_deps.py +++ /dev/null @@ -1,166 +0,0 @@ -# oscillode -# Copyright (C) 2024 oscillode Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from pathlib import Path - -import bpy - -from ... import contracts as ct -from ..utils import pip_process, pydeps, simple_logger - -log = simple_logger.get(__name__) - - -class InstallPyDeps(bpy.types.Operator): - bl_idname = ct.OperatorType.InstallPyDeps - bl_label = 'Install BLMaxwell Python Deps' - - @classmethod - def poll(cls, _: bpy.types.Context): - return not pip_process.is_loaded() and not pydeps.DEPS_OK - - #################### - # - Property: PyDeps Path - #################### - _timer = None - - bl__pydeps_path: bpy.props.StringProperty( - default='', - ) - - @property - def pydeps_path(self): - return Path(bpy.path.abspath(self.bl__pydeps_path)) - - @pydeps_path.setter - def pydeps_path(self, path: Path) -> None: - self.bl__pydeps_path = str(path.resolve()) - - #################### - # - Property: requirements.lock - #################### - bl__pydeps_reqlock_path: bpy.props.StringProperty( - default='', - ) - - @property - def pydeps_reqlock_path(self): - return Path(bpy.path.abspath(self.bl__pydeps_reqlock_path)) - - @pydeps_reqlock_path.setter - def pydeps_reqlock_path(self, path: Path) -> None: - self.bl__pydeps_reqlock_path = str(path.resolve()) - - #################### - # - Execution - #################### - def execute(self, context: bpy.types.Context): - if pip_process.is_loaded(): - self.report( - {'ERROR'}, - 'A PyDeps installation is already running. Please wait for it to complete.', - ) - return {'FINISHED'} - - log.info( - 'Installing PyDeps to path: %s', - str(self.pydeps_path), - ) - - # Create the Addon-Specific Folder (if Needed) - ## It MUST, however, have a parent already - self.pydeps_path.mkdir(parents=False, exist_ok=True) - - # Run Pip Install - pip_process.run(ct.addon.PATH_REQS, self.pydeps_path, ct.addon.PIP_INSTALL_LOG) - - # Set Timer - self._timer = context.window_manager.event_timer_add( - 0.25, window=context.window - ) - context.window_manager.modal_handler_add(self) - - return {'RUNNING_MODAL'} - - def modal( - self, context: bpy.types.Context, event: bpy.types.Event - ) -> ct.BLOperatorStatus: - # Non-Timer Event: Do Nothing - if event.type != 'TIMER': - return {'PASS_THROUGH'} - - # No Process: Very Bad! - if not pip_process.is_loaded(): - msg = 'Pip process was removed elsewhere than "install_deps" modal operator' - raise RuntimeError(msg) - - # Not Running: Done! - if not pip_process.is_running(): - # Report Result - if pip_process.returncode() == 0: - self.report({'INFO'}, 'PyDeps installation succeeded.') - else: - self.report( - {'ERROR'}, - f'PyDeps installation returned status code: {pip_process.returncode()}. Please see the addon preferences, or the pip installation logs at: {ct.addon.PIP_INSTALL_LOG}', - ) - - # Reset Process and Timer - pip_process.reset() - context.window_manager.event_timer_remove(self._timer) - - # Mark PyDeps Changed - ct.addon.prefs().on_addon_pydeps_changed() - - return {'FINISHED'} - - if ct.addon.PIP_INSTALL_LOG.is_file(): - pip_process.update_progress(ct.addon.PIP_INSTALL_LOG) - context.area.tag_redraw() - return {'PASS_THROUGH'} - - def cancel(self, context: bpy.types.Context): - # Kill / Reset Process and Delete Event Timer - pip_process.kill() - pip_process.reset() - context.window_manager.event_timer_remove(self._timer) - - return {'CANCELLED'} - - -#################### -# - Blender Registration -#################### -BL_REGISTER = [ - InstallPyDeps, -] -BL_HOTKEYS = [] diff --git a/oscillode/nodeps/operators/manage_pydeps.py b/oscillode/nodeps/operators/manage_pydeps.py deleted file mode 100644 index 2a21f2a..0000000 --- a/oscillode/nodeps/operators/manage_pydeps.py +++ /dev/null @@ -1,172 +0,0 @@ -# oscillode -# Copyright (C) 2024 oscillode Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from pathlib import Path - -import bpy - -from blender_maxwell import contracts as ct - -from ..utils import pip_process, pydeps, simple_logger - -log = simple_logger.get(__name__) - - -class ManagePyDeps(bpy.types.Operator): - bl_idname = ct.OperatorType.ManagePyDeps - bl_label = 'Blender Maxwell Python Dependency Manager' - bl_options = {'REGISTER'} - - show_pydeps_conflicts: bpy.props.BoolProperty( - name='Show Conflicts', - description='Show the conflicts between installed and required packages.', - default=False, - ) - #################### - # - Property: PyDeps Path - #################### - bl__pydeps_path: bpy.props.StringProperty( - default='', - ) - - @property - def pydeps_path(self): - return Path(bpy.path.abspath(self.bl__pydeps_path)) - - @pydeps_path.setter - def pydeps_path(self, path: Path) -> None: - self.bl__pydeps_path = str(path.resolve()) - - #################### - # - UI - #################### - def draw(self, _: bpy.types.Context) -> None: - layout = self.layout - - ## Row: Toggle Default PyDeps Path - row = layout.row() - row.alignment = 'CENTER' - row.label( - text="Blender Maxwell relies on Python dependencies that aren't currently satisfied." - ) - row.prop( - self, - 'show_pydeps_conflicts', - text=f'Show Conflicts ({len(pydeps.DEPS_ISSUES)})', - toggle=True, - ) - - ## Grid: Issues Panel - if self.show_pydeps_conflicts: - grid = layout.grid_flow() - grid.alignment = 'CENTER' - for issue in pydeps.DEPS_ISSUES: - grid.label(text=issue) - - # Row: Install Deps - row = layout.row(align=True) - op = row.operator( - ct.OperatorType.InstallPyDeps, - text='Install Python Dependencies (requires internet)', - ) - op.bl__pydeps_path = str(self.pydeps_path) - - ## Row: Uninstall Deps - row = layout.row(align=True) - op = row.operator( - ct.OperatorType.UninstallPyDeps, - text='Uninstall Python Dependencies', - ) - op.bl__pydeps_path = str(self.pydeps_path) - - ## Row: Deps Install Progress - row = layout.row() - num_req_deplocks = len(pydeps.DEPS_REQ_DEPLOCKS) - if pydeps.DEPS_OK: - row.progress( - text=f'{num_req_deplocks}/{num_req_deplocks} Installed', - factor=1.0, - ) - elif pip_process.PROGRESS is not None: - row.progress( - text='/'.join(pip_process.PROGRESS_FRAC) + ' Installed', - factor=float(pip_process.PROGRESS), - ) - else: - row.progress( - text=f'0/{num_req_deplocks} Installed', - factor=0.0, - ) - - ## Row: Toggle Default PyDeps Path - row = layout.row() - row.alignment = 'CENTER' - row.label( - text='After installation, the addon is ready to use. For more details, please refer to the addon preferences.' - ) - - #################### - # - Execute - #################### - def invoke(self, context: bpy.types.Context, event: bpy.types.Event): - if not bpy.app.background: - # Force-Move Mouse Cursor to Window Center - ## This forces the popup dialog to spawn in the center of the screen. - context.window.cursor_warp( - context.window.width // 2, - context.window.height // 2 + 2 * bpy.context.preferences.system.dpi, - ) - - # Spawn Popup Dialogue - return context.window_manager.invoke_props_dialog( - self, width=8 * bpy.context.preferences.system.dpi - ) - - log.info('Skipping ManagePyDeps popup, since Blender is running without a GUI') - return {'INTERFACE'} - - def execute(self, _: bpy.types.Context): - if not pydeps.DEPS_OK: - self.report( - {'ERROR'}, - f'Python Dependencies for "{ct.addon.NAME}" were not installed. Please refer to the addon preferences.', - ) - return {'FINISHED'} - - -#################### -# - Blender Registration -#################### -BL_REGISTER = [ - ManagePyDeps, -] -BL_HOTKEYS = [] diff --git a/oscillode/nodeps/operators/uninstall_deps.py b/oscillode/nodeps/operators/uninstall_deps.py deleted file mode 100644 index 25cfbcf..0000000 --- a/oscillode/nodeps/operators/uninstall_deps.py +++ /dev/null @@ -1,146 +0,0 @@ -# oscillode -# Copyright (C) 2024 oscillode Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import shutil -import site -from pathlib import Path - -import bpy - -from blender_maxwell import contracts as ct - -from ..utils import pip_process, pydeps, simple_logger - -log = simple_logger.get(__name__) - - -class UninstallPyDeps(bpy.types.Operator): - bl_idname = ct.OperatorType.UninstallPyDeps - bl_label = 'Uninstall BLMaxwell Python Deps' - - @classmethod - def poll(cls, _: bpy.types.Context): - return not pip_process.is_loaded() and ( - pydeps.DEPS_OK or (pydeps.DEPS_ISSUES and pydeps.DEPS_INST_DEPLOCKS) - ) - - #################### - # - Property: PyDeps Path - #################### - bl__pydeps_path: bpy.props.StringProperty( - default='', - ) - - @property - def pydeps_path(self): - return Path(bpy.path.abspath(self.bl__pydeps_path)) - - @pydeps_path.setter - def pydeps_path(self, path: Path) -> None: - self.bl__pydeps_path = str(path.resolve()) - - #################### - # - Execution - #################### - def execute(self, _: bpy.types.Context): - # Reject Bad PyDeps Paths (to prevent unfortunate deletions) - ## Reject user site-packages - if self.pydeps_path == Path(site.getusersitepackages()): - msg = f"PyDeps path ({self.pydeps_path}) can't be the user site-packages" - raise ValueError(msg) - - ## Reject any global site-packages - if self.pydeps_path == Path(site.getusersitepackages()): - msg = f"PyDeps path ({self.pydeps_path}) can't be a global site-packages" - raise ValueError(msg) - - ## Reject any Reserved sys.path Entry (as of addon initialization) - ## -> At addon init, ORIGINAL_SYS_PATH is created as a sys.path copy. - ## -> Thus, ORIGINAL_SYS_PATH only includes Blender-set paths. - ## -> (possibly also other addon's manipulations, but that's good!) - if self.pydeps_path in [ - Path(sys_path) for sys_path in ct.addon.ORIGINAL_SYS_PATH - ]: - msg = f'PyDeps path ({self.pydeps_path}) can\'t be any package defined in "sys.path"' - raise ValueError(msg) - - ## Reject non-existant PyDeps Path - if not self.pydeps_path.exists(): - msg = f"PyDeps path ({self.pydeps_path}) doesn't exist" - raise ValueError(msg) - - ## Reject non-directory PyDeps Path - if not self.pydeps_path.is_dir(): - msg = f"PyDeps path ({self.pydeps_path}) isn't a directory" - raise ValueError(msg) - - ## Reject PyDeps Path that is Home Dir (I hope nobody needs this) - if self.pydeps_path == Path.home().resolve(): - msg = f"PyDeps path ({self.pydeps_path}) can't be the user home directory" - raise ValueError(msg) - - # Check for Empty Directory - if len(pydeps.compute_installed_deplocks(self.pydeps_path)) == 0: - ## Reject Non-Empty Directories w/o Python Dependencies - if any(Path(self.pydeps_path).iterdir()): - msg = "PyDeps Path has no installed Python modules, but isn't empty: {self.pydeps_path)" - raise ValueError(msg) - - self.report( - {'ERROR'}, - f"PyDeps Path is empty; uninstall can't run: {self.pydeps_path}", - ) - return {'FINISHED'} - - # Brutally Delete / Remake PyDeps Folder - ## The point isn't to protect against dedicated stupididy. - ## Just to nudge away a few of the obvious "bad ideas" users might have. - log.warning( - 'Deleting and Creating Folder at "%s": %s', - 'pydeps_path', - str(self.pydeps_path), - ) - shutil.rmtree(self.pydeps_path) - self.pydeps_path.mkdir() - - # Update Changed PyDeps - ct.addon.prefs().on_addon_pydeps_changed() - return {'FINISHED'} - - -#################### -# - Blender Registration -#################### -BL_REGISTER = [ - UninstallPyDeps, -] -BL_HOTKEYS = [] diff --git a/oscillode/nodeps/utils/__init__.py b/oscillode/nodeps/utils/__init__.py deleted file mode 100644 index 2ddbd19..0000000 --- a/oscillode/nodeps/utils/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -# oscillode -# Copyright (C) 2024 oscillode Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from . import pydeps - -__all__ = ['pydeps'] diff --git a/oscillode/nodeps/utils/pip_process.py b/oscillode/nodeps/utils/pip_process.py deleted file mode 100644 index 6d71360..0000000 --- a/oscillode/nodeps/utils/pip_process.py +++ /dev/null @@ -1,146 +0,0 @@ -# oscillode -# Copyright (C) 2024 oscillode Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import os -import re -import subprocess -import sys -from pathlib import Path - -from . import pydeps, simple_logger - -log = simple_logger.get(__name__) - -PROCESS: subprocess.Popen | None = None -PROGRESS: float | None = None -PROGRESS_FRAC: tuple[str, str] | None = None - - -def run(reqs_path: Path, pydeps_path: Path, install_log: Path) -> None: - global PROCESS # noqa: PLW0603 - - if PROCESS is not None: - msg = 'A pip process is already loaded' - raise ValueError(msg) - - # Path to Blender's Bundled Python - ## bpy.app.binary_path_python was deprecated in 2.91. - ## sys.executable points to the correct bundled Python. - ## See - cmdline = [ - sys.executable, - '-m', - 'pip', - 'install', - '-r', - str(reqs_path), - '--target', - str(pydeps_path), - '--log', - str(install_log), - '--disable-pip-version-check', - ] - - log.debug( - 'pip cmdline: %s', - ' '.join(cmdline), - ) - - PROCESS = subprocess.Popen( - cmdline, - env=os.environ.copy() | {'PYTHONUNBUFFERED': '1'}, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - -def is_loaded() -> bool: - return PROCESS is not None - - -def is_running() -> bool: - if PROCESS is None: - msg = "Tried to check whether a process that doesn't exist is running" - raise ValueError(msg) - - return PROCESS.poll() is None - - -def returncode() -> bool: - if not is_running() and PROCESS is not None: - return PROCESS.returncode - - msg = "Can't get process return code of running/nonexistant process" - raise ValueError(msg) - - -def kill() -> None: - global PROCESS - - if not is_running(): - msg = "Can't kill process that isn't running" - raise ValueError(msg) - - PROCESS.kill() - - -def reset() -> None: - global PROCESS # noqa: PLW0603 - global PROGRESS # noqa: PLW0603 - global PROGRESS_FRAC # noqa: PLW0603 - - PROCESS = None - PROGRESS = None - PROGRESS_FRAC = None - - -RE_COLLECTED_DEPLOCK = re.compile(r'Collecting (\w+==[\w\.]+)') - - -def update_progress(pip_install_log_path: Path): - global PROGRESS # noqa: PLW0603 - global PROGRESS_FRAC # noqa: PLW0603 - - if not pip_install_log_path.is_file(): - msg = "Can't parse progress from non-existant pip-install log" - raise ValueError(msg) - - # start_time = time.perf_counter() - with pip_install_log_path.open('r') as f: - pip_install_log = f.read() - # print('READ', time.perf_counter() - start_time) - - found_deplocks = set(RE_COLLECTED_DEPLOCK.findall(pip_install_log)) - # print('SETUP', time.perf_counter() - start_time) - PROGRESS = len(found_deplocks) / len(pydeps.DEPS_REQ_DEPLOCKS) - PROGRESS_FRAC = (str(len(found_deplocks)), str(len(pydeps.DEPS_REQ_DEPLOCKS))) - # print('COMPUTED', time.perf_counter() - start_time) diff --git a/oscillode/nodeps/utils/pydeps.py b/oscillode/nodeps/utils/pydeps.py deleted file mode 100644 index 11330e4..0000000 --- a/oscillode/nodeps/utils/pydeps.py +++ /dev/null @@ -1,250 +0,0 @@ -# oscillode -# Copyright (C) 2024 oscillode Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""Tools for fearless managemenet of addon-specific Python dependencies.""" - -import contextlib -import importlib.metadata -import os -import sys -from pathlib import Path - -import blender_maxwell.contracts as ct - -from . import simple_logger - -log = simple_logger.get(__name__) - -#################### -# - Globals -#################### -DEPS_OK: bool = False ## Presume no (but we don't know yet) -DEPS_ISSUES: list[str] = [] ## No known issues (yet) -DEPS_REQ_DEPLOCKS: set[str] = set() -DEPS_INST_DEPLOCKS: set[str] = set() - - -#################### -# - sys.path Context Manager -#################### -@contextlib.contextmanager -def importable_addon_deps(path_deps: Path): - """Temporarily modifies `sys.path` with a light touch and minimum of side-effects. - - Warnings: - There are a lot of gotchas with the import system, and this is an enormously imperfect "solution". - - Parameters: - path_deps: - Corresponds to the directory into which `pip install --target` was used to install packages. - """ - os_path = os.fspath(path_deps) - - if os_path not in sys.path: - log.info('Adding Path to sys.path: %s', str(os_path)) - sys.path.insert(0, os_path) - try: - yield - finally: - # TODO: Re-add - # log.info('Removing Path from sys.path: %s', str(os_path)) - # sys.path.remove(os_path) - pass - else: - try: - yield - finally: - pass - - -@contextlib.contextmanager -def syspath_from_bpy_prefs() -> bool: - """Temporarily modifies `sys.path` using the dependencies found in addon preferences. - - Warnings: - There are a lot of gotchas with the import system, and this is an enormously imperfect "solution". - - Parameters: - path_deps: Path to the directory where Python modules can be found. - Corresponds to the directory into which `pip install --target` was used to install packages. - """ - with importable_addon_deps(ct.addon.prefs().pydeps_path): - log.info('Retrieved PyDeps Path from Addon Prefs') - yield True - - -#################### -# - Passive PyDeps Checkers -#################### -def conform_pypi_package_deplock(deplock: str) -> str: - """Conforms a "deplock" string (`==`) so that comparing it with other "deplock" strings will conform to PyPi's matching rules. - - - **Case Sensitivity**: PyPi considers packages with non-matching cases to be the same. _Therefore, we cast all deplocks to lowercase._ - - **Special Characters**: PyPi considers `-` and `_` to be the same character. _Therefore, we replace `_` with `-`_. - - See for the specification. - - Parameters: - deplock: The string formatted like `==`. - - Returns: - The conformed deplock string. - """ - return deplock.lower().replace('_', '-') - - -def compute_required_deplocks( - path_requirementslock: Path, -) -> set[str]: - with path_requirementslock.open('r') as file: - return { - conform_pypi_package_deplock(line) - for raw_line in file.readlines() - if (line := raw_line.strip()) and not line.startswith('#') - } - - -def compute_installed_deplocks( - path_deps: Path, -) -> set[str]: - return { - conform_pypi_package_deplock( - f'{dep.metadata["Name"]}=={dep.metadata["Version"]}' - ) - for dep in importlib.metadata.distributions(path=[str(path_deps.resolve())]) - } - - -def deplock_conflicts( - path_requirementslock: Path, - path_deps: Path, -) -> list[str]: - """Check if packages defined in a 'requirements.lock' file are **strictly** realized by a particular dependency path. - - **Strict** means not only that everything is satisfied, but that _the exact versions_ are satisfied, and that _no extra packages_ are installed either. - - Parameters: - path_requirementslock: Path to the `requirements.lock` file. - Generally, one would use `ct.addon.PATH_REQS` to use the `requirements.lock` file shipped with the addon. - path_deps: Path to the directory where Python modules can be found. - Corresponds to the directory into which `pip install --target` was used to install packages. - - Returns: - A list of messages explaining mismatches between the currently installed dependencies, and the given `requirements.lock` file. - There are three kinds of conflicts: - - - **Version**: The wrong version of something is installed. - - **Missing**: Something should be installed that isn't. - - **Superfluous**: Something is installed that shouldn't be. - """ - required_deplocks = compute_required_deplocks(path_requirementslock) - installed_deplocks = compute_installed_deplocks(path_deps) - - # Determine Diff of Required vs. Installed - req_not_inst = required_deplocks - installed_deplocks - inst_not_req = installed_deplocks - required_deplocks - conflicts = { - req.split('==')[0]: (req.split('==')[1], inst.split('==')[1]) - for req in req_not_inst - for inst in inst_not_req - if req.split('==')[0] == inst.split('==')[0] - } - - return ( - [ - f'{name}: Have {inst_ver}, Need {req_ver}' - for name, (req_ver, inst_ver) in conflicts.items() - ] - + [ - f'Missing {deplock}' - for deplock in req_not_inst - if deplock.split('==')[0] not in conflicts - ] - + [ - f'Superfluous {deplock}' - for deplock in inst_not_req - if deplock.split('==')[0] not in conflicts - ] - ) - - -#################### -# - Passive PyDeps Checker -#################### -def check_pydeps(path_requirementslock: Path, path_deps: Path): - """Check if all dependencies are satisfied without `deplock_conflicts()` conflicts, and update globals in response. - - Notes: - Use of the globals `DEPS_OK` and `DEPS_ISSUES` should be preferred in general, since they are very fast to access. - - **Only**, use `check_pydeps()` after any operation something that might have changed the dependency status; both to check the result, but also to update the globals. - - Parameters: - path_requirementslock: Path to the `requirements.lock` file. - Generally, one would use `ct.addon.PATH_REQS` to use the `requirements.lock` file shipped with the addon. - path_deps: Path to the directory where Python modules can be found. - Corresponds to the directory into which `pip install --target` was used to install packages. - - Returns: - A list of messages explaining mismatches between the currently installed dependencies, and the given `requirements.lock` file. - There are three kinds of conflicts: - - - **Version**: The wrong version of something is installed. - - **Missing**: Something should be installed that isn't. - - **Superfluous**: Something is installed that shouldn't be. - """ - global DEPS_OK # noqa: PLW0603 - global DEPS_ISSUES # noqa: PLW0603 - global DEPS_REQ_DEPLOCKS # noqa: PLW0603 - global DEPS_INST_DEPLOCKS # noqa: PLW0603 - - log.info( - 'Analyzing PyDeps at: %s', - str(path_deps), - ) - if len(issues := deplock_conflicts(path_requirementslock, path_deps)) > 0: - log.info( - 'PyDeps Check Failed - adjust Addon Preferences for: %s', ct.addon.NAME - ) - log.debug('%s', ', '.join(issues)) - log.debug('PyDeps Conflicts: %s', ', '.join(issues)) - - DEPS_OK = False - DEPS_ISSUES = issues - else: - log.info('PyDeps Check Succeeded - DEPS_OK and DEPS_ISSUES have been updated') - DEPS_OK = True - DEPS_ISSUES = [] - - DEPS_REQ_DEPLOCKS = compute_required_deplocks(path_requirementslock) - DEPS_INST_DEPLOCKS = compute_installed_deplocks(path_deps) - return DEPS_OK diff --git a/oscillode/nodeps/utils/simple_logger.py b/oscillode/nodeps/utils/simple_logger.py deleted file mode 100644 index fdecd5f..0000000 --- a/oscillode/nodeps/utils/simple_logger.py +++ /dev/null @@ -1,267 +0,0 @@ -# oscillode -# Copyright (C) 2024 oscillode Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import logging -import typing as typ -from pathlib import Path - -log = logging.getLogger(__name__) -## TODO: Hygiene; don't try to own all root loggers. - -LogLevel: typ.TypeAlias = int -LogHandler: typ.TypeAlias = typ.Any ## TODO: Can we do better? - -#################### -# - Constants -#################### -LOG_LEVEL_MAP: dict[str, LogLevel] = { - 'DEBUG': logging.DEBUG, - 'INFO': logging.INFO, - 'WARNING': logging.WARNING, - 'ERROR': logging.ERROR, - 'CRITICAL': logging.CRITICAL, -} - -STREAM_LOG_FORMAT = 11 * ' ' + '%(levelname)-8s %(message)s (%(name)s)' -FILE_LOG_FORMAT = STREAM_LOG_FORMAT - -#################### -# - Globals -#################### -CACHE = { - 'simple_loggers': set(), - 'console_level': None, - 'file_path': None, - 'file_level': logging.NOTSET, -} - - -#################### -# - Logging Handlers -#################### -def console_handler(level: LogLevel) -> logging.StreamHandler: - """A logging handler that prints messages to the console. - - Parameters: - level: The log levels (debug, info, etc.) to print. - - Returns: - The logging handler, which can be added to a logger. - """ - stream_formatter = logging.Formatter(STREAM_LOG_FORMAT) - stream_handler = logging.StreamHandler() - stream_handler.setFormatter(stream_formatter) - stream_handler.setLevel(level) - return stream_handler - - -def file_handler(path_log_file: Path, level: LogLevel) -> logging.FileHandler: - """A logging handler that prints messages to a file. - - Parameters: - path_log_file: The path to the log file. - level: The log levels (debug, info, etc.) to append to the file. - - Returns: - The logging handler, which can be added to a logger. - """ - file_formatter = logging.Formatter(FILE_LOG_FORMAT) - file_handler = logging.FileHandler(path_log_file) - file_handler.setFormatter(file_formatter) - file_handler.setLevel(level) - return file_handler - - -#################### -# - Logger Setup -#################### -def update_logger( - cb_console_handler: typ.Callable[[LogLevel], LogHandler], - cb_file_handler: typ.Callable[[Path, LogLevel], LogHandler], - logger: logging.Logger, - console_level: LogLevel | None, - file_path: Path | None, - file_level: LogLevel, -) -> None: - """Configures a single logger with given console and file handlers, individualizing the log level that triggers each. - - This is a lower-level function - generally, modules that want to use a well-configured logger will use the `get()` function, which retrieves the parameters for this function from the addon preferences. - This function is also used by the higher-level log setup. - - Parameters: - cb_console_handler: A function that takes a log level threshold (inclusive), and returns a logging handler to a console-printer. - cb_file_handler: A function that takes a log level threshold (inclusive), and returns a logging handler to a file-printer. - logger: The logger to configure. - console_level: The log level threshold to print to the console. - None deactivates file logging. - path_log_file: The path to the log file. - None deactivates file logging. - file_level: The log level threshold to print to the log file. - """ - # Delegate Level Semantics to Log Handlers - ## This lets everything through - logger.setLevel(logging.DEBUG) - - # DO NOT Propagate to Root Logger - ## This looks like 'double messages' - logger.propagate = False - ## See SO/6729268/log-messages-appearing-twice-with-python-logging - - # Clear Existing Handlers - if logger.handlers: - logger.handlers.clear() - - # Add Console Logging Handler - if console_level is not None: - logger.addHandler(cb_console_handler(console_level)) - - # Add File Logging Handler - if file_path is not None: - logger.addHandler(cb_file_handler(file_path, file_level)) - - -#################### -# - Logger Initialization -#################### -def init_simple_logger_defaults( - console_level: LogLevel | None = None, - file_path: Path | None = None, - file_level: LogLevel = logging.NOTSET, -) -> None: - """Initialize the simple logger, including the `CACHE`, so that logging will work without dependencies / the addon preferences being started yet. - - Should only be called by the addon's pre-initialization code, before `register()`. - - Parameters: - console_level: The console log level threshold to store in `CACHE`. - `None` deactivates console logging. - file_path: The file path to use for file logging, stored in `CACHE`. - `None` deactivates file logging. - file_level: The file log level threshold to store in `CACHE`. - Only needs to be set if `file_path` is not `None`. - """ - CACHE['simple_loggers'].add(__name__) - CACHE['console_level'] = console_level - CACHE['file_path'] = file_path - CACHE['file_level'] = file_level - - # Setup __name__ Logger - update_logger( - console_handler, - file_handler, - log, - console_level=console_level, - file_path=file_path, - file_level=file_level, - ) - log.info('Initialized Simple Logging w/Settings %s', str(CACHE)) - - -#################### -# - Logger Access -#################### -def get(module_name) -> logging.Logger: - """Get a simple logger from the module name. - - Should be used by calling ex. `LOG = simple_logger.get(__name__)` in the module wherein logging is desired. - Should **only** be used if the dependencies aren't yet available for using `blender_maxwell.utils.logger`. - - Uses the global `CACHE` to store `console_level`, `file_path`, and `file_level`, since addon preferences aren't yet available. - - Parameters: - module_name: The name of the module to create a logger for. - Should be set to `__name__`. - """ - logger = logging.getLogger(module_name) - CACHE['simple_loggers'].add(module_name) - - # Reuse Cached Arguments from Last sync_* - update_logger( - console_handler, - file_handler, - logger, - console_level=CACHE['console_level'], - file_path=CACHE['file_path'], - file_level=CACHE['file_level'], - ) - - return logger - - -#################### -# - Logger Sync -#################### -def update_all_loggers( - cb_console_handler: typ.Callable[[LogLevel], LogHandler], - cb_file_handler: typ.Callable[[Path, LogLevel], LogHandler], - console_level: LogLevel | None, - file_path: Path | None, - file_level: LogLevel, -): - """Update all loggers to conform to the given per-handler on/off state and log level. - - This runs the corresponding `update_logger()` for all active loggers. - Thus, all parameters are identical to `update_logger()`. - """ - CACHE['console_level'] = console_level - CACHE['file_path'] = file_path - CACHE['file_level'] = file_level - - for name in logging.root.manager.loggerDict: - logger = logging.getLogger(name) - update_logger( - cb_console_handler, - cb_file_handler, - logger, - console_level=console_level, - file_path=file_path, - file_level=file_level, - ) - - -#################### -# - Logger Iteration -#################### -def loggers(): - return [logging.getLogger(name) for name in logging.root.manager.loggerDict] - - -def simple_loggers(): - return [ - logging.getLogger(name) - for name in logging.root.manager.loggerDict - if name in CACHE['simple_loggers'] - ] - - -def clear_simple_loggers(): - CACHE['simple_loggers'].clear() diff --git a/oscillode/operators/bl_append.py b/oscillode/operators/bl_append.py deleted file mode 100644 index a220f4a..0000000 --- a/oscillode/operators/bl_append.py +++ /dev/null @@ -1,32 +0,0 @@ -# oscillode -# Copyright (C) 2024 oscillode Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - diff --git a/oscillode/preferences.py b/oscillode/preferences.py index 758d4f9..f909bd1 100644 --- a/oscillode/preferences.py +++ b/oscillode/preferences.py @@ -14,21 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . +"""Addon preferences, encapsulating the various global modifications that the user may make to how this addon functions.""" import logging from pathlib import Path @@ -36,11 +22,21 @@ from pathlib import Path import bpy from . import contracts as ct -from . import registration -from .nodeps.operators import install_deps, uninstall_deps -from .nodeps.utils import pip_process, pydeps, simple_logger +from .utils import logger -log = simple_logger.get(__name__) +log = logger.get(__name__) + + +#################### +# - Constants +#################### +LOG_LEVEL_MAP: dict[str, logger.LogLevel] = { + 'DEBUG': logging.DEBUG, + 'INFO': logging.INFO, + 'WARNING': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL, +} #################### @@ -62,90 +58,15 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences): #################### # - Properties #################### - # Addon Cache Path - bl__addon_cache_path: bpy.props.StringProperty( - name='Addon Cache Path', - description='Path to Addon Cache', - subtype='FILE_PATH', - default=str(ct.addon.DEFAULT_ADDON_CACHE), - update=lambda self, _: self.on_addon_pydeps_changed(), - ) - - @property - def addon_cache_path(self) -> Path: - return Path(bpy.path.abspath(self.bl__addon_cache_path)) - - @addon_cache_path.setter - def addon_cache_path(self, path: Path) -> None: - self.bl__addon_cache_path = str(path.resolve()) - - # PyDeps Default Path - use_default_pydeps_path: bpy.props.BoolProperty( - name='Use Default PyDeps Path', - description='Whether to use the default PyDeps path', - default=True, - update=lambda self, context: self.on_addon_pydeps_changed(context), - ) - - # PyDeps Path - bl__pydeps_path: bpy.props.StringProperty( - name='Addon PyDeps Path', - description='Path to Addon Python Dependencies', - subtype='FILE_PATH', - default=str(ct.addon.DEFAULT_PATH_DEPS), - update=lambda self, _: self.on_addon_pydeps_changed(), - ) - - cache__backup_pydeps_path: bpy.props.StringProperty( - default=str(ct.addon.DEFAULT_PATH_DEPS), - ) - - @property - def pydeps_path(self) -> Path: - if self.use_default_pydeps_path: - return ct.addon.DEFAULT_PATH_DEPS - - return Path(bpy.path.abspath(self.bl__pydeps_path)) - - @pydeps_path.setter - def pydeps_path(self, path: Path) -> None: - if not self.use_default_pydeps_path: - self.bl__pydeps_path = str(path.resolve()) - else: - msg = f'Can\'t set "pydeps_path" to {path} while "use_default_pydeps_path" is "True"' - raise ValueError(msg) - # Logging - ## Console Logging - use_log_console: bpy.props.BoolProperty( - name='Log to Console', - description='Whether to use the console for addon logging', - default=True, - update=lambda self, _: self.on_addon_logging_changed(), - ) - log_level_console: bpy.props.EnumProperty( - name='Console Log Level', - description='Level of addon logging to expose in the console', - items=[ - ('DEBUG', 'Debug', 'Debug'), - ('INFO', 'Info', 'Info'), - ('WARNING', 'Warning', 'Warning'), - ('ERROR', 'Error', 'Error'), - ('CRITICAL', 'Critical', 'Critical'), - ], - default='DEBUG', - update=lambda self, _: self.on_addon_logging_changed(), - ) - ## TODO: Derive default from BOOTSTRAP_LOG_LEVEL - ## File Logging - use_log_file: bpy.props.BoolProperty( + use_log_file: bpy.props.BoolProperty( # type: ignore name='Log to File', description='Whether to use a file for addon logging', default=True, update=lambda self, _: self.on_addon_logging_changed(), ) - log_level_file: bpy.props.EnumProperty( + log_level_file: bpy.props.EnumProperty( # type: ignore name='File Log Level', description='Level of addon logging to expose in the file', items=[ @@ -159,7 +80,7 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences): update=lambda self, _: self.on_addon_logging_changed(), ) - bl__log_file_path: bpy.props.StringProperty( + bl__log_file_path: bpy.props.StringProperty( # type: ignore name='Log Path', description='Path to the Addon Log File', subtype='FILE_PATH', @@ -169,105 +90,84 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences): @property def log_file_path(self) -> Path: + """Retrieve the configured file-logging path as a `pathlib.Path`.""" return Path(bpy.path.abspath(self.bl__log_file_path)) @log_file_path.setter def log_file_path(self, path: Path) -> None: + """Set the configured file-logging path as a `pathlib.Path`.""" self.bl__log_file_path = str(path.resolve()) + ## Console Logging + use_log_console: bpy.props.BoolProperty( # type: ignore + name='Log to Console', + description='Whether to use the console for addon logging', + default=True, + update=lambda self, _: self.on_addon_logging_changed(), + ) + log_level_console: bpy.props.EnumProperty( # type: ignore + name='Console Log Level', + description='Level of addon logging to expose in the console', + items=[ + ('DEBUG', 'Debug', 'Debug'), + ('INFO', 'Info', 'Info'), + ('WARNING', 'Warning', 'Warning'), + ('ERROR', 'Error', 'Error'), + ('CRITICAL', 'Critical', 'Critical'), + ], + default='DEBUG', + update=lambda self, _: self.on_addon_logging_changed(), + ) + ## TODO: Derive default from INIT_SETTINGS + #################### # - Events: Properties Changed #################### - def on_addon_logging_changed( - self, single_logger_to_setup: logging.Logger | None = None - ) -> None: - """Configure one, or all, active addon logger(s). + def setup_logger(self, _logger: logging.Logger) -> None: + """Setup a logger using the settings declared in the addon preferences. + + Args: + _logger: The logger to configure using settings in the addon preferences. + """ + logger.update_logger( + logger.console_handler, + logger.file_handler, + _logger, + file_path=self.log_file_path if self.use_log_file else None, + file_level=LOG_LEVEL_MAP[self.log_level_file], + console_level=LOG_LEVEL_MAP[self.log_level_console] + if self.use_console_level + else None, + ) + + def on_addon_logging_changed(self) -> None: + """Called to reconfigure all loggers to match newly-altered addon preferences. + + This causes ex. changes to desired console log level to immediately be applied, but only the this addon's loggers. Parameters: single_logger_to_setup: When set, only this logger will be setup. Otherwise, **all addon loggers will be setup**. """ - if pydeps.DEPS_OK: - with pydeps.importable_addon_deps(self.pydeps_path): - from blender_maxwell.utils import logger - else: - logger = simple_logger + log.info('Reconfiguring Loggers') + for _logger in logger.all_addon_loggers(): + self.setup_logger(_logger) - # Retrieve Configured Log Levels - log_level_console = logger.LOG_LEVEL_MAP[self.log_level_console] - log_level_file = logger.LOG_LEVEL_MAP[self.log_level_file] - - log_setup_kwargs = { - 'console_level': log_level_console if self.use_log_console else None, - 'file_path': self.log_file_path if self.use_log_file else None, - 'file_level': log_level_file, - } - - # Sync Single Logger / All Loggers - if single_logger_to_setup is not None: - logger.update_logger( - logger.console_handler, - logger.file_handler, - single_logger_to_setup, - **log_setup_kwargs, - ) - else: - log.info('Re-Configuring All Loggers') - logger.update_all_loggers( - logger.console_handler, - logger.file_handler, - **log_setup_kwargs, - ) - - def on_addon_pydeps_changed(self, show_popup_if_deps_invalid: bool = False) -> None: - """Checks if the Python dependencies are valid, and runs any delayed setup (inclusing `ct.BLClass` registrations) in response. - - Notes: - **The addon does not load until this method allows it**. - - Parameters: - show_popup_if_deps_invalid: If True, a failed dependency check will `invoke()` the operator `ct.OperatorType.ManagePyDeps`, which is a popup that guides the user through - **NOTE**: Must be called after addon registration. - - Notes: - Run by `__init__.py` after registering a barebones addon (including this class), and after queueing a delayed registration. - """ - if pydeps.check_pydeps(ct.addon.PATH_REQS, self.pydeps_path): - # Re-Sync Loggers - ## We can now upgrade all loggers to the fancier loggers. - for _log in simple_logger.simple_loggers(): - log.debug('Upgrading Logger (%s)', str(_log)) - self.on_addon_logging_changed(single_logger_to_setup=_log) - simple_logger.clear_simple_loggers() - - # Run Registrations Waiting on DEPS_SATISFIED - ## Since the deps are OK, we can now register the whole addon. - if ( - registration.BLRegisterEvent.DepsSatisfied - in registration.DELAYED_REGISTRATIONS - ): - registration.run_delayed_registration( - registration.BLRegisterEvent.DepsSatisfied, - self.pydeps_path, - ) - - elif show_popup_if_deps_invalid: - ct.addon.operator( - ct.OperatorType.ManagePyDeps, - 'INVOKE_DEFAULT', - bl__pydeps_path=str(self.pydeps_path), - ) - ## TODO: else: - ## TODO: Can we 'downgrade' the loggers back to simple loggers? - ## TODO: Can we undo the delayed registration? - ## TODO: Do we need the fancy pants sys.modules handling for all this? + log.info('Loggers Reconfigured') #################### # - UI #################### def draw(self, _: bpy.types.Context) -> None: + """Draw the addon preferences within its panel in Blender's preferences. + + Notes: + Run by Blender when this addon's preferences need to be displayed. + + Parameters: + context: The Blender context object. + """ layout = self.layout - num_pydeps_issues = len(pydeps.DEPS_ISSUES) #################### # - Logging @@ -301,75 +201,6 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences): row.enabled = self.use_log_file row.prop(self, 'log_level_file') - #################### - # - Dependencies - #################### - # Box: Dependency Status - box = layout.box() - row = box.row(align=True) - row.alignment = 'CENTER' - row.label(text='Python Dependencies') - - ## Row: Toggle Default PyDeps Path - row = box.row(align=True) - row.enabled = not pydeps.DEPS_OK - row.prop( - self, - 'use_default_pydeps_path', - text='Use Default PyDeps Install Path', - toggle=True, - ) - - ## Row: Current PyDeps Path - row = box.row(align=True) - row.enabled = not pydeps.DEPS_OK and not self.use_default_pydeps_path - row.prop(self, 'bl__pydeps_path', text='PyDeps Install Path') - - ## Row: More Information Panel - col = box.column(align=True) - header, panel = col.panel('pydeps_issues', default_closed=True) - header.label(text=f'Show Conflicts ({num_pydeps_issues})') - if panel is not None: - grid = panel.grid_flow() - for issue in pydeps.DEPS_ISSUES: - grid.label(text=issue) - - ## Row: Install - row = box.row(align=True) - op = row.operator( - install_deps.InstallPyDeps.bl_idname, - text='Install PyDeps', - ) - op.bl__pydeps_path = str(self.pydeps_path) - op.bl__pydeps_reqlock_path = str(ct.addon.PATH_REQS) - - ## Row: Uninstall - row = box.row(align=True) - op = row.operator( - uninstall_deps.UninstallPyDeps.bl_idname, - text='Uninstall PyDeps', - ) - op.bl__pydeps_path = str(self.pydeps_path) - - ## Row: Deps Install Progress - row = box.row() - num_req_deplocks = len(pydeps.DEPS_REQ_DEPLOCKS) - if pydeps.DEPS_OK: - row.progress( - text=f'{num_req_deplocks}/{num_req_deplocks} Installed', - factor=1.0, - ) - elif pip_process.PROGRESS is not None: - row.progress( - text='/'.join(pip_process.PROGRESS_FRAC) + ' Installed', - factor=float(pip_process.PROGRESS), - ) - else: - row.progress( - text=f'0/{num_req_deplocks} Installed', - factor=0.0, - ) - #################### # - Blender Registration diff --git a/oscillode/registration.py b/oscillode/registration.py index e9b6b68..6fe6fdb 100644 --- a/oscillode/registration.py +++ b/oscillode/registration.py @@ -14,22 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - """Manages the registration of Blender classes, including delayed registrations that require access to Python dependencies. Attributes: @@ -40,16 +24,12 @@ Attributes: _REGISTERED_HOTKEYS: Currently registered Blender keymap items. """ -import enum -import typing as typ -from pathlib import Path - import bpy from . import contracts as ct -from .nodeps.utils import simple_logger +from .utils import logger -log = simple_logger.get(__name__) +log = logger.get(__name__) #################### # - Globals @@ -59,16 +39,6 @@ _ADDON_KEYMAP: bpy.types.KeyMap | None = None _REGISTERED_HOTKEYS: list[ct.BLKeymapItem] = [] -#################### -# - Delayed Registration -#################### -class BLRegisterEvent(enum.StrEnum): - DepsSatisfied = enum.auto() - - -DELAYED_REGISTRATIONS: dict[BLRegisterEvent, typ.Callable[[Path], None]] = {} - - #################### # - Class Registration #################### @@ -109,7 +79,7 @@ def unregister_classes() -> None: #################### # - Keymap Registration #################### -def register_hotkeys(hotkey_defs: list[dict]): +def register_hotkeys(hotkey_defs: list[dict]) -> None: """Registers a list of Blender hotkey definitions. Parameters: @@ -144,7 +114,7 @@ def register_hotkeys(hotkey_defs: list[dict]): _REGISTERED_HOTKEYS.append(keymap_item) -def unregister_hotkeys(): +def unregister_hotkeys() -> None: """Unregisters all Blender hotkeys associated with the addon.""" global _ADDON_KEYMAP # noqa: PLW0603 @@ -165,59 +135,3 @@ def unregister_hotkeys(): ) _REGISTERED_HOTKEYS.clear() _ADDON_KEYMAP = None - - -#################### -# - Delayed Registration Semantics -#################### -def delay_registration_until( - delayed_reg_key: BLRegisterEvent, - then_register_classes: typ.Callable[[Path], list[ct.BLClass]], - then_register_hotkeys: typ.Callable[[Path], list[ct.KeymapItemDef]], -) -> None: - """Delays the registration of Blender classes that depend on certain Python dependencies, for which neither the location nor validity is yet known. - - The function that registers is stored in the module global `DELAYED_REGISTRATIONS`, indexed by `delayed_reg_key`. - Once the PyDeps location and validity is determined, `run_delayed_registration()` can be used as a shorthand for accessing `DELAYED_REGISTRATIONS[delayed_reg_key]`. - - Parameters: - delayed_reg_key: The identifier with which to index the registration callback. - classes_cb: A function that takes a `sys.path`-compatible path to Python dependencies needed by the Blender classes in question, and returns a list of Blender classes to import. - `register_classes()` will be used to actually register the returned Blender classes. - hotkey_defs_cb: Similar, except for addon keymap items. - - Returns: - A function that takes a `sys.path`-compatible path to the Python dependencies needed to import the given Blender classes. - """ - if delayed_reg_key in DELAYED_REGISTRATIONS: - msg = f'Already delayed a registration with key {delayed_reg_key}' - raise ValueError(msg) - - def register_cb(path_pydeps: Path): - log.info( - 'Delayed Registration (key %s) with PyDeps Path: %s', - delayed_reg_key, - path_pydeps, - ) - register_classes(then_register_classes(path_pydeps)) - register_hotkeys(then_register_hotkeys(path_pydeps)) - - DELAYED_REGISTRATIONS[delayed_reg_key] = register_cb - - -def run_delayed_registration( - delayed_reg_key: BLRegisterEvent, path_pydeps: Path -) -> None: - """Run a delayed registration, by using `delayed_reg_key` to lookup the correct path, passing `path_pydeps` to the registration. - - Parameters: - delayed_reg_key: The identifier with which to index the registration callback. - Must match the parameter with which the delayed registration was first declared. - path_pydeps: The `sys.path`-compatible path to the Python dependencies that the classes need to have available in order to register. - """ - DELAYED_REGISTRATIONS.pop(delayed_reg_key)(path_pydeps) - - -def clear_delayed_registrations() -> None: - """Dequeue all queued delayed registrations.""" - DELAYED_REGISTRATIONS.clear() diff --git a/oscillode/services/init_settings.py b/oscillode/services/init_settings.py new file mode 100644 index 0000000..42a3db1 --- /dev/null +++ b/oscillode/services/init_settings.py @@ -0,0 +1,59 @@ +# oscillode +# Copyright (C) 2024 oscillode Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Authoritative definition and storage of "Initialization Settings", which provide out-of-the-box defaults for settings relevant for ex. debugging. + +These settings are transported via a special TOML file that is packed into the extension zip-file when packaging for release. +""" + +import tomllib +import typing as typ +from pathlib import Path + +import pydantic as pyd + +#################### +# - Constants +#################### +PATH_ROOT = Path(__file__).resolve().parent.parent +INIT_SETTINGS_FILENAME = 'init_settings.toml' + + +#################### +# - Types +#################### +LogLevel: typ.TypeAlias = int + + +class InitSettings(pyd.BaseModel): + """Model describing addon initialization settings, describing default settings baked into the release. + + Each parameter + + """ + + use_log_file: bool + log_file_path: Path + log_file_level: LogLevel + use_log_console: bool + log_console_level: LogLevel + + +#################### +# - Init Settings Management +#################### +with (PATH_ROOT / INIT_SETTINGS_FILENAME).open('rb') as f: + INIT_SETTINGS: InitSettings = InitSettings(tomllib.load(f)) diff --git a/oscillode/nodeps/utils/blender_type_enum.py b/oscillode/utils/blender_type_enum.py similarity index 100% rename from oscillode/nodeps/utils/blender_type_enum.py rename to oscillode/utils/blender_type_enum.py diff --git a/oscillode/utils/logger.py b/oscillode/utils/logger.py index 1982ba1..4ba1ea3 100644 --- a/oscillode/utils/logger.py +++ b/oscillode/utils/logger.py @@ -14,40 +14,27 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . +"""A lightweight, `rich`-based approach to logging that is isolated from other extensions that may used the Python stdlib `logging` module.""" import logging +import typing as typ from pathlib import Path import rich.console import rich.logging import rich.traceback - from blender_maxwell import contracts as ct -from ..nodeps.utils import simple_logger -from ..nodeps.utils.simple_logger import ( - LOG_LEVEL_MAP, # noqa: F401 - LogLevel, - loggers, # noqa: F401 - simple_loggers, # noqa: F401 - update_all_loggers, # noqa: F401 - update_logger, # noqa: F401 -) +from ..services import init_settings + +LogLevel: typ.TypeAlias = int + + +#################### +# - Configuration +#################### +STREAM_LOG_FORMAT = 11 * ' ' + '%(levelname)-8s %(message)s (%(name)s)' +FILE_LOG_FORMAT = STREAM_LOG_FORMAT OUTPUT_CONSOLE = rich.console.Console( color_system='truecolor', @@ -56,13 +43,45 @@ ERROR_CONSOLE = rich.console.Console( color_system='truecolor', stderr=True, ) + +ADDON_LOGGER_NAME = f'blext-{ct.addon.NAME}' +ADDON_LOGGER: logging.Logger = logging.getLogger(ADDON_LOGGER_NAME) + rich.traceback.install(show_locals=True, console=ERROR_CONSOLE) +#################### +# - Logger Access +#################### +def all_addon_loggers() -> set[logging.Logger]: + """Retrieve all loggers currently declared by this addon. + + These loggers are all children of `ADDON_LOGGER`, essentially. + This allows for name-isolation from other Blender extensions, as well as easy cleanup. + + Returns: + Set of all loggers declared by this addon. + """ + return { + logging.getLogger(name) + for name in logging.root.manager.loggerDict + if name.startswith(ADDON_LOGGER_NAME) + } + ## TODO: Python 3.12 has a .getChildren() method that also returns sets. + + #################### # - Logging Handlers #################### def console_handler(level: LogLevel) -> rich.logging.RichHandler: + """A logging handler that prints messages to the console. + + Parameters: + level: The log levels (debug, info, etc.) to print. + + Returns: + The logging handler, which can be added to a logger. + """ rich_formatter = logging.Formatter( '%(message)s', datefmt='[%X]', @@ -77,16 +96,117 @@ def console_handler(level: LogLevel) -> rich.logging.RichHandler: def file_handler(path_log_file: Path, level: LogLevel) -> rich.logging.RichHandler: - return simple_logger.file_handler(path_log_file, level) + """A logging handler that prints messages to a file. + + Parameters: + path_log_file: The path to the log file. + level: The log levels (debug, info, etc.) to append to the file. + + Returns: + The logging handler, which can be added to a logger. + """ + file_formatter = logging.Formatter(FILE_LOG_FORMAT) + file_handler = logging.FileHandler(path_log_file) + file_handler.setFormatter(file_formatter) + file_handler.setLevel(level) + return file_handler #################### # - Logger Setup #################### -def get(module_name): - logger = logging.getLogger(module_name) +def get(module_name: str) -> logging.Logger: + """Retrieve and/or create a logger corresponding to a module name. - # Setup Logger from Addon Preferences - ct.addon.prefs().on_addon_logging_changed(single_logger_to_setup=logger) + Warnings: + MUST be used as `logger.get(__name__)`. - return logger + Parameters: + module_name: The `__name__` of the module to return a logger for. + """ + log = ADDON_LOGGER.getChild(module_name) + + # Setup Logger from Init Settings or Addon Preferences + ## - We prefer addon preferences, but they may not be setup yet. + ## - Once setup, the preferences may decide to re-configure all the loggers. + addon_prefs = ct.addon.prefs() + if addon_prefs is None: + use_log_file = init_settings.INIT_SETTINGS['use_log_file'] + log_file_path = init_settings.INIT_SETTINGS['log_file_path'] + log_file_level = init_settings.INIT_SETTINGS['log_file_level'] + use_log_console = init_settings.INIT_SETTINGS['use_log_console'] + log_console_level = init_settings.INIT_SETTINGS['log_console_level'] + + update_logger( + console_handler, + file_handler, + log, + file_path=log_file_path if use_log_file else None, + file_level=log_file_level, + console_level=log_console_level if use_log_console else None, + ) + else: + addon_prefs.setup_logger(log) + + return log + + +#################### +# - Logger Update +#################### +def _init_logger(logger: logging.Logger) -> None: + """Prepare a logger for handlers to be added, ensuring normalized semantics for all loggers. + + - Messages should not propagate to the root logger, causing double-messages. + - Mesages should not filter by level; this is the job of the handlers. + - No handlers must be set. + + Args: + logger: The logger to prepare. + """ + # DO NOT Propagate to Root Logger + ## - This looks like 'double messages' + ## - See SO/6729268/log-messages-appearing-twice-with-python-logging + logger.propagate = False + + # Let All Messages Through + ## - The individual handlers perform appropriate filtering. + logger.setLevel(logging.NOTSET) + + if logger.handlers: + logger.handlers.clear() + + +def update_logger( + cb_console_handler: typ.Callable[[LogLevel], logging.Handler], + cb_file_handler: typ.Callable[[Path, LogLevel], logging.Handler], + logger: logging.Logger, + console_level: LogLevel | None, + file_path: Path | None, + file_level: LogLevel, +) -> None: + """Configures a single logger with given console and file handlers, individualizing the log level that triggers it. + + This is a lower-level function - generally, modules that want to use a well-configured logger will use the `get()` function, which retrieves the parameters for this function from the addon preferences. + This function is used by the higher-level log setup. + + Parameters: + cb_console_handler: A function that takes a log level threshold (inclusive), and returns a logging handler to a console-printer. + cb_file_handler: A function that takes a log level threshold (inclusive), and returns a logging handler to a file-printer. + logger: The logger to configure. + console_level: The log level threshold to print to the console. + None deactivates file logging. + path_log_file: The path to the log file. + None deactivates file logging. + file_level: The log level threshold to print to the log file. + """ + # Initialize Logger + _init_logger(logger) + + # Add Console Logging Handler + if console_level is not None: + logger.addHandler(cb_console_handler(console_level)) + + # Add File Logging Handler + if file_path is not None: + logger.addHandler(cb_file_handler(file_path, file_level)) diff --git a/pyproject.toml b/pyproject.toml index 548a34a..0ef4d46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ dependencies = [ "seaborn[stats]>=0.13.*", "frozendict>=2.4.*", "pydantic-tensor>=0.2", - "pip>=24.2", ] readme = "README.md" requires-python = "~= 3.11" @@ -95,10 +94,11 @@ log_console_level = 'warning' [tool.uv] managed = true dev-dependencies = [ - "ruff>=0.4.3", - "fake-bpy-module-4-0>=20231118", + "ruff>=0.6.8", "pre-commit>=3.7.0", "commitizen>=3.25.0", + "fake-bpy-module==20240927", + "pip>=24.2", ## Requires charset-normalizer>=2.1.0 # Required by Commitizen ## -> It's okay to have different dev/prod versions in our use case. @@ -205,6 +205,25 @@ quote-style = "single" indent-style = "tab" docstring-code-format = false +#################### +# - Tooling: MyPy +#################### +[tool.mypy] +python_version = '3.11' +ignore_missing_imports = true +strict_optional = true +disallow_subclassing_any = false +disallow_any_generics = true +disallow_untyped_calls = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = true + + #################### # - Tooling: Commits #################### diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..85273af --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1,16 @@ +# oscillode +# Copyright (C) 2024 oscillode Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + diff --git a/scripts/info.py b/scripts/info.py index 1d06b0b..3e14465 100644 --- a/scripts/info.py +++ b/scripts/info.py @@ -30,9 +30,20 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +"""Informational constants and variables that ease the implementation of scripts external to oscillode. + +In general, user-relevant information available here should be mined from `pyproject.toml`, unless it is purely structural. +""" + import tomllib +import typing as typ from pathlib import Path +import pydantic as pyd +from rich.console import Console + +console = Console() + #################### # - Basic #################### @@ -40,13 +51,23 @@ PATH_ROOT = Path(__file__).resolve().parent.parent with (PATH_ROOT / 'pyproject.toml').open('rb') as f: PROJ_SPEC = tomllib.load(f) -REQ_PYTHON_VERSION = PROJ_SPEC['project']['requires-python'].replace('~= ', '') +REQ_PYTHON_VERSION: str = PROJ_SPEC['project']['requires-python'].replace('~= ', '') #################### # - Paths #################### -def proc_path(p_str: str) -> Path: +def normalize_path(p_str: str) -> Path: + """Convert a project-root-relative '/'-delimited string path to an absolute path. + + This allows for cross-platform path specification, while retaining normalized use of '/' in configuration. + + Args: + p_str: The '/'-delimited string denoting a path relative to the project root. + + Returns: + The full absolute path to use when ex. packaging. + """ return PATH_ROOT / Path(*p_str.split('/')) @@ -57,18 +78,18 @@ PATH_DEV.mkdir(exist_ok=True) # Retrieve Build Path ## Extension ZIP files will be placed here after packaging. -PATH_BUILD = proc_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_builds']) +PATH_BUILD = normalize_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_builds']) PATH_BUILD.mkdir(exist_ok=True) # Retrieve Wheels Path ## Dependency wheels will be downloaded to here, then copied into the extension packages. -PATH_WHEELS = proc_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_wheels']) +PATH_WHEELS = normalize_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_wheels']) PATH_WHEELS.mkdir(exist_ok=True) # Retrieve Local Cache Path ## During development, files such as logs will be placed here instead of in the extension's user path. ## This is essentially a debugging convenience. -PATH_LOCAL = proc_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_local']) +PATH_LOCAL = normalize_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_local']) PATH_LOCAL.mkdir(exist_ok=True) #################### @@ -79,3 +100,18 @@ PATH_LOCAL.mkdir(exist_ok=True) PATH_ZIP = PATH_BUILD / ( PROJ_SPEC['project']['name'] + '__' + PROJ_SPEC['project']['version'] + '.zip' ) + +#################### +# - Computed Paths +#################### +LogLevel: typ.TypeAlias = int + + +class InitSettings(pyd.BaseModel): + """Model describing addon initialization settings, describing default settings baked into the release.""" + + use_log_file: bool + log_file_path: Path + log_file_level: LogLevel + use_log_console: bool + log_console_level: LogLevel diff --git a/scripts/pack.py b/scripts/pack.py index 5d2a11d..801a1f8 100644 --- a/scripts/pack.py +++ b/scripts/pack.py @@ -14,35 +14,26 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -# blender_maxwell -# Copyright (C) 2024 blender_maxwell Project Contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . +"""Contains tools and procedures that deterministically and reliably package the Blender extension. -import sys -import contextlib +This involves parsing and validating the plugin configuration from `pyproject.toml`, generating the extension manifest, downloading the correct platform-specific binary wheels for distribution, and zipping it all up together. +""" + +import itertools +import json import logging +import subprocess +import sys import tempfile import typing as typ import zipfile from pathlib import Path -import itertools -import subprocess - -import tomli_w import info +import rich +import rich.progress +import rich.prompt +import tomli_w LogLevel: typ.TypeAlias = int @@ -55,10 +46,10 @@ BL_EXT__TYPE = 'add-on' #################### # See https://docs.blender.org/manual/en/4.2/extensions/getting_started.html # See https://packaging.python.org/en/latest/guides/writing-pyproject-toml -## TODO: More validation and such. _FIRST_MAINTAINER = info.PROJ_SPEC['project']['maintainers'][0] _SPDX_LICENSE_NAME = info.PROJ_SPEC['project']['license']['text'] +## TODO: pydantic model w/validation BL_EXT_MANIFEST = { 'schema_version': BL_EXT__SCHEMA_VERSION, # Basics @@ -95,13 +86,19 @@ BL_EXT_MANIFEST = { #################### # - Generate Init Settings #################### -def generate_init_settings_dict(profile: str) -> dict: +## TODO: Use an enum for 'profile'. +def generate_init_settings_dict(profile: str) -> info.InitSettings: + """Generate initialization settings from a particular `profile` configured in `pyproject.toml`. + + Args: + profile: The string identifier corresponding to an entry in `pyproject.toml`. + + Returns: + The `info.InitSettings` structure to be bundled into the addon. + """ profile_settings = info.PROJ_SPEC['tool']['bl_ext']['profiles'][profile] - if profile_settings['use_path_local']: - base_path = info.PATH_LOCAL - else: - base_path = Path('{USER}') + base_path = info.PATH_LOCAL if profile_settings['use_path_local'] else Path('USER') log_levels = { None: logging.NOTSET, @@ -112,32 +109,54 @@ def generate_init_settings_dict(profile: str) -> dict: 'critical': logging.CRITICAL, } - return { - # File Logging - 'use_log_file': profile_settings['use_log_file'], - 'log_file_path': str(base_path / profile_settings['log_file_path']), - 'log_file_level': log_levels[profile_settings['log_file_level']], - # Console Logging - 'use_log_console': profile_settings['use_log_console'], - 'log_console_level': log_levels[profile_settings['log_console_level']], - } + return info.InitSettings( + use_log_file=profile_settings['use_log_file'], + log_file_path=base_path + / info.normalize_path(profile_settings['log_file_path']), + log_file_level=log_levels[profile_settings['log_file_level']], + use_log_console=profile_settings['use_log_console'], + log_console_level=log_levels[profile_settings['log_console_level']], + ) #################### # - Wheel Downloader #################### -def download_wheels() -> dict: +def download_wheels(delete_existing_wheels: bool = True) -> None: + """Download universal and binary wheels for all platforms defined in `pyproject.toml`. + + Each blender-supported platform requires specifying a valid list of PyPi platform constraints. + These will be used as an allow-list when deciding which binary wheels may be selected for ex. 'mac'. + + It is recommended to start with the most compatible platform tags, then work one's way up to the newest. + Depending on how old the compatibility should stretch, one may have to omit / manually compile some wheels. + + There is no exhaustive list of valid platform tags - though this should get you started: + - https://stackoverflow.com/questions/49672621/what-are-the-valid-values-for-platform-abi-and-implementation-for-pip-do + - Examine https://pypi.org/project/pillow/#files for some widely-supported tags. + + Args: + delete_existing_wheels: Whether to delete all wheels already in the directory. + This doesn't generally require re-downloading; the pip-cache will generally be hit first. + """ + if delete_existing_wheels and rich.prompt.Confirm.ask( + f'OK to delete "*.whl" in {info.PATH_WHEELS}?' + ): + info.console.log(f'[bold] Deleting Existing Wheels in {info.PATH_WHEELS}') + for existing_wheel in info.PATH_WHEELS.rglob('*.whl'): + existing_wheel.unlink() + with tempfile.NamedTemporaryFile(delete=False) as f_reqlock: - reqlock_str = subprocess.check_output(['uv', 'export', '--no-dev']) + reqlock_str = subprocess.check_output(['uv', 'export', '--no-dev', '--locked']) f_reqlock.write(reqlock_str) reqlock_path = Path(f_reqlock.name) + ## TODO: Use-after-close may not work on Windows. for platform, pypi_platform_tags in info.PROJ_SPEC['tool']['bl_ext'][ 'platforms' ].items(): - print(f'[{platform}] Downloading Wheels...') - print() - print() + info.console.rule(f'[bold] Downloading Wheels for {platform}') + platform_constraints = list( itertools.chain.from_iterable( [ @@ -146,42 +165,51 @@ def download_wheels() -> dict: ] ) ) - subprocess.check_call( - [ - sys.executable, - '-m', - 'pip', - 'download', - '--requirement', - str(reqlock_path), - '--dest', - str(info.PATH_WHEELS), - '--require-hashes', - '--only-binary', - ':all:', - '--python-version', - info.REQ_PYTHON_VERSION, - ] - + platform_constraints - ) - print() + cmd = [ + sys.executable, + '-m', + 'pip', + 'download', + '--requirement', + str(reqlock_path), + '--dest', + str(info.PATH_WHEELS), + '--require-hashes', + '--only-binary', + ':all:', + '--python-version', + info.REQ_PYTHON_VERSION, + *platform_constraints, + ] + + progress = rich.progress.Progress() + progress_task_id = progress.add_task(' '.join(cmd)) + with ( + rich.progress.Live(progress, console=info.console, transient=False) as live, + subprocess.Popen(cmd, stdout=subprocess.PIPE) as process, + ): + for line in process.stdout if process.stdout is not None else []: + progress.update(progress_task_id, description=line.decode()) + live.refresh() + + # Cleanup the Temporary File + reqlock_path.unlink() #################### # - Pack Extension to ZIP #################### -def pack_bl_extension( # noqa: PLR0913 +def pack_bl_extension( profile: str, replace_if_exists: bool = False, -) -> typ.Iterator[Path]: - """Context manager exposing a folder as a (temporary) zip file. +) -> None: + """Package a Blender extension, using a particular given `profile` of init settings. + + Configuration data is sourced from `info`, which in turns sources much of its user-facing configuration from `pyproject.toml`. Parameters: - path_addon_pkg: Path to the folder containing __init__.py of the Blender addon. - path_addon_zip: Path to the Addon ZIP to generate. - path_pyproject_toml: Path to the `pyproject.toml` of the project. - This is made available to the addon, to de-duplicate definition of name, - The .zip file is deleted afterwards, unless `remove_after_close` is specified. + profile: Identifier matching `pyproject.toml`, which select a predefined set of init settings. + replace_if_exists: Replace the zip file if it already exists. """ # Delete Existing ZIP (maybe) if info.PATH_ZIP.is_file(): @@ -190,44 +218,77 @@ def pack_bl_extension( # noqa: PLR0913 raise ValueError(msg) info.PATH_ZIP.unlink() - init_settings: dict = generate_init_settings_dict(profile) + init_settings: info.InitSettings = generate_init_settings_dict(profile) # Create New ZIP file of the addon directory + info.console.rule(f'[bold] Creating zipfile @ {info.PATH_ZIP}') with zipfile.ZipFile(info.PATH_ZIP, 'w', zipfile.ZIP_DEFLATED) as f_zip: # Write Blender Extension Manifest - print('Writing Blender Extension Manifest...') - f_zip.writestr(BL_EXT__MANIFEST_FILENAME, tomli_w.dumps(BL_EXT_MANIFEST)) + with info.console.status('Writing Extension Manifest...'): + f_zip.writestr(BL_EXT__MANIFEST_FILENAME, tomli_w.dumps(BL_EXT_MANIFEST)) + info.console.log('Wrote Extension Manifest.') # Write Init Settings - print('Writing Init Settings...') - f_zip.writestr( - info.PROJ_SPEC['tool']['bl_ext']['packaging']['init_settings_filename'], - tomli_w.dumps(init_settings), - ) + with info.console.status('Writing Init Settings...'): + f_zip.writestr( + info.PROJ_SPEC['tool']['bl_ext']['packaging']['init_settings_filename'], + tomli_w.dumps(json.loads(init_settings.model_dump_json())), + ) + info.console.log('Wrote Init Settings.') # Install Addon Files @ /* - print('Writing Addon Files Settings...') - for file_to_zip in info.PATH_PKG.rglob('*'): - f_zip.write(file_to_zip, file_to_zip.relative_to(info.PATH_PKG.parent)) + with info.console.status('Writing Addon Files...'): + for file_to_zip in info.PATH_PKG.rglob('*'): + f_zip.write(file_to_zip, file_to_zip.relative_to(info.PATH_PKG.parent)) + info.console.log('Wrote Addon Files.') # Install Wheels @ /wheels/* - print('Writing Wheels...') - for wheel_to_zip in info.PATH_WHEELS.rglob('*'): - f_zip.write(wheel_to_zip, Path('wheels') / wheel_to_zip.name) + # with info.console.status('Writing Wheels...'): + # for wheel_to_zip in info.PATH_WHEELS.rglob('*'): + # f_zip.write(wheel_to_zip, Path('wheels') / wheel_to_zip.name) + # info.console.log('Wrote Wheels.') + + total_wheel_size = sum( + f.stat().st_size for f in info.PATH_WHEELS.rglob('*') if f.is_file() + ) + + progress = rich.progress.Progress( + rich.progress.TextColumn( + 'Writing Wheel: {task.description}...', + table_column=rich.progress.Column(ratio=2), + ), + rich.progress.BarColumn( + bar_width=None, + table_column=rich.progress.Column(ratio=2), + ), + expand=True, + ) + progress_task = progress.add_task('Writing Wheels...', total=total_wheel_size) + with rich.progress.Live(progress, console=info.console, transient=True) as live: + for wheel_to_zip in info.PATH_WHEELS.rglob('*.whl'): + f_zip.write(wheel_to_zip, Path('wheels') / wheel_to_zip.name) + progress.update( + progress_task, + description=wheel_to_zip.name, + advance=wheel_to_zip.stat().st_size, + ) + live.refresh() + + info.console.log('Wrote Wheels.') # Delete the ZIP - print('Packed Blender Extension!') + info.console.rule(f'[bold green] Extension Packed to {info.PATH_ZIP}!') #################### # - Run Blender w/Clean Addon Reinstall #################### if __name__ == '__main__': - if not list(info.PATH_WHEELS.iterdir()) or '--download-wheels' in sys.argv: - download_wheels() - profile = sys.argv[1] if sys.argv[1] in ['dev', 'release', 'release-debug']: + if not list(info.PATH_WHEELS.iterdir()) or '--download-wheels' in sys.argv: + download_wheels() + pack_bl_extension(profile) else: msg = f'Packaging profile "{profile}" is invalid. Refer to source of pack.py for more information' diff --git a/uv.lock b/uv.lock index 3ad397e..8f5ddf7 100644 --- a/uv.lock +++ b/uv.lock @@ -269,12 +269,12 @@ wheels = [ ] [[package]] -name = "fake-bpy-module-4-0" -version = "20240604" +name = "fake-bpy-module" +version = "20240927" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/f4/b996342217daec3db18ee60c64ced9e0871ad51ca596bd7c6662687fcdea/fake_bpy_module_4_0-20240604.tar.gz", hash = "sha256:1b11e8c69c5952710f3547401f7a2b2343f4328ed5925df23fb9bf9b6dd83c13", size = 841347 } +sdist = { url = "https://files.pythonhosted.org/packages/d2/4d/938a7b6049b05b5c9861758792c64b10c0bf733c9b8eb8f8721b5abf8105/fake_bpy_module-20240927.tar.gz", hash = "sha256:fd706c4f73283473ff2ae9ec9017fcfc33fc32c9ddd64d9a89b4701d1d9dc8a3", size = 953541 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/78/de406749fd585dbb6e9fe8f120ea4ff5bcc6a1eafef9b479578ac37f9fb1/fake_bpy_module_4.0-20240604-py3-none-any.whl", hash = "sha256:764c32e1fca23b1a58eb2f6db57e945cc18ac3ed5317e6d5088f2d1097a8d8ea", size = 1066475 }, + { url = "https://files.pythonhosted.org/packages/3a/22/fc70e69f3ae6399bdb4dd218e257001a1b67483e59ee7caaaf71e30eb1f2/fake_bpy_module-20240927-py3-none-any.whl", hash = "sha256:123ca66bfe184f4e8b2a6cec3ef66c522acfa0807b1b478bd110687d920fa0ac", size = 1076677 }, ] [[package]] @@ -835,7 +835,6 @@ dependencies = [ { name = "msgspec", extra = ["toml"] }, { name = "networkx" }, { name = "numba" }, - { name = "pip" }, { name = "polars" }, { name = "pydantic" }, { name = "pydantic-tensor" }, @@ -851,7 +850,8 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "commitizen" }, - { name = "fake-bpy-module-4-0" }, + { name = "fake-bpy-module" }, + { name = "pip" }, { name = "pre-commit" }, { name = "ruff" }, ] @@ -864,7 +864,6 @@ requires-dist = [ { name = "msgspec", extras = ["toml"], specifier = "==0.18.*" }, { name = "networkx", specifier = "==3.3.*" }, { name = "numba", specifier = "==0.60.*" }, - { name = "pip", specifier = ">=24.2" }, { name = "polars", specifier = "==1.8.*" }, { name = "pydantic", specifier = "==2.9.*" }, { name = "pydantic-tensor", specifier = ">=0.2" }, @@ -880,9 +879,10 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "commitizen", specifier = ">=3.25.0" }, - { name = "fake-bpy-module-4-0", specifier = ">=20231118" }, + { name = "fake-bpy-module", specifier = "==20240927" }, + { name = "pip", specifier = ">=24.2" }, { name = "pre-commit", specifier = ">=3.7.0" }, - { name = "ruff", specifier = ">=0.4.3" }, + { name = "ruff", specifier = ">=0.6.8" }, ] [[package]] @@ -1287,27 +1287,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.6.7" +version = "0.6.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/3045a526c57cef4b5ec4d5d154692e31429749a49810a53e785de334c4f6/ruff-0.6.7.tar.gz", hash = "sha256:44e52129d82266fa59b587e2cd74def5637b730a69c4542525dfdecfaae38bd5", size = 3073785 } +sdist = { url = "https://files.pythonhosted.org/packages/74/f9/4ce3e765a72ab8fe0f80f48508ea38b4196daab3da14d803c21349b2d367/ruff-0.6.8.tar.gz", hash = "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18", size = 3084543 } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/c4/1c5c636f83f905c537785016e9cdd7a36df53c025a2d07940580ecb37bcf/ruff-0.6.7-py3-none-linux_armv6l.whl", hash = "sha256:08277b217534bfdcc2e1377f7f933e1c7957453e8a79764d004e44c40db923f2", size = 10336748 }, - { url = "https://files.pythonhosted.org/packages/84/d9/aa15a56be7ad796f4d7625362aff588f9fc013bbb7323a63571628a2cf2d/ruff-0.6.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c6707a32e03b791f4448dc0dce24b636cbcdee4dd5607adc24e5ee73fd86c00a", size = 9958833 }, - { url = "https://files.pythonhosted.org/packages/27/25/5dd1c32bfc3ad3136c8ebe84312d1bdd2e6c908ac7f60692ec009b7050a8/ruff-0.6.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:533d66b7774ef224e7cf91506a7dafcc9e8ec7c059263ec46629e54e7b1f90ab", size = 9633369 }, - { url = "https://files.pythonhosted.org/packages/0e/3e/01b25484f3cb08fe6fddedf1f55f3f3c0af861a5b5f5082fbe60ab4b2596/ruff-0.6.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17a86aac6f915932d259f7bec79173e356165518859f94649d8c50b81ff087e9", size = 10637415 }, - { url = "https://files.pythonhosted.org/packages/8a/c9/5bb9b849e4777e0f961de43edf95d2af0ab34999a5feee957be096887876/ruff-0.6.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3f8822defd260ae2460ea3832b24d37d203c3577f48b055590a426a722d50ef", size = 10097389 }, - { url = "https://files.pythonhosted.org/packages/52/cf/e08f1c290c7d848ddfb2ae811f24f445c18e1d3e50e01c38ffa7f5a50494/ruff-0.6.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba4efe5c6dbbb58be58dd83feedb83b5e95c00091bf09987b4baf510fee5c99", size = 10951440 }, - { url = "https://files.pythonhosted.org/packages/a2/2d/ca8aa0da5841913c302d8034c6de0ce56c401c685184d8dd23cfdd0003f9/ruff-0.6.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:525201b77f94d2b54868f0cbe5edc018e64c22563da6c5c2e5c107a4e85c1c0d", size = 11708900 }, - { url = "https://files.pythonhosted.org/packages/89/fc/9a83c57baee977c82392e19a328b52cebdaf61601af3d99498e278ef5104/ruff-0.6.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8854450839f339e1049fdbe15d875384242b8e85d5c6947bb2faad33c651020b", size = 11258892 }, - { url = "https://files.pythonhosted.org/packages/d3/a3/254cc7afef702c68ae9079290c2a1477ae0e81478589baf745026d8a4eb5/ruff-0.6.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f0b62056246234d59cbf2ea66e84812dc9ec4540518e37553513392c171cb18", size = 12367932 }, - { url = "https://files.pythonhosted.org/packages/9f/55/53f10c1bd8c3b2ae79aed18e62b22c6346f9296aa0ec80489b8442bd06a9/ruff-0.6.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b1462fa56c832dc0cea5b4041cfc9c97813505d11cce74ebc6d1aae068de36b", size = 10838629 }, - { url = "https://files.pythonhosted.org/packages/84/72/fb335c2b25432c63d15383ecbd7bfc1915e68cdf8d086a08042052144255/ruff-0.6.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:02b083770e4cdb1495ed313f5694c62808e71764ec6ee5db84eedd82fd32d8f5", size = 10648824 }, - { url = "https://files.pythonhosted.org/packages/92/a8/d57e135a8ad99b6a0c6e2a5c590bcacdd57f44340174f4409c3893368610/ruff-0.6.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c05fd37013de36dfa883a3854fae57b3113aaa8abf5dea79202675991d48624", size = 10174368 }, - { url = "https://files.pythonhosted.org/packages/a7/6f/1a30a6e81dcf2fa9ff3f7011eb87fe76c12a3c6bba74db6a1977d763de1f/ruff-0.6.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f49c9caa28d9bbfac4a637ae10327b3db00f47d038f3fbb2195c4d682e925b14", size = 10514383 }, - { url = "https://files.pythonhosted.org/packages/0b/25/df6f2575bc9fe43a6dedfd8dee12896f09a94303e2c828d5f85856bb69a0/ruff-0.6.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0e1655868164e114ba43a908fd2d64a271a23660195017c17691fb6355d59bb", size = 10902340 }, - { url = "https://files.pythonhosted.org/packages/68/62/f2c1031e2fb7b94f9bf0603744e73db4ef90081b0eb1b9639a6feefd52ea/ruff-0.6.7-py3-none-win32.whl", hash = "sha256:a939ca435b49f6966a7dd64b765c9df16f1faed0ca3b6f16acdf7731969deb35", size = 8448033 }, - { url = "https://files.pythonhosted.org/packages/97/80/193d1604a3f7d75eb1b2a7ce6bf0fdbdbc136889a65caacea6ffb29501b1/ruff-0.6.7-py3-none-win_amd64.whl", hash = "sha256:590445eec5653f36248584579c06252ad2e110a5d1f32db5420de35fb0e1c977", size = 9273543 }, - { url = "https://files.pythonhosted.org/packages/8e/a8/4abb5a9f58f51e4b1ea386be5ab2e547035bc1ee57200d1eca2f8909a33e/ruff-0.6.7-py3-none-win_arm64.whl", hash = "sha256:b28f0d5e2f771c1fe3c7a45d3f53916fc74a480698c4b5731f0bea61e52137c8", size = 8618044 }, + { url = "https://files.pythonhosted.org/packages/db/07/42ee57e8b76ca585297a663a552b4f6d6a99372ca47fdc2276ef72cc0f2f/ruff-0.6.8-py3-none-linux_armv6l.whl", hash = "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2", size = 10404327 }, + { url = "https://files.pythonhosted.org/packages/eb/51/d42571ff8156d65086acb72d39aa64cb24181db53b497d0ed6293f43f07a/ruff-0.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c", size = 10018797 }, + { url = "https://files.pythonhosted.org/packages/c1/d7/fa5514a60b03976af972b67fe345deb0335dc96b9f9a9fa4df9890472427/ruff-0.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5", size = 9691303 }, + { url = "https://files.pythonhosted.org/packages/d6/c4/d812a74976927e51d0782a47539069657ac78535779bfa4d061c4fc8d89d/ruff-0.6.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f", size = 10719452 }, + { url = "https://files.pythonhosted.org/packages/ec/b6/aa700c4ae6db9b3ee660e23f3c7db596e2b16a3034b797704fba33ddbc96/ruff-0.6.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb", size = 10161353 }, + { url = "https://files.pythonhosted.org/packages/ea/39/0b10075ffcd52ff3a581b9b69eac53579deb230aad300ce8f9d0b58e77bc/ruff-0.6.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f", size = 10980630 }, + { url = "https://files.pythonhosted.org/packages/c1/af/9eb9efc98334f62652e2f9318f137b2667187851911fac3b395365a83708/ruff-0.6.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0", size = 11768996 }, + { url = "https://files.pythonhosted.org/packages/e0/59/8b1369cf7878358952b1c0a1559b4d6b5c824c003d09b0db26d26c9d094f/ruff-0.6.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87", size = 11317469 }, + { url = "https://files.pythonhosted.org/packages/b9/6d/e252e9b11bbca4114c386ee41ad559d0dac13246201d77ea1223c6fea17f/ruff-0.6.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098", size = 12467185 }, + { url = "https://files.pythonhosted.org/packages/48/44/7caa223af7d4ea0f0b2bd34acca65a7694a58317714675a2478815ab3f45/ruff-0.6.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0", size = 10887766 }, + { url = "https://files.pythonhosted.org/packages/81/ed/394aff3a785f171869158b9d5be61eec9ffb823c3ad5d2bdf2e5f13cb029/ruff-0.6.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750", size = 10711609 }, + { url = "https://files.pythonhosted.org/packages/47/31/f31d04c842e54699eab7e3b864538fea26e6c94b71806cd10aa49f13e1c1/ruff-0.6.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce", size = 10237621 }, + { url = "https://files.pythonhosted.org/packages/20/95/a764e84acf11d425f2f23b8b78b4fd715e9c20be4aac157c6414ca859a67/ruff-0.6.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa", size = 10558329 }, + { url = "https://files.pythonhosted.org/packages/2a/76/d4e38846ac9f6dd62dce858a54583911361b5339dcf8f84419241efac93a/ruff-0.6.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44", size = 10954102 }, + { url = "https://files.pythonhosted.org/packages/e7/36/f18c678da6c69f8d022480f3e8ddce6e4a52e07602c1d212056fbd234f8f/ruff-0.6.8-py3-none-win32.whl", hash = "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a", size = 8511090 }, + { url = "https://files.pythonhosted.org/packages/4c/c4/0ca7d8ffa358b109db7d7d045a1a076fd8e5d9cbeae022242d3c060931da/ruff-0.6.8-py3-none-win_amd64.whl", hash = "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263", size = 9350079 }, + { url = "https://files.pythonhosted.org/packages/d9/bd/a8b0c64945a92eaeeb8d0283f27a726a776a1c9d12734d990c5fc7a1278c/ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc", size = 8669595 }, ] [[package]]