refactor: Combing through spaghetti

uv-refactor
Sofus Albert Høgsbro Rose 2024-09-27 14:20:26 +02:00
parent e6ada5028d
commit ad548b8f03
Signed by: so-rose
GPG Key ID: AD901CB0F3701434
24 changed files with 620 additions and 2032 deletions

View File

@ -14,8 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# blender_maxwell # oscillode Copyright (C) 2024 oscillode Project Contributors
# Copyright (C) 2024 blender_maxwell Project Contributors
# #
# This program is free software: you can redistribute it and/or modify # 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 # 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 # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
"""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` from . import assets, node_trees, operators, preferences, registration
`bl_info` declares information about the addon to Blender. from . import contracts as ct
from .utils import logger
However, it is not _dynamically_ read: Blender traverses it using `ast.parse`. log = logger.get(__name__)
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__)
#################### ####################
# - Load and Register Addon # - Load and Register Addon
#################### ####################
BL_REGISTER_BEFORE_DEPS: list[ct.BLClass] = [ BL_REGISTER: list[ct.BLClass] = [
*nodeps_operators.BL_REGISTER, *operators.BL_REGISTER,
*preferences.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 ## 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 # - 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: def register() -> None:
"""Implements a multi-stage addon registration, which accounts for Python dependency management. """Implements addon registration in a way that respects the availability of addon preferences and loggers.
# 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.
Notes: Notes:
Called by Blender when enabling the addon. 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) log.info('Registering Addon Preferences: %s', ct.addon.NAME)
bpy.app.handlers.load_post.append(manage_pydeps) registration.register_classes(preferences.BL_REGISTER)
# Register Barebones Addon addon_prefs = ct.addon.prefs()
## Contains all no-dependency BLClasses: if addon_prefs is not None:
## - Contains AddonPreferences. # Update Loggers
## Contains all BLClasses from 'nodeps'. # - This updates existing loggers to use settings defined by preferences.
registration.register_classes(BL_REGISTER_BEFORE_DEPS) addon_prefs.on_addon_logging_changed()
registration.register_hotkeys(BL_HOTKEYS_BEFORE_DEPS)
# Delay Complete Registration until DEPS_SATISFIED log.info('Registering Addon: %s', ct.addon.NAME)
registration.delay_registration_until(
registration.BLRegisterEvent.DepsSatisfied,
then_register_classes=load_main_blclasses,
then_register_hotkeys=load_main_blhotkeys,
)
# Trigger PyDeps Check registration.register_classes(BL_REGISTER)
## Deps ARE OK: Delayed registration will trigger. registration.register_hotkeys(BL_HOTKEYS)
## Deps NOT OK: User must fix the pydeps, then trigger this method.
ct.addon.prefs().on_addon_pydeps_changed() 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: def unregister() -> None:
"""Unregisters anything that was registered by the addon. """Unregisters anything that was registered by the addon.
Notes: Notes:
Run by Blender when disabling the addon. Run by Blender when disabling and/or uninstalling the addon.
This doesn't clean `sys.modules`. 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) log.info('Starting %s Unregister', ct.addon.NAME)
registration.unregister_classes() registration.unregister_classes()
registration.unregister_hotkeys() registration.unregister_hotkeys()
registration.clear_delayed_registrations()
log.info('Finished %s Unregister', ct.addon.NAME) log.info('Finished %s Unregister', ct.addon.NAME)

View File

@ -14,105 +14,74 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# blender_maxwell """Basic 'single-source-of-truth' static and dynamic information about this addon.
# Copyright (C) 2024 blender_maxwell Project Contributors
# Information is mined both from `bpy`, and from the addon's bundled manifest file.
# 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 <http://www.gnu.org/licenses/>.
import sys
import tomllib import tomllib
import typing as typ
from pathlib import Path from pathlib import Path
import bpy import bpy
import bpy_restrict_state
PATH_ADDON_ROOT = Path(__file__).resolve().parent.parent 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'] with (PATH_ADDON_ROOT / 'blender_manifest.toml').open('rb') as f:
VERSION = PROJ_SPEC['project']['version'] MANIFEST = tomllib.load(f)
NAME: str = MANIFEST['id']
VERSION: str = MANIFEST['version']
#################### ####################
# - Assets # - Assets
#################### ####################
PATH_ASSETS = PATH_ADDON_ROOT / 'assets' PATH_ASSETS: Path = PATH_ADDON_ROOT / 'assets'
PATH_CACHE: Path = Path(bpy.utils.extension_path_user(__package__, create=True))
####################
# - 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'
#################### ####################
# - Dynamic Addon Information # - Dynamic Addon Information
#################### ####################
def is_loading() -> bool: def operator(
"""Checks whether the addon is currently loading. 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. This avoids having to use `bpy.ops.<addon_name>.operator_name`, which isn't generic.
For example, operators can't run while the addon is loading.
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: Raises:
Since `bpy_restrict_state._RestrictContext` is a very internal thing, this function may be prone to breakage on Blender updates. ValueError: If an addon from outside this operator is attempted to be used.
**Keep an eye out**!
Returns:
Whether the addon has been fully loaded, such that `bpy.context` is fully accessible.
""" """
return isinstance(bpy.context, bpy_restrict_state._RestrictContext)
def operator(name: str, *operator_args, **operator_kwargs) -> None:
# Parse Operator Name # Parse Operator Name
operator_namespace, operator_name = name.split('.') operator_namespace, operator_name = name.split('.')
if operator_namespace != NAME: if operator_namespace != NAME:
msg = f'Tried to call operator {operator_name}, but addon operators may only use the addon operator namespace "{operator_namespace}.<name>"' msg = f'Tried to call operator {operator_name}, but addon operators may only use the addon operator namespace "{operator_namespace}.<name>"'
raise RuntimeError(msg) raise ValueError(msg)
# Addon Not Loading: Run Operator # Run Operator
if not is_loading(): operator = getattr(getattr(bpy.ops, NAME), operator_name)
operator = getattr(getattr(bpy.ops, NAME), operator_name) operator(*operator_args, **operator_kwargs)
operator(*operator_args, **operator_kwargs) ## TODO: Can't we constrain 'name' to be an OperatorType somehow?
else:
msg = f'Tried to call operator "{operator_name}" while addon is loading'
raise RuntimeError(msg)
def prefs() -> bpy.types.AddonPreferences | None: def prefs() -> bpy.types.AddonPreferences | None:
if (addon := bpy.context.preferences.addons.get(NAME)) is None: """Retrieve the preferences of this addon, if they are available yet.
msg = 'Addon is not installed'
raise RuntimeError(msg)
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 return addon.preferences

View File

@ -34,7 +34,7 @@
import enum import enum
from ..nodeps.utils import blender_type_enum from ..utils import blender_type_enum
from .addon import NAME as ADDON_NAME from .addon import NAME as ADDON_NAME

View File

@ -14,21 +14,6 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
import enum import enum
import functools import functools

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
__all__ = ['operators', 'utils']

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
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,
]

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
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 = []

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
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 = []

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
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 = []

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
from . import pydeps
__all__ = ['pydeps']

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
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 <https://developer.blender.org/docs/release_notes/2.91/python_api/>
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)

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
"""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 (`<package>==<version>`) 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 <https://peps.python.org/pep-0426/#name> for the specification.
Parameters:
deplock: The string formatted like `<package>==<version>`.
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

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
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()

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.

View File

@ -14,21 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# blender_maxwell """Addon preferences, encapsulating the various global modifications that the user may make to how this addon functions."""
# 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 <http://www.gnu.org/licenses/>.
import logging import logging
from pathlib import Path from pathlib import Path
@ -36,11 +22,21 @@ from pathlib import Path
import bpy import bpy
from . import contracts as ct from . import contracts as ct
from . import registration from .utils import logger
from .nodeps.operators import install_deps, uninstall_deps
from .nodeps.utils import pip_process, pydeps, simple_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 # - 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 # 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 ## File Logging
use_log_file: bpy.props.BoolProperty( use_log_file: bpy.props.BoolProperty( # type: ignore
name='Log to File', name='Log to File',
description='Whether to use a file for addon logging', description='Whether to use a file for addon logging',
default=True, default=True,
update=lambda self, _: self.on_addon_logging_changed(), 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', name='File Log Level',
description='Level of addon logging to expose in the file', description='Level of addon logging to expose in the file',
items=[ items=[
@ -159,7 +80,7 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
update=lambda self, _: self.on_addon_logging_changed(), 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', name='Log Path',
description='Path to the Addon Log File', description='Path to the Addon Log File',
subtype='FILE_PATH', subtype='FILE_PATH',
@ -169,105 +90,84 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
@property @property
def log_file_path(self) -> Path: 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)) return Path(bpy.path.abspath(self.bl__log_file_path))
@log_file_path.setter @log_file_path.setter
def log_file_path(self, path: Path) -> None: 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()) 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 # - Events: Properties Changed
#################### ####################
def on_addon_logging_changed( def setup_logger(self, _logger: logging.Logger) -> None:
self, single_logger_to_setup: logging.Logger | None = None """Setup a logger using the settings declared in the addon preferences.
) -> None:
"""Configure one, or all, active addon logger(s). 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: Parameters:
single_logger_to_setup: When set, only this logger will be setup. single_logger_to_setup: When set, only this logger will be setup.
Otherwise, **all addon loggers will be setup**. Otherwise, **all addon loggers will be setup**.
""" """
if pydeps.DEPS_OK: log.info('Reconfiguring Loggers')
with pydeps.importable_addon_deps(self.pydeps_path): for _logger in logger.all_addon_loggers():
from blender_maxwell.utils import logger self.setup_logger(_logger)
else:
logger = simple_logger
# Retrieve Configured Log Levels log.info('Loggers Reconfigured')
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?
#################### ####################
# - UI # - UI
#################### ####################
def draw(self, _: bpy.types.Context) -> None: 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 layout = self.layout
num_pydeps_issues = len(pydeps.DEPS_ISSUES)
#################### ####################
# - Logging # - Logging
@ -301,75 +201,6 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
row.enabled = self.use_log_file row.enabled = self.use_log_file
row.prop(self, 'log_level_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 # - Blender Registration

View File

@ -14,22 +14,6 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# 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 <http://www.gnu.org/licenses/>.
"""Manages the registration of Blender classes, including delayed registrations that require access to Python dependencies. """Manages the registration of Blender classes, including delayed registrations that require access to Python dependencies.
Attributes: Attributes:
@ -40,16 +24,12 @@ Attributes:
_REGISTERED_HOTKEYS: Currently registered Blender keymap items. _REGISTERED_HOTKEYS: Currently registered Blender keymap items.
""" """
import enum
import typing as typ
from pathlib import Path
import bpy import bpy
from . import contracts as ct 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 # - Globals
@ -59,16 +39,6 @@ _ADDON_KEYMAP: bpy.types.KeyMap | None = None
_REGISTERED_HOTKEYS: list[ct.BLKeymapItem] = [] _REGISTERED_HOTKEYS: list[ct.BLKeymapItem] = []
####################
# - Delayed Registration
####################
class BLRegisterEvent(enum.StrEnum):
DepsSatisfied = enum.auto()
DELAYED_REGISTRATIONS: dict[BLRegisterEvent, typ.Callable[[Path], None]] = {}
#################### ####################
# - Class Registration # - Class Registration
#################### ####################
@ -109,7 +79,7 @@ def unregister_classes() -> None:
#################### ####################
# - Keymap Registration # - Keymap Registration
#################### ####################
def register_hotkeys(hotkey_defs: list[dict]): def register_hotkeys(hotkey_defs: list[dict]) -> None:
"""Registers a list of Blender hotkey definitions. """Registers a list of Blender hotkey definitions.
Parameters: Parameters:
@ -144,7 +114,7 @@ def register_hotkeys(hotkey_defs: list[dict]):
_REGISTERED_HOTKEYS.append(keymap_item) _REGISTERED_HOTKEYS.append(keymap_item)
def unregister_hotkeys(): def unregister_hotkeys() -> None:
"""Unregisters all Blender hotkeys associated with the addon.""" """Unregisters all Blender hotkeys associated with the addon."""
global _ADDON_KEYMAP # noqa: PLW0603 global _ADDON_KEYMAP # noqa: PLW0603
@ -165,59 +135,3 @@ def unregister_hotkeys():
) )
_REGISTERED_HOTKEYS.clear() _REGISTERED_HOTKEYS.clear()
_ADDON_KEYMAP = None _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()

View File

@ -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 <http://www.gnu.org/licenses/>.
"""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))

View File

@ -14,40 +14,27 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# blender_maxwell """A lightweight, `rich`-based approach to logging that is isolated from other extensions that may used the Python stdlib `logging` module."""
# 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 <http://www.gnu.org/licenses/>.
import logging import logging
import typing as typ
from pathlib import Path from pathlib import Path
import rich.console import rich.console
import rich.logging import rich.logging
import rich.traceback import rich.traceback
from blender_maxwell import contracts as ct from blender_maxwell import contracts as ct
from ..nodeps.utils import simple_logger from ..services import init_settings
from ..nodeps.utils.simple_logger import (
LOG_LEVEL_MAP, # noqa: F401 LogLevel: typ.TypeAlias = int
LogLevel,
loggers, # noqa: F401
simple_loggers, # noqa: F401 ####################
update_all_loggers, # noqa: F401 # - Configuration
update_logger, # noqa: F401 ####################
) STREAM_LOG_FORMAT = 11 * ' ' + '%(levelname)-8s %(message)s (%(name)s)'
FILE_LOG_FORMAT = STREAM_LOG_FORMAT
OUTPUT_CONSOLE = rich.console.Console( OUTPUT_CONSOLE = rich.console.Console(
color_system='truecolor', color_system='truecolor',
@ -56,13 +43,45 @@ ERROR_CONSOLE = rich.console.Console(
color_system='truecolor', color_system='truecolor',
stderr=True, 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) 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 # - Logging Handlers
#################### ####################
def console_handler(level: LogLevel) -> rich.logging.RichHandler: 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( rich_formatter = logging.Formatter(
'%(message)s', '%(message)s',
datefmt='[%X]', 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: 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 # - Logger Setup
#################### ####################
def get(module_name): def get(module_name: str) -> logging.Logger:
logger = logging.getLogger(module_name) """Retrieve and/or create a logger corresponding to a module name.
# Setup Logger from Addon Preferences Warnings:
ct.addon.prefs().on_addon_logging_changed(single_logger_to_setup=logger) 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))

View File

@ -25,7 +25,6 @@ dependencies = [
"seaborn[stats]>=0.13.*", "seaborn[stats]>=0.13.*",
"frozendict>=2.4.*", "frozendict>=2.4.*",
"pydantic-tensor>=0.2", "pydantic-tensor>=0.2",
"pip>=24.2",
] ]
readme = "README.md" readme = "README.md"
requires-python = "~= 3.11" requires-python = "~= 3.11"
@ -95,10 +94,11 @@ log_console_level = 'warning'
[tool.uv] [tool.uv]
managed = true managed = true
dev-dependencies = [ dev-dependencies = [
"ruff>=0.4.3", "ruff>=0.6.8",
"fake-bpy-module-4-0>=20231118",
"pre-commit>=3.7.0", "pre-commit>=3.7.0",
"commitizen>=3.25.0", "commitizen>=3.25.0",
"fake-bpy-module==20240927",
"pip>=24.2",
## Requires charset-normalizer>=2.1.0 ## Requires charset-normalizer>=2.1.0
# Required by Commitizen # Required by Commitizen
## -> It's okay to have different dev/prod versions in our use case. ## -> It's okay to have different dev/prod versions in our use case.
@ -205,6 +205,25 @@ quote-style = "single"
indent-style = "tab" indent-style = "tab"
docstring-code-format = false 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 # - Tooling: Commits
#################### ####################

View File

@ -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 <http://www.gnu.org/licenses/>.

View File

@ -30,9 +30,20 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
"""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 tomllib
import typing as typ
from pathlib import Path from pathlib import Path
import pydantic as pyd
from rich.console import Console
console = Console()
#################### ####################
# - Basic # - Basic
#################### ####################
@ -40,13 +51,23 @@ PATH_ROOT = Path(__file__).resolve().parent.parent
with (PATH_ROOT / 'pyproject.toml').open('rb') as f: with (PATH_ROOT / 'pyproject.toml').open('rb') as f:
PROJ_SPEC = tomllib.load(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 # - 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('/')) return PATH_ROOT / Path(*p_str.split('/'))
@ -57,18 +78,18 @@ PATH_DEV.mkdir(exist_ok=True)
# Retrieve Build Path # Retrieve Build Path
## Extension ZIP files will be placed here after packaging. ## 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) PATH_BUILD.mkdir(exist_ok=True)
# Retrieve Wheels Path # Retrieve Wheels Path
## Dependency wheels will be downloaded to here, then copied into the extension packages. ## 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) PATH_WHEELS.mkdir(exist_ok=True)
# Retrieve Local Cache Path # Retrieve Local Cache Path
## During development, files such as logs will be placed here instead of in the extension's user 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. ## 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) PATH_LOCAL.mkdir(exist_ok=True)
#################### ####################
@ -79,3 +100,18 @@ PATH_LOCAL.mkdir(exist_ok=True)
PATH_ZIP = PATH_BUILD / ( PATH_ZIP = PATH_BUILD / (
PROJ_SPEC['project']['name'] + '__' + PROJ_SPEC['project']['version'] + '.zip' 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

View File

@ -14,35 +14,26 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# blender_maxwell """Contains tools and procedures that deterministically and reliably package the Blender extension.
# 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 <http://www.gnu.org/licenses/>.
import sys 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 contextlib """
import itertools
import json
import logging import logging
import subprocess
import sys
import tempfile import tempfile
import typing as typ import typing as typ
import zipfile import zipfile
from pathlib import Path from pathlib import Path
import itertools
import subprocess
import tomli_w
import info import info
import rich
import rich.progress
import rich.prompt
import tomli_w
LogLevel: typ.TypeAlias = int 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://docs.blender.org/manual/en/4.2/extensions/getting_started.html
# See https://packaging.python.org/en/latest/guides/writing-pyproject-toml # See https://packaging.python.org/en/latest/guides/writing-pyproject-toml
## TODO: More validation and such.
_FIRST_MAINTAINER = info.PROJ_SPEC['project']['maintainers'][0] _FIRST_MAINTAINER = info.PROJ_SPEC['project']['maintainers'][0]
_SPDX_LICENSE_NAME = info.PROJ_SPEC['project']['license']['text'] _SPDX_LICENSE_NAME = info.PROJ_SPEC['project']['license']['text']
## TODO: pydantic model w/validation
BL_EXT_MANIFEST = { BL_EXT_MANIFEST = {
'schema_version': BL_EXT__SCHEMA_VERSION, 'schema_version': BL_EXT__SCHEMA_VERSION,
# Basics # Basics
@ -95,13 +86,19 @@ BL_EXT_MANIFEST = {
#################### ####################
# - Generate Init Settings # - 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] profile_settings = info.PROJ_SPEC['tool']['bl_ext']['profiles'][profile]
if profile_settings['use_path_local']: base_path = info.PATH_LOCAL if profile_settings['use_path_local'] else Path('USER')
base_path = info.PATH_LOCAL
else:
base_path = Path('{USER}')
log_levels = { log_levels = {
None: logging.NOTSET, None: logging.NOTSET,
@ -112,32 +109,54 @@ def generate_init_settings_dict(profile: str) -> dict:
'critical': logging.CRITICAL, 'critical': logging.CRITICAL,
} }
return { return info.InitSettings(
# File Logging use_log_file=profile_settings['use_log_file'],
'use_log_file': profile_settings['use_log_file'], log_file_path=base_path
'log_file_path': str(base_path / profile_settings['log_file_path']), / info.normalize_path(profile_settings['log_file_path']),
'log_file_level': log_levels[profile_settings['log_file_level']], log_file_level=log_levels[profile_settings['log_file_level']],
# Console Logging use_log_console=profile_settings['use_log_console'],
'use_log_console': profile_settings['use_log_console'], log_console_level=log_levels[profile_settings['log_console_level']],
'log_console_level': log_levels[profile_settings['log_console_level']], )
}
#################### ####################
# - Wheel Downloader # - 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: 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) f_reqlock.write(reqlock_str)
reqlock_path = Path(f_reqlock.name) 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'][ for platform, pypi_platform_tags in info.PROJ_SPEC['tool']['bl_ext'][
'platforms' 'platforms'
].items(): ].items():
print(f'[{platform}] Downloading Wheels...') info.console.rule(f'[bold] Downloading Wheels for {platform}')
print()
print()
platform_constraints = list( platform_constraints = list(
itertools.chain.from_iterable( itertools.chain.from_iterable(
[ [
@ -146,42 +165,51 @@ def download_wheels() -> dict:
] ]
) )
) )
subprocess.check_call( cmd = [
[ sys.executable,
sys.executable, '-m',
'-m', 'pip',
'pip', 'download',
'download', '--requirement',
'--requirement', str(reqlock_path),
str(reqlock_path), '--dest',
'--dest', str(info.PATH_WHEELS),
str(info.PATH_WHEELS), '--require-hashes',
'--require-hashes', '--only-binary',
'--only-binary', ':all:',
':all:', '--python-version',
'--python-version', info.REQ_PYTHON_VERSION,
info.REQ_PYTHON_VERSION, *platform_constraints,
] ]
+ platform_constraints
) progress = rich.progress.Progress()
print() 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 # - Pack Extension to ZIP
#################### ####################
def pack_bl_extension( # noqa: PLR0913 def pack_bl_extension(
profile: str, profile: str,
replace_if_exists: bool = False, replace_if_exists: bool = False,
) -> typ.Iterator[Path]: ) -> None:
"""Context manager exposing a folder as a (temporary) zip file. """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: Parameters:
path_addon_pkg: Path to the folder containing __init__.py of the Blender addon. profile: Identifier matching `pyproject.toml`, which select a predefined set of init settings.
path_addon_zip: Path to the Addon ZIP to generate. replace_if_exists: Replace the zip file if it already exists.
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.
""" """
# Delete Existing ZIP (maybe) # Delete Existing ZIP (maybe)
if info.PATH_ZIP.is_file(): if info.PATH_ZIP.is_file():
@ -190,44 +218,77 @@ def pack_bl_extension( # noqa: PLR0913
raise ValueError(msg) raise ValueError(msg)
info.PATH_ZIP.unlink() 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 # 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: with zipfile.ZipFile(info.PATH_ZIP, 'w', zipfile.ZIP_DEFLATED) as f_zip:
# Write Blender Extension Manifest # Write Blender Extension Manifest
print('Writing Blender Extension Manifest...') with info.console.status('Writing Extension Manifest...'):
f_zip.writestr(BL_EXT__MANIFEST_FILENAME, tomli_w.dumps(BL_EXT_MANIFEST)) f_zip.writestr(BL_EXT__MANIFEST_FILENAME, tomli_w.dumps(BL_EXT_MANIFEST))
info.console.log('Wrote Extension Manifest.')
# Write Init Settings # Write Init Settings
print('Writing Init Settings...') with info.console.status('Writing Init Settings...'):
f_zip.writestr( f_zip.writestr(
info.PROJ_SPEC['tool']['bl_ext']['packaging']['init_settings_filename'], info.PROJ_SPEC['tool']['bl_ext']['packaging']['init_settings_filename'],
tomli_w.dumps(init_settings), tomli_w.dumps(json.loads(init_settings.model_dump_json())),
) )
info.console.log('Wrote Init Settings.')
# Install Addon Files @ /* # Install Addon Files @ /*
print('Writing Addon Files Settings...') with info.console.status('Writing Addon Files...'):
for file_to_zip in info.PATH_PKG.rglob('*'): for file_to_zip in info.PATH_PKG.rglob('*'):
f_zip.write(file_to_zip, file_to_zip.relative_to(info.PATH_PKG.parent)) f_zip.write(file_to_zip, file_to_zip.relative_to(info.PATH_PKG.parent))
info.console.log('Wrote Addon Files.')
# Install Wheels @ /wheels/* # Install Wheels @ /wheels/*
print('Writing Wheels...') # with info.console.status('Writing Wheels...'):
for wheel_to_zip in info.PATH_WHEELS.rglob('*'): # for wheel_to_zip in info.PATH_WHEELS.rglob('*'):
f_zip.write(wheel_to_zip, Path('wheels') / wheel_to_zip.name) # 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 # 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 # - Run Blender w/Clean Addon Reinstall
#################### ####################
if __name__ == '__main__': if __name__ == '__main__':
if not list(info.PATH_WHEELS.iterdir()) or '--download-wheels' in sys.argv:
download_wheels()
profile = sys.argv[1] profile = sys.argv[1]
if sys.argv[1] in ['dev', 'release', 'release-debug']: 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) pack_bl_extension(profile)
else: else:
msg = f'Packaging profile "{profile}" is invalid. Refer to source of pack.py for more information' msg = f'Packaging profile "{profile}" is invalid. Refer to source of pack.py for more information'

56
uv.lock
View File

@ -269,12 +269,12 @@ wheels = [
] ]
[[package]] [[package]]
name = "fake-bpy-module-4-0" name = "fake-bpy-module"
version = "20240604" version = "20240927"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -835,7 +835,6 @@ dependencies = [
{ name = "msgspec", extra = ["toml"] }, { name = "msgspec", extra = ["toml"] },
{ name = "networkx" }, { name = "networkx" },
{ name = "numba" }, { name = "numba" },
{ name = "pip" },
{ name = "polars" }, { name = "polars" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-tensor" }, { name = "pydantic-tensor" },
@ -851,7 +850,8 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "commitizen" }, { name = "commitizen" },
{ name = "fake-bpy-module-4-0" }, { name = "fake-bpy-module" },
{ name = "pip" },
{ name = "pre-commit" }, { name = "pre-commit" },
{ name = "ruff" }, { name = "ruff" },
] ]
@ -864,7 +864,6 @@ requires-dist = [
{ name = "msgspec", extras = ["toml"], specifier = "==0.18.*" }, { name = "msgspec", extras = ["toml"], specifier = "==0.18.*" },
{ name = "networkx", specifier = "==3.3.*" }, { name = "networkx", specifier = "==3.3.*" },
{ name = "numba", specifier = "==0.60.*" }, { name = "numba", specifier = "==0.60.*" },
{ name = "pip", specifier = ">=24.2" },
{ name = "polars", specifier = "==1.8.*" }, { name = "polars", specifier = "==1.8.*" },
{ name = "pydantic", specifier = "==2.9.*" }, { name = "pydantic", specifier = "==2.9.*" },
{ name = "pydantic-tensor", specifier = ">=0.2" }, { name = "pydantic-tensor", specifier = ">=0.2" },
@ -880,9 +879,10 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "commitizen", specifier = ">=3.25.0" }, { 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 = "pre-commit", specifier = ">=3.7.0" },
{ name = "ruff", specifier = ">=0.4.3" }, { name = "ruff", specifier = ">=0.6.8" },
] ]
[[package]] [[package]]
@ -1287,27 +1287,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.6.7" version = "0.6.8"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/db/07/42ee57e8b76ca585297a663a552b4f6d6a99372ca47fdc2276ef72cc0f2f/ruff-0.6.8-py3-none-linux_armv6l.whl", hash = "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2", size = 10404327 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/68/62/f2c1031e2fb7b94f9bf0603744e73db4ef90081b0eb1b9639a6feefd52ea/ruff-0.6.7-py3-none-win32.whl", hash = "sha256:a939ca435b49f6966a7dd64b765c9df16f1faed0ca3b6f16acdf7731969deb35", size = 8448033 }, { 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/97/80/193d1604a3f7d75eb1b2a7ce6bf0fdbdbc136889a65caacea6ffb29501b1/ruff-0.6.7-py3-none-win_amd64.whl", hash = "sha256:590445eec5653f36248584579c06252ad2e110a5d1f32db5420de35fb0e1c977", size = 9273543 }, { 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/8e/a8/4abb5a9f58f51e4b1ea386be5ab2e547035bc1ee57200d1eca2f8909a33e/ruff-0.6.7-py3-none-win_arm64.whl", hash = "sha256:b28f0d5e2f771c1fe3c7a45d3f53916fc74a480698c4b5731f0bea61e52137c8", size = 8618044 }, { url = "https://files.pythonhosted.org/packages/d9/bd/a8b0c64945a92eaeeb8d0283f27a726a776a1c9d12734d990c5fc7a1278c/ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc", size = 8669595 },
] ]
[[package]] [[package]]