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]]