refactor: Combing through spaghetti
parent
e6ada5028d
commit
ad548b8f03
|
@ -14,8 +14,7 @@
|
|||
# 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
|
||||
# oscillode Copyright (C) 2024 oscillode Project Contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -30,205 +29,76 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <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`
|
||||
`bl_info` declares information about the addon to Blender.
|
||||
from . import assets, node_trees, operators, preferences, registration
|
||||
from . import contracts as ct
|
||||
from .utils import logger
|
||||
|
||||
However, it is not _dynamically_ read: Blender traverses it using `ast.parse`.
|
||||
This makes it difficult to synchronize `bl_info` with the project's `pyproject.toml`.
|
||||
As a workaround, **the addon zip-packer will replace `bl_info` entries**.
|
||||
|
||||
The following `bl_info` entries are currently replaced when the ZIP is built:
|
||||
|
||||
- `description`: To match the description in `pyproject.toml`.
|
||||
- `version`: To match the version in `pyproject.toml`.
|
||||
|
||||
For more information, see `scripts.pack.BL_INFO_REPLACEMENTS`.
|
||||
|
||||
**NOTE**: The find/replace procedure is "dumb" (aka. no regex, no `ast` traversal, etc.).
|
||||
|
||||
This is surprisingly robust, so long as use of the deterministic code-formatter `ruff fmt` is enforced.
|
||||
|
||||
Still. Be careful around `bl_info`.
|
||||
|
||||
Attributes:
|
||||
bl_info: Information about the addon declared to Blender.
|
||||
BL_REGISTER_BEFORE_DEPS: Blender classes to register before dependencies are verified as installed.
|
||||
BL_HOTKEYS: Blender keymap item defs to register before dependencies are verified as installed.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .nodeps.utils import simple_logger
|
||||
|
||||
# Initialize Logging Defaults
|
||||
## Initial logger settings (ex. log level) must be set somehow.
|
||||
## The Addon ZIP-packer makes this decision, and packs it into files.
|
||||
## AddonPreferences will, once loaded, override this.
|
||||
_PATH_ADDON_ROOT = Path(__file__).resolve().parent
|
||||
_PATH_BOOTSTRAP_LOG_LEVEL = _PATH_ADDON_ROOT / '.bootstrap_log_level'
|
||||
with _PATH_BOOTSTRAP_LOG_LEVEL.open('r') as f:
|
||||
_BOOTSTRAP_LOG_LEVEL = int(f.read().strip())
|
||||
|
||||
simple_logger.init_simple_logger_defaults(console_level=_BOOTSTRAP_LOG_LEVEL)
|
||||
|
||||
# Import Statements
|
||||
import bpy # noqa: E402
|
||||
|
||||
from . import contracts as ct # noqa: E402
|
||||
from . import preferences, registration # noqa: E402
|
||||
from .nodeps import operators as nodeps_operators # noqa: E402
|
||||
from .nodeps.utils import pydeps # noqa: E402
|
||||
|
||||
log = simple_logger.get(__name__)
|
||||
log = logger.get(__name__)
|
||||
|
||||
|
||||
####################
|
||||
# - Load and Register Addon
|
||||
####################
|
||||
BL_REGISTER_BEFORE_DEPS: list[ct.BLClass] = [
|
||||
*nodeps_operators.BL_REGISTER,
|
||||
*preferences.BL_REGISTER,
|
||||
]
|
||||
|
||||
## 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 [
|
||||
BL_REGISTER: list[ct.BLClass] = [
|
||||
*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 [
|
||||
BL_HOTKEYS: list[ct.KeymapItemDef] = [
|
||||
*operators.BL_HOTKEYS,
|
||||
*assets.BL_HOTKEYS,
|
||||
]
|
||||
|
||||
## TODO: BL_HANDLERS and BL_SOCKET_DEFS
|
||||
|
||||
|
||||
####################
|
||||
# - Registration
|
||||
####################
|
||||
@bpy.app.handlers.persistent
|
||||
def manage_pydeps(*_):
|
||||
# ct.addon.operator(
|
||||
# ct.OperatorType.ManagePyDeps,
|
||||
# 'INVOKE_DEFAULT',
|
||||
# path_addon_pydeps='',
|
||||
# path_addon_reqs='',
|
||||
# )
|
||||
log.debug('PyDeps: Analyzing Post-File Load')
|
||||
ct.addon.prefs().on_addon_pydeps_changed(show_popup_if_deps_invalid=True)
|
||||
|
||||
|
||||
def register() -> None:
|
||||
"""Implements a multi-stage addon registration, which accounts for Python dependency management.
|
||||
|
||||
# Multi-Stage Registration
|
||||
The trouble is that many classes in our addon might require Python dependencies.
|
||||
|
||||
## Stage 1: Barebones Addon
|
||||
Many classes in our addon might require Python dependencies.
|
||||
However, they may not yet be installed.
|
||||
|
||||
To solve this bootstrapping problem in a streamlined manner, we only **guarantee** the registration of a few key classes, including:
|
||||
|
||||
- `AddonPreferences`: The addon preferences provide an interface for the user to fix Python dependency problems, thereby triggering subsequent stages.
|
||||
- `InstallPyDeps`: An operator that installs missing Python dependencies, using Blender's embeded `pip`.
|
||||
- `UninstallPyDeps`: An operator that uninstalls Python dependencies.
|
||||
|
||||
**These classes provide just enough interface to help the user install the missing Python dependencies**.
|
||||
|
||||
## Stage 2: Declare Delayed Registration
|
||||
We may not be able to register any classes that rely on Python dependencies.
|
||||
However, we can use `registration.delay_registration()` to **delay the registration until it is determined that the Python dependencies are satisfied**.`
|
||||
|
||||
For now, we just pass a callback that will import + return a list of classes to register (`load_main_blclasses()`) when the time comes.
|
||||
|
||||
## Stage 3: Trigger "PyDeps Changed"
|
||||
The addon preferences is responsible for storing (and exposing to the user) the path to the Python dependencies.
|
||||
|
||||
Thus, the addon preferences method `on_addon_pydeps_changed()` has the responsibility for checking when the dependencies are valid, and running the delayed registrations (and any other delayed setup) in response.
|
||||
In general, `on_addon_pydeps_changed()` runs whenever the PyDeps path is changed, but it can also be run manually.
|
||||
|
||||
As the last part of this process, that's exactly what `register()` does: Runs `on_addon_pydeps_changed()` manually.
|
||||
Depending on the addon preferences (which persist), one of two things can happen:
|
||||
|
||||
1. **Deps Satisfied**: The addon will load without issue: The just-declared "delayed registrations" will run immediately, and all is well.
|
||||
2. **Deps Not Satisfied**: The user must take action to fix the conflicts due to Python dependencies, before the addon can load. **A popup will show to help the user do so.
|
||||
|
||||
"""Implements addon registration in a way that respects the availability of addon preferences and loggers.
|
||||
|
||||
Notes:
|
||||
Called by Blender when enabling the addon.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If addon preferences fail to register.
|
||||
"""
|
||||
log.info('Commencing Registration of Addon: %s', ct.addon.NAME)
|
||||
bpy.app.handlers.load_post.append(manage_pydeps)
|
||||
log.info('Registering Addon Preferences: %s', ct.addon.NAME)
|
||||
registration.register_classes(preferences.BL_REGISTER)
|
||||
|
||||
# Register Barebones Addon
|
||||
## Contains all no-dependency BLClasses:
|
||||
## - Contains AddonPreferences.
|
||||
## Contains all BLClasses from 'nodeps'.
|
||||
registration.register_classes(BL_REGISTER_BEFORE_DEPS)
|
||||
registration.register_hotkeys(BL_HOTKEYS_BEFORE_DEPS)
|
||||
addon_prefs = ct.addon.prefs()
|
||||
if addon_prefs is not None:
|
||||
# Update Loggers
|
||||
# - This updates existing loggers to use settings defined by preferences.
|
||||
addon_prefs.on_addon_logging_changed()
|
||||
|
||||
# Delay Complete Registration until DEPS_SATISFIED
|
||||
registration.delay_registration_until(
|
||||
registration.BLRegisterEvent.DepsSatisfied,
|
||||
then_register_classes=load_main_blclasses,
|
||||
then_register_hotkeys=load_main_blhotkeys,
|
||||
)
|
||||
log.info('Registering Addon: %s', ct.addon.NAME)
|
||||
|
||||
# Trigger PyDeps Check
|
||||
## Deps ARE OK: Delayed registration will trigger.
|
||||
## Deps NOT OK: User must fix the pydeps, then trigger this method.
|
||||
ct.addon.prefs().on_addon_pydeps_changed()
|
||||
registration.register_classes(BL_REGISTER)
|
||||
registration.register_hotkeys(BL_HOTKEYS)
|
||||
|
||||
log.info('Finished Registration of Addon: %s', ct.addon.NAME)
|
||||
else:
|
||||
msg = 'Addon preferences did not register for addon {ct.addon.NAME} - something is very wrong!'
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def unregister() -> None:
|
||||
"""Unregisters anything that was registered by the addon.
|
||||
|
||||
Notes:
|
||||
Run by Blender when disabling the addon.
|
||||
Run by Blender when disabling and/or uninstalling the addon.
|
||||
|
||||
This doesn't clean `sys.modules`.
|
||||
To fully revert to Blender's state before the addon was in use (especially various import-related caches in the Python process), Blender must be restarted.
|
||||
We rely on the hope that Blender has extension-extension module isolation.
|
||||
"""
|
||||
log.info('Starting %s Unregister', ct.addon.NAME)
|
||||
|
||||
registration.unregister_classes()
|
||||
registration.unregister_hotkeys()
|
||||
registration.clear_delayed_registrations()
|
||||
|
||||
log.info('Finished %s Unregister', ct.addon.NAME)
|
||||
|
|
|
@ -14,105 +14,74 @@
|
|||
# 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/>.
|
||||
"""Basic 'single-source-of-truth' static and dynamic information about this addon.
|
||||
|
||||
Information is mined both from `bpy`, and from the addon's bundled manifest file.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
import typing as typ
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import bpy_restrict_state
|
||||
|
||||
PATH_ADDON_ROOT = Path(__file__).resolve().parent.parent
|
||||
with (PATH_ADDON_ROOT / 'pyproject.toml').open('rb') as f:
|
||||
PROJ_SPEC = tomllib.load(f)
|
||||
## bl_info is filled with PROJ_SPEC when packing the .zip.
|
||||
|
||||
NAME = PROJ_SPEC['project']['name']
|
||||
VERSION = PROJ_SPEC['project']['version']
|
||||
with (PATH_ADDON_ROOT / 'blender_manifest.toml').open('rb') as f:
|
||||
MANIFEST = tomllib.load(f)
|
||||
|
||||
NAME: str = MANIFEST['id']
|
||||
VERSION: str = MANIFEST['version']
|
||||
|
||||
####################
|
||||
# - Assets
|
||||
####################
|
||||
PATH_ASSETS = PATH_ADDON_ROOT / 'assets'
|
||||
|
||||
####################
|
||||
# - PyDeps Info
|
||||
####################
|
||||
PATH_REQS = PATH_ADDON_ROOT / 'requirements.lock'
|
||||
DEFAULT_PATH_DEPS = PATH_ADDON_ROOT / '.addon_dependencies'
|
||||
DEFAULT_PATH_DEPS.mkdir(exist_ok=True)
|
||||
## requirements.lock is written when packing the .zip.
|
||||
## By default, the addon pydeps are kept in the addon dir.
|
||||
|
||||
ORIGINAL_SYS_PATH = sys.path.copy()
|
||||
|
||||
####################
|
||||
# - Local Addon Cache
|
||||
####################
|
||||
DEFAULT_ADDON_CACHE = PATH_ADDON_ROOT / '.addon_cache'
|
||||
DEFAULT_ADDON_CACHE.mkdir(exist_ok=True)
|
||||
|
||||
PIP_INSTALL_LOG = DEFAULT_ADDON_CACHE / 'pip_install.log'
|
||||
PATH_ASSETS: Path = PATH_ADDON_ROOT / 'assets'
|
||||
PATH_CACHE: Path = Path(bpy.utils.extension_path_user(__package__, create=True))
|
||||
|
||||
|
||||
####################
|
||||
# - Dynamic Addon Information
|
||||
####################
|
||||
def is_loading() -> bool:
|
||||
"""Checks whether the addon is currently loading.
|
||||
def operator(
|
||||
name: str, *operator_args: list[typ.Any], **operator_kwargs: dict[str, typ.Any]
|
||||
) -> None:
|
||||
"""Convenienve method to call an operator from this addon.
|
||||
|
||||
While an addon is loading, `bpy.context` is temporarily very limited.
|
||||
For example, operators can't run while the addon is loading.
|
||||
This avoids having to use `bpy.ops.<addon_name>.operator_name`, which isn't generic.
|
||||
|
||||
By checking whether `bpy.context` is limited like this, we can determine whether the addon is currently loading.
|
||||
Only operators from this addon may be called using this method.
|
||||
|
||||
Notes:
|
||||
Since `bpy_restrict_state._RestrictContext` is a very internal thing, this function may be prone to breakage on Blender updates.
|
||||
|
||||
**Keep an eye out**!
|
||||
|
||||
Returns:
|
||||
Whether the addon has been fully loaded, such that `bpy.context` is fully accessible.
|
||||
Raises:
|
||||
ValueError: If an addon from outside this operator is attempted to be used.
|
||||
"""
|
||||
return isinstance(bpy.context, bpy_restrict_state._RestrictContext)
|
||||
|
||||
|
||||
def operator(name: str, *operator_args, **operator_kwargs) -> None:
|
||||
# Parse Operator Name
|
||||
operator_namespace, operator_name = name.split('.')
|
||||
if operator_namespace != NAME:
|
||||
msg = f'Tried to call operator {operator_name}, but addon operators may only use the addon operator namespace "{operator_namespace}.<name>"'
|
||||
raise RuntimeError(msg)
|
||||
raise ValueError(msg)
|
||||
|
||||
# Addon Not Loading: Run Operator
|
||||
if not is_loading():
|
||||
# Run Operator
|
||||
operator = getattr(getattr(bpy.ops, NAME), operator_name)
|
||||
operator(*operator_args, **operator_kwargs)
|
||||
else:
|
||||
msg = f'Tried to call operator "{operator_name}" while addon is loading'
|
||||
raise RuntimeError(msg)
|
||||
## TODO: Can't we constrain 'name' to be an OperatorType somehow?
|
||||
|
||||
|
||||
def prefs() -> bpy.types.AddonPreferences | None:
|
||||
if (addon := bpy.context.preferences.addons.get(NAME)) is None:
|
||||
msg = 'Addon is not installed'
|
||||
raise RuntimeError(msg)
|
||||
"""Retrieve the preferences of this addon, if they are available yet.
|
||||
|
||||
Notes:
|
||||
While registering the addon, one may wish to use the addon preferences.
|
||||
This isn't possible - not even for default values.
|
||||
|
||||
Either a bad idea is at work, or `oscillode.utils.init_settings` should be consulted until the preferences are available.
|
||||
|
||||
Returns:
|
||||
The addon preferences, if the addon is registered and loaded - otherwise None.
|
||||
"""
|
||||
addon = bpy.context.preferences.addons.get(NAME)
|
||||
if addon is None:
|
||||
return None
|
||||
return addon.preferences
|
||||
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
import enum
|
||||
|
||||
from ..nodeps.utils import blender_type_enum
|
||||
from ..utils import blender_type_enum
|
||||
from .addon import NAME as ADDON_NAME
|
||||
|
||||
|
||||
|
|
|
@ -14,21 +14,6 @@
|
|||
# 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 enum
|
||||
import functools
|
||||
|
|
|
@ -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']
|
|
@ -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,
|
||||
]
|
|
@ -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 = []
|
|
@ -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 = []
|
|
@ -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 = []
|
|
@ -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']
|
|
@ -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)
|
|
@ -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
|
|
@ -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()
|
|
@ -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/>.
|
||||
|
|
@ -14,21 +14,7 @@
|
|||
# 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/>.
|
||||
"""Addon preferences, encapsulating the various global modifications that the user may make to how this addon functions."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
@ -36,11 +22,21 @@ from pathlib import Path
|
|||
import bpy
|
||||
|
||||
from . import contracts as ct
|
||||
from . import registration
|
||||
from .nodeps.operators import install_deps, uninstall_deps
|
||||
from .nodeps.utils import pip_process, pydeps, simple_logger
|
||||
from .utils import logger
|
||||
|
||||
log = simple_logger.get(__name__)
|
||||
log = logger.get(__name__)
|
||||
|
||||
|
||||
####################
|
||||
# - Constants
|
||||
####################
|
||||
LOG_LEVEL_MAP: dict[str, logger.LogLevel] = {
|
||||
'DEBUG': logging.DEBUG,
|
||||
'INFO': logging.INFO,
|
||||
'WARNING': logging.WARNING,
|
||||
'ERROR': logging.ERROR,
|
||||
'CRITICAL': logging.CRITICAL,
|
||||
}
|
||||
|
||||
|
||||
####################
|
||||
|
@ -62,90 +58,15 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
|
|||
####################
|
||||
# - Properties
|
||||
####################
|
||||
# Addon Cache Path
|
||||
bl__addon_cache_path: bpy.props.StringProperty(
|
||||
name='Addon Cache Path',
|
||||
description='Path to Addon Cache',
|
||||
subtype='FILE_PATH',
|
||||
default=str(ct.addon.DEFAULT_ADDON_CACHE),
|
||||
update=lambda self, _: self.on_addon_pydeps_changed(),
|
||||
)
|
||||
|
||||
@property
|
||||
def addon_cache_path(self) -> Path:
|
||||
return Path(bpy.path.abspath(self.bl__addon_cache_path))
|
||||
|
||||
@addon_cache_path.setter
|
||||
def addon_cache_path(self, path: Path) -> None:
|
||||
self.bl__addon_cache_path = str(path.resolve())
|
||||
|
||||
# PyDeps Default Path
|
||||
use_default_pydeps_path: bpy.props.BoolProperty(
|
||||
name='Use Default PyDeps Path',
|
||||
description='Whether to use the default PyDeps path',
|
||||
default=True,
|
||||
update=lambda self, context: self.on_addon_pydeps_changed(context),
|
||||
)
|
||||
|
||||
# PyDeps Path
|
||||
bl__pydeps_path: bpy.props.StringProperty(
|
||||
name='Addon PyDeps Path',
|
||||
description='Path to Addon Python Dependencies',
|
||||
subtype='FILE_PATH',
|
||||
default=str(ct.addon.DEFAULT_PATH_DEPS),
|
||||
update=lambda self, _: self.on_addon_pydeps_changed(),
|
||||
)
|
||||
|
||||
cache__backup_pydeps_path: bpy.props.StringProperty(
|
||||
default=str(ct.addon.DEFAULT_PATH_DEPS),
|
||||
)
|
||||
|
||||
@property
|
||||
def pydeps_path(self) -> Path:
|
||||
if self.use_default_pydeps_path:
|
||||
return ct.addon.DEFAULT_PATH_DEPS
|
||||
|
||||
return Path(bpy.path.abspath(self.bl__pydeps_path))
|
||||
|
||||
@pydeps_path.setter
|
||||
def pydeps_path(self, path: Path) -> None:
|
||||
if not self.use_default_pydeps_path:
|
||||
self.bl__pydeps_path = str(path.resolve())
|
||||
else:
|
||||
msg = f'Can\'t set "pydeps_path" to {path} while "use_default_pydeps_path" is "True"'
|
||||
raise ValueError(msg)
|
||||
|
||||
# Logging
|
||||
## Console Logging
|
||||
use_log_console: bpy.props.BoolProperty(
|
||||
name='Log to Console',
|
||||
description='Whether to use the console for addon logging',
|
||||
default=True,
|
||||
update=lambda self, _: self.on_addon_logging_changed(),
|
||||
)
|
||||
log_level_console: bpy.props.EnumProperty(
|
||||
name='Console Log Level',
|
||||
description='Level of addon logging to expose in the console',
|
||||
items=[
|
||||
('DEBUG', 'Debug', 'Debug'),
|
||||
('INFO', 'Info', 'Info'),
|
||||
('WARNING', 'Warning', 'Warning'),
|
||||
('ERROR', 'Error', 'Error'),
|
||||
('CRITICAL', 'Critical', 'Critical'),
|
||||
],
|
||||
default='DEBUG',
|
||||
update=lambda self, _: self.on_addon_logging_changed(),
|
||||
)
|
||||
## TODO: Derive default from BOOTSTRAP_LOG_LEVEL
|
||||
|
||||
## File Logging
|
||||
use_log_file: bpy.props.BoolProperty(
|
||||
use_log_file: bpy.props.BoolProperty( # type: ignore
|
||||
name='Log to File',
|
||||
description='Whether to use a file for addon logging',
|
||||
default=True,
|
||||
update=lambda self, _: self.on_addon_logging_changed(),
|
||||
)
|
||||
log_level_file: bpy.props.EnumProperty(
|
||||
log_level_file: bpy.props.EnumProperty( # type: ignore
|
||||
name='File Log Level',
|
||||
description='Level of addon logging to expose in the file',
|
||||
items=[
|
||||
|
@ -159,7 +80,7 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
|
|||
update=lambda self, _: self.on_addon_logging_changed(),
|
||||
)
|
||||
|
||||
bl__log_file_path: bpy.props.StringProperty(
|
||||
bl__log_file_path: bpy.props.StringProperty( # type: ignore
|
||||
name='Log Path',
|
||||
description='Path to the Addon Log File',
|
||||
subtype='FILE_PATH',
|
||||
|
@ -169,105 +90,84 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
|
|||
|
||||
@property
|
||||
def log_file_path(self) -> Path:
|
||||
"""Retrieve the configured file-logging path as a `pathlib.Path`."""
|
||||
return Path(bpy.path.abspath(self.bl__log_file_path))
|
||||
|
||||
@log_file_path.setter
|
||||
def log_file_path(self, path: Path) -> None:
|
||||
"""Set the configured file-logging path as a `pathlib.Path`."""
|
||||
self.bl__log_file_path = str(path.resolve())
|
||||
|
||||
## Console Logging
|
||||
use_log_console: bpy.props.BoolProperty( # type: ignore
|
||||
name='Log to Console',
|
||||
description='Whether to use the console for addon logging',
|
||||
default=True,
|
||||
update=lambda self, _: self.on_addon_logging_changed(),
|
||||
)
|
||||
log_level_console: bpy.props.EnumProperty( # type: ignore
|
||||
name='Console Log Level',
|
||||
description='Level of addon logging to expose in the console',
|
||||
items=[
|
||||
('DEBUG', 'Debug', 'Debug'),
|
||||
('INFO', 'Info', 'Info'),
|
||||
('WARNING', 'Warning', 'Warning'),
|
||||
('ERROR', 'Error', 'Error'),
|
||||
('CRITICAL', 'Critical', 'Critical'),
|
||||
],
|
||||
default='DEBUG',
|
||||
update=lambda self, _: self.on_addon_logging_changed(),
|
||||
)
|
||||
## TODO: Derive default from INIT_SETTINGS
|
||||
|
||||
####################
|
||||
# - Events: Properties Changed
|
||||
####################
|
||||
def on_addon_logging_changed(
|
||||
self, single_logger_to_setup: logging.Logger | None = None
|
||||
) -> None:
|
||||
"""Configure one, or all, active addon logger(s).
|
||||
def setup_logger(self, _logger: logging.Logger) -> None:
|
||||
"""Setup a logger using the settings declared in the addon preferences.
|
||||
|
||||
Args:
|
||||
_logger: The logger to configure using settings in the addon preferences.
|
||||
"""
|
||||
logger.update_logger(
|
||||
logger.console_handler,
|
||||
logger.file_handler,
|
||||
_logger,
|
||||
file_path=self.log_file_path if self.use_log_file else None,
|
||||
file_level=LOG_LEVEL_MAP[self.log_level_file],
|
||||
console_level=LOG_LEVEL_MAP[self.log_level_console]
|
||||
if self.use_console_level
|
||||
else None,
|
||||
)
|
||||
|
||||
def on_addon_logging_changed(self) -> None:
|
||||
"""Called to reconfigure all loggers to match newly-altered addon preferences.
|
||||
|
||||
This causes ex. changes to desired console log level to immediately be applied, but only the this addon's loggers.
|
||||
|
||||
Parameters:
|
||||
single_logger_to_setup: When set, only this logger will be setup.
|
||||
Otherwise, **all addon loggers will be setup**.
|
||||
"""
|
||||
if pydeps.DEPS_OK:
|
||||
with pydeps.importable_addon_deps(self.pydeps_path):
|
||||
from blender_maxwell.utils import logger
|
||||
else:
|
||||
logger = simple_logger
|
||||
log.info('Reconfiguring Loggers')
|
||||
for _logger in logger.all_addon_loggers():
|
||||
self.setup_logger(_logger)
|
||||
|
||||
# Retrieve Configured Log Levels
|
||||
log_level_console = logger.LOG_LEVEL_MAP[self.log_level_console]
|
||||
log_level_file = logger.LOG_LEVEL_MAP[self.log_level_file]
|
||||
|
||||
log_setup_kwargs = {
|
||||
'console_level': log_level_console if self.use_log_console else None,
|
||||
'file_path': self.log_file_path if self.use_log_file else None,
|
||||
'file_level': log_level_file,
|
||||
}
|
||||
|
||||
# Sync Single Logger / All Loggers
|
||||
if single_logger_to_setup is not None:
|
||||
logger.update_logger(
|
||||
logger.console_handler,
|
||||
logger.file_handler,
|
||||
single_logger_to_setup,
|
||||
**log_setup_kwargs,
|
||||
)
|
||||
else:
|
||||
log.info('Re-Configuring All Loggers')
|
||||
logger.update_all_loggers(
|
||||
logger.console_handler,
|
||||
logger.file_handler,
|
||||
**log_setup_kwargs,
|
||||
)
|
||||
|
||||
def on_addon_pydeps_changed(self, show_popup_if_deps_invalid: bool = False) -> None:
|
||||
"""Checks if the Python dependencies are valid, and runs any delayed setup (inclusing `ct.BLClass` registrations) in response.
|
||||
|
||||
Notes:
|
||||
**The addon does not load until this method allows it**.
|
||||
|
||||
Parameters:
|
||||
show_popup_if_deps_invalid: If True, a failed dependency check will `invoke()` the operator `ct.OperatorType.ManagePyDeps`, which is a popup that guides the user through
|
||||
**NOTE**: Must be called after addon registration.
|
||||
|
||||
Notes:
|
||||
Run by `__init__.py` after registering a barebones addon (including this class), and after queueing a delayed registration.
|
||||
"""
|
||||
if pydeps.check_pydeps(ct.addon.PATH_REQS, self.pydeps_path):
|
||||
# Re-Sync Loggers
|
||||
## We can now upgrade all loggers to the fancier loggers.
|
||||
for _log in simple_logger.simple_loggers():
|
||||
log.debug('Upgrading Logger (%s)', str(_log))
|
||||
self.on_addon_logging_changed(single_logger_to_setup=_log)
|
||||
simple_logger.clear_simple_loggers()
|
||||
|
||||
# Run Registrations Waiting on DEPS_SATISFIED
|
||||
## Since the deps are OK, we can now register the whole addon.
|
||||
if (
|
||||
registration.BLRegisterEvent.DepsSatisfied
|
||||
in registration.DELAYED_REGISTRATIONS
|
||||
):
|
||||
registration.run_delayed_registration(
|
||||
registration.BLRegisterEvent.DepsSatisfied,
|
||||
self.pydeps_path,
|
||||
)
|
||||
|
||||
elif show_popup_if_deps_invalid:
|
||||
ct.addon.operator(
|
||||
ct.OperatorType.ManagePyDeps,
|
||||
'INVOKE_DEFAULT',
|
||||
bl__pydeps_path=str(self.pydeps_path),
|
||||
)
|
||||
## TODO: else:
|
||||
## TODO: Can we 'downgrade' the loggers back to simple loggers?
|
||||
## TODO: Can we undo the delayed registration?
|
||||
## TODO: Do we need the fancy pants sys.modules handling for all this?
|
||||
log.info('Loggers Reconfigured')
|
||||
|
||||
####################
|
||||
# - UI
|
||||
####################
|
||||
def draw(self, _: bpy.types.Context) -> None:
|
||||
"""Draw the addon preferences within its panel in Blender's preferences.
|
||||
|
||||
Notes:
|
||||
Run by Blender when this addon's preferences need to be displayed.
|
||||
|
||||
Parameters:
|
||||
context: The Blender context object.
|
||||
"""
|
||||
layout = self.layout
|
||||
num_pydeps_issues = len(pydeps.DEPS_ISSUES)
|
||||
|
||||
####################
|
||||
# - Logging
|
||||
|
@ -301,75 +201,6 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
|
|||
row.enabled = self.use_log_file
|
||||
row.prop(self, 'log_level_file')
|
||||
|
||||
####################
|
||||
# - Dependencies
|
||||
####################
|
||||
# Box: Dependency Status
|
||||
box = layout.box()
|
||||
row = box.row(align=True)
|
||||
row.alignment = 'CENTER'
|
||||
row.label(text='Python Dependencies')
|
||||
|
||||
## Row: Toggle Default PyDeps Path
|
||||
row = box.row(align=True)
|
||||
row.enabled = not pydeps.DEPS_OK
|
||||
row.prop(
|
||||
self,
|
||||
'use_default_pydeps_path',
|
||||
text='Use Default PyDeps Install Path',
|
||||
toggle=True,
|
||||
)
|
||||
|
||||
## Row: Current PyDeps Path
|
||||
row = box.row(align=True)
|
||||
row.enabled = not pydeps.DEPS_OK and not self.use_default_pydeps_path
|
||||
row.prop(self, 'bl__pydeps_path', text='PyDeps Install Path')
|
||||
|
||||
## Row: More Information Panel
|
||||
col = box.column(align=True)
|
||||
header, panel = col.panel('pydeps_issues', default_closed=True)
|
||||
header.label(text=f'Show Conflicts ({num_pydeps_issues})')
|
||||
if panel is not None:
|
||||
grid = panel.grid_flow()
|
||||
for issue in pydeps.DEPS_ISSUES:
|
||||
grid.label(text=issue)
|
||||
|
||||
## Row: Install
|
||||
row = box.row(align=True)
|
||||
op = row.operator(
|
||||
install_deps.InstallPyDeps.bl_idname,
|
||||
text='Install PyDeps',
|
||||
)
|
||||
op.bl__pydeps_path = str(self.pydeps_path)
|
||||
op.bl__pydeps_reqlock_path = str(ct.addon.PATH_REQS)
|
||||
|
||||
## Row: Uninstall
|
||||
row = box.row(align=True)
|
||||
op = row.operator(
|
||||
uninstall_deps.UninstallPyDeps.bl_idname,
|
||||
text='Uninstall PyDeps',
|
||||
)
|
||||
op.bl__pydeps_path = str(self.pydeps_path)
|
||||
|
||||
## Row: Deps Install Progress
|
||||
row = box.row()
|
||||
num_req_deplocks = len(pydeps.DEPS_REQ_DEPLOCKS)
|
||||
if pydeps.DEPS_OK:
|
||||
row.progress(
|
||||
text=f'{num_req_deplocks}/{num_req_deplocks} Installed',
|
||||
factor=1.0,
|
||||
)
|
||||
elif pip_process.PROGRESS is not None:
|
||||
row.progress(
|
||||
text='/'.join(pip_process.PROGRESS_FRAC) + ' Installed',
|
||||
factor=float(pip_process.PROGRESS),
|
||||
)
|
||||
else:
|
||||
row.progress(
|
||||
text=f'0/{num_req_deplocks} Installed',
|
||||
factor=0.0,
|
||||
)
|
||||
|
||||
|
||||
####################
|
||||
# - Blender Registration
|
||||
|
|
|
@ -14,22 +14,6 @@
|
|||
# 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/>.
|
||||
|
||||
"""Manages the registration of Blender classes, including delayed registrations that require access to Python dependencies.
|
||||
|
||||
Attributes:
|
||||
|
@ -40,16 +24,12 @@ Attributes:
|
|||
_REGISTERED_HOTKEYS: Currently registered Blender keymap items.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import typing as typ
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
from . import contracts as ct
|
||||
from .nodeps.utils import simple_logger
|
||||
from .utils import logger
|
||||
|
||||
log = simple_logger.get(__name__)
|
||||
log = logger.get(__name__)
|
||||
|
||||
####################
|
||||
# - Globals
|
||||
|
@ -59,16 +39,6 @@ _ADDON_KEYMAP: bpy.types.KeyMap | None = None
|
|||
_REGISTERED_HOTKEYS: list[ct.BLKeymapItem] = []
|
||||
|
||||
|
||||
####################
|
||||
# - Delayed Registration
|
||||
####################
|
||||
class BLRegisterEvent(enum.StrEnum):
|
||||
DepsSatisfied = enum.auto()
|
||||
|
||||
|
||||
DELAYED_REGISTRATIONS: dict[BLRegisterEvent, typ.Callable[[Path], None]] = {}
|
||||
|
||||
|
||||
####################
|
||||
# - Class Registration
|
||||
####################
|
||||
|
@ -109,7 +79,7 @@ def unregister_classes() -> None:
|
|||
####################
|
||||
# - Keymap Registration
|
||||
####################
|
||||
def register_hotkeys(hotkey_defs: list[dict]):
|
||||
def register_hotkeys(hotkey_defs: list[dict]) -> None:
|
||||
"""Registers a list of Blender hotkey definitions.
|
||||
|
||||
Parameters:
|
||||
|
@ -144,7 +114,7 @@ def register_hotkeys(hotkey_defs: list[dict]):
|
|||
_REGISTERED_HOTKEYS.append(keymap_item)
|
||||
|
||||
|
||||
def unregister_hotkeys():
|
||||
def unregister_hotkeys() -> None:
|
||||
"""Unregisters all Blender hotkeys associated with the addon."""
|
||||
global _ADDON_KEYMAP # noqa: PLW0603
|
||||
|
||||
|
@ -165,59 +135,3 @@ def unregister_hotkeys():
|
|||
)
|
||||
_REGISTERED_HOTKEYS.clear()
|
||||
_ADDON_KEYMAP = None
|
||||
|
||||
|
||||
####################
|
||||
# - Delayed Registration Semantics
|
||||
####################
|
||||
def delay_registration_until(
|
||||
delayed_reg_key: BLRegisterEvent,
|
||||
then_register_classes: typ.Callable[[Path], list[ct.BLClass]],
|
||||
then_register_hotkeys: typ.Callable[[Path], list[ct.KeymapItemDef]],
|
||||
) -> None:
|
||||
"""Delays the registration of Blender classes that depend on certain Python dependencies, for which neither the location nor validity is yet known.
|
||||
|
||||
The function that registers is stored in the module global `DELAYED_REGISTRATIONS`, indexed by `delayed_reg_key`.
|
||||
Once the PyDeps location and validity is determined, `run_delayed_registration()` can be used as a shorthand for accessing `DELAYED_REGISTRATIONS[delayed_reg_key]`.
|
||||
|
||||
Parameters:
|
||||
delayed_reg_key: The identifier with which to index the registration callback.
|
||||
classes_cb: A function that takes a `sys.path`-compatible path to Python dependencies needed by the Blender classes in question, and returns a list of Blender classes to import.
|
||||
`register_classes()` will be used to actually register the returned Blender classes.
|
||||
hotkey_defs_cb: Similar, except for addon keymap items.
|
||||
|
||||
Returns:
|
||||
A function that takes a `sys.path`-compatible path to the Python dependencies needed to import the given Blender classes.
|
||||
"""
|
||||
if delayed_reg_key in DELAYED_REGISTRATIONS:
|
||||
msg = f'Already delayed a registration with key {delayed_reg_key}'
|
||||
raise ValueError(msg)
|
||||
|
||||
def register_cb(path_pydeps: Path):
|
||||
log.info(
|
||||
'Delayed Registration (key %s) with PyDeps Path: %s',
|
||||
delayed_reg_key,
|
||||
path_pydeps,
|
||||
)
|
||||
register_classes(then_register_classes(path_pydeps))
|
||||
register_hotkeys(then_register_hotkeys(path_pydeps))
|
||||
|
||||
DELAYED_REGISTRATIONS[delayed_reg_key] = register_cb
|
||||
|
||||
|
||||
def run_delayed_registration(
|
||||
delayed_reg_key: BLRegisterEvent, path_pydeps: Path
|
||||
) -> None:
|
||||
"""Run a delayed registration, by using `delayed_reg_key` to lookup the correct path, passing `path_pydeps` to the registration.
|
||||
|
||||
Parameters:
|
||||
delayed_reg_key: The identifier with which to index the registration callback.
|
||||
Must match the parameter with which the delayed registration was first declared.
|
||||
path_pydeps: The `sys.path`-compatible path to the Python dependencies that the classes need to have available in order to register.
|
||||
"""
|
||||
DELAYED_REGISTRATIONS.pop(delayed_reg_key)(path_pydeps)
|
||||
|
||||
|
||||
def clear_delayed_registrations() -> None:
|
||||
"""Dequeue all queued delayed registrations."""
|
||||
DELAYED_REGISTRATIONS.clear()
|
||||
|
|
|
@ -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))
|
|
@ -14,40 +14,27 @@
|
|||
# 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/>.
|
||||
"""A lightweight, `rich`-based approach to logging that is isolated from other extensions that may used the Python stdlib `logging` module."""
|
||||
|
||||
import logging
|
||||
import typing as typ
|
||||
from pathlib import Path
|
||||
|
||||
import rich.console
|
||||
import rich.logging
|
||||
import rich.traceback
|
||||
|
||||
from blender_maxwell import contracts as ct
|
||||
|
||||
from ..nodeps.utils import simple_logger
|
||||
from ..nodeps.utils.simple_logger import (
|
||||
LOG_LEVEL_MAP, # noqa: F401
|
||||
LogLevel,
|
||||
loggers, # noqa: F401
|
||||
simple_loggers, # noqa: F401
|
||||
update_all_loggers, # noqa: F401
|
||||
update_logger, # noqa: F401
|
||||
)
|
||||
from ..services import init_settings
|
||||
|
||||
LogLevel: typ.TypeAlias = int
|
||||
|
||||
|
||||
####################
|
||||
# - Configuration
|
||||
####################
|
||||
STREAM_LOG_FORMAT = 11 * ' ' + '%(levelname)-8s %(message)s (%(name)s)'
|
||||
FILE_LOG_FORMAT = STREAM_LOG_FORMAT
|
||||
|
||||
OUTPUT_CONSOLE = rich.console.Console(
|
||||
color_system='truecolor',
|
||||
|
@ -56,13 +43,45 @@ ERROR_CONSOLE = rich.console.Console(
|
|||
color_system='truecolor',
|
||||
stderr=True,
|
||||
)
|
||||
|
||||
ADDON_LOGGER_NAME = f'blext-{ct.addon.NAME}'
|
||||
ADDON_LOGGER: logging.Logger = logging.getLogger(ADDON_LOGGER_NAME)
|
||||
|
||||
rich.traceback.install(show_locals=True, console=ERROR_CONSOLE)
|
||||
|
||||
|
||||
####################
|
||||
# - Logger Access
|
||||
####################
|
||||
def all_addon_loggers() -> set[logging.Logger]:
|
||||
"""Retrieve all loggers currently declared by this addon.
|
||||
|
||||
These loggers are all children of `ADDON_LOGGER`, essentially.
|
||||
This allows for name-isolation from other Blender extensions, as well as easy cleanup.
|
||||
|
||||
Returns:
|
||||
Set of all loggers declared by this addon.
|
||||
"""
|
||||
return {
|
||||
logging.getLogger(name)
|
||||
for name in logging.root.manager.loggerDict
|
||||
if name.startswith(ADDON_LOGGER_NAME)
|
||||
}
|
||||
## TODO: Python 3.12 has a .getChildren() method that also returns sets.
|
||||
|
||||
|
||||
####################
|
||||
# - Logging Handlers
|
||||
####################
|
||||
def console_handler(level: LogLevel) -> rich.logging.RichHandler:
|
||||
"""A logging handler that prints messages to the console.
|
||||
|
||||
Parameters:
|
||||
level: The log levels (debug, info, etc.) to print.
|
||||
|
||||
Returns:
|
||||
The logging handler, which can be added to a logger.
|
||||
"""
|
||||
rich_formatter = logging.Formatter(
|
||||
'%(message)s',
|
||||
datefmt='[%X]',
|
||||
|
@ -77,16 +96,117 @@ def console_handler(level: LogLevel) -> rich.logging.RichHandler:
|
|||
|
||||
|
||||
def file_handler(path_log_file: Path, level: LogLevel) -> rich.logging.RichHandler:
|
||||
return simple_logger.file_handler(path_log_file, level)
|
||||
"""A logging handler that prints messages to a file.
|
||||
|
||||
Parameters:
|
||||
path_log_file: The path to the log file.
|
||||
level: The log levels (debug, info, etc.) to append to the file.
|
||||
|
||||
Returns:
|
||||
The logging handler, which can be added to a logger.
|
||||
"""
|
||||
file_formatter = logging.Formatter(FILE_LOG_FORMAT)
|
||||
file_handler = logging.FileHandler(path_log_file)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
file_handler.setLevel(level)
|
||||
return file_handler
|
||||
|
||||
|
||||
####################
|
||||
# - Logger Setup
|
||||
####################
|
||||
def get(module_name):
|
||||
logger = logging.getLogger(module_name)
|
||||
def get(module_name: str) -> logging.Logger:
|
||||
"""Retrieve and/or create a logger corresponding to a module name.
|
||||
|
||||
# Setup Logger from Addon Preferences
|
||||
ct.addon.prefs().on_addon_logging_changed(single_logger_to_setup=logger)
|
||||
Warnings:
|
||||
MUST be used as `logger.get(__name__)`.
|
||||
|
||||
return logger
|
||||
Parameters:
|
||||
module_name: The `__name__` of the module to return a logger for.
|
||||
"""
|
||||
log = ADDON_LOGGER.getChild(module_name)
|
||||
|
||||
# Setup Logger from Init Settings or Addon Preferences
|
||||
## - We prefer addon preferences, but they may not be setup yet.
|
||||
## - Once setup, the preferences may decide to re-configure all the loggers.
|
||||
addon_prefs = ct.addon.prefs()
|
||||
if addon_prefs is None:
|
||||
use_log_file = init_settings.INIT_SETTINGS['use_log_file']
|
||||
log_file_path = init_settings.INIT_SETTINGS['log_file_path']
|
||||
log_file_level = init_settings.INIT_SETTINGS['log_file_level']
|
||||
use_log_console = init_settings.INIT_SETTINGS['use_log_console']
|
||||
log_console_level = init_settings.INIT_SETTINGS['log_console_level']
|
||||
|
||||
update_logger(
|
||||
console_handler,
|
||||
file_handler,
|
||||
log,
|
||||
file_path=log_file_path if use_log_file else None,
|
||||
file_level=log_file_level,
|
||||
console_level=log_console_level if use_log_console else None,
|
||||
)
|
||||
else:
|
||||
addon_prefs.setup_logger(log)
|
||||
|
||||
return log
|
||||
|
||||
|
||||
####################
|
||||
# - Logger Update
|
||||
####################
|
||||
def _init_logger(logger: logging.Logger) -> None:
|
||||
"""Prepare a logger for handlers to be added, ensuring normalized semantics for all loggers.
|
||||
|
||||
- Messages should not propagate to the root logger, causing double-messages.
|
||||
- Mesages should not filter by level; this is the job of the handlers.
|
||||
- No handlers must be set.
|
||||
|
||||
Args:
|
||||
logger: The logger to prepare.
|
||||
"""
|
||||
# DO NOT Propagate to Root Logger
|
||||
## - This looks like 'double messages'
|
||||
## - See SO/6729268/log-messages-appearing-twice-with-python-logging
|
||||
logger.propagate = False
|
||||
|
||||
# Let All Messages Through
|
||||
## - The individual handlers perform appropriate filtering.
|
||||
logger.setLevel(logging.NOTSET)
|
||||
|
||||
if logger.handlers:
|
||||
logger.handlers.clear()
|
||||
|
||||
|
||||
def update_logger(
|
||||
cb_console_handler: typ.Callable[[LogLevel], logging.Handler],
|
||||
cb_file_handler: typ.Callable[[Path, LogLevel], logging.Handler],
|
||||
logger: logging.Logger,
|
||||
console_level: LogLevel | None,
|
||||
file_path: Path | None,
|
||||
file_level: LogLevel,
|
||||
) -> None:
|
||||
"""Configures a single logger with given console and file handlers, individualizing the log level that triggers it.
|
||||
|
||||
This is a lower-level function - generally, modules that want to use a well-configured logger will use the `get()` function, which retrieves the parameters for this function from the addon preferences.
|
||||
This function is used by the higher-level log setup.
|
||||
|
||||
Parameters:
|
||||
cb_console_handler: A function that takes a log level threshold (inclusive), and returns a logging handler to a console-printer.
|
||||
cb_file_handler: A function that takes a log level threshold (inclusive), and returns a logging handler to a file-printer.
|
||||
logger: The logger to configure.
|
||||
console_level: The log level threshold to print to the console.
|
||||
None deactivates file logging.
|
||||
path_log_file: The path to the log file.
|
||||
None deactivates file logging.
|
||||
file_level: The log level threshold to print to the log file.
|
||||
"""
|
||||
# Initialize Logger
|
||||
_init_logger(logger)
|
||||
|
||||
# Add Console Logging Handler
|
||||
if console_level is not None:
|
||||
logger.addHandler(cb_console_handler(console_level))
|
||||
|
||||
# Add File Logging Handler
|
||||
if file_path is not None:
|
||||
logger.addHandler(cb_file_handler(file_path, file_level))
|
||||
|
|
|
@ -25,7 +25,6 @@ dependencies = [
|
|||
"seaborn[stats]>=0.13.*",
|
||||
"frozendict>=2.4.*",
|
||||
"pydantic-tensor>=0.2",
|
||||
"pip>=24.2",
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = "~= 3.11"
|
||||
|
@ -95,10 +94,11 @@ log_console_level = 'warning'
|
|||
[tool.uv]
|
||||
managed = true
|
||||
dev-dependencies = [
|
||||
"ruff>=0.4.3",
|
||||
"fake-bpy-module-4-0>=20231118",
|
||||
"ruff>=0.6.8",
|
||||
"pre-commit>=3.7.0",
|
||||
"commitizen>=3.25.0",
|
||||
"fake-bpy-module==20240927",
|
||||
"pip>=24.2",
|
||||
## Requires charset-normalizer>=2.1.0
|
||||
# Required by Commitizen
|
||||
## -> It's okay to have different dev/prod versions in our use case.
|
||||
|
@ -205,6 +205,25 @@ quote-style = "single"
|
|||
indent-style = "tab"
|
||||
docstring-code-format = false
|
||||
|
||||
####################
|
||||
# - Tooling: MyPy
|
||||
####################
|
||||
[tool.mypy]
|
||||
python_version = '3.11'
|
||||
ignore_missing_imports = true
|
||||
strict_optional = true
|
||||
disallow_subclassing_any = false
|
||||
disallow_any_generics = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_return_any = true
|
||||
|
||||
|
||||
####################
|
||||
# - Tooling: Commits
|
||||
####################
|
||||
|
|
|
@ -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/>.
|
||||
|
|
@ -30,9 +30,20 @@
|
|||
# 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/>.
|
||||
|
||||
"""Informational constants and variables that ease the implementation of scripts external to oscillode.
|
||||
|
||||
In general, user-relevant information available here should be mined from `pyproject.toml`, unless it is purely structural.
|
||||
"""
|
||||
|
||||
import tomllib
|
||||
import typing as typ
|
||||
from pathlib import Path
|
||||
|
||||
import pydantic as pyd
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
####################
|
||||
# - Basic
|
||||
####################
|
||||
|
@ -40,13 +51,23 @@ PATH_ROOT = Path(__file__).resolve().parent.parent
|
|||
with (PATH_ROOT / 'pyproject.toml').open('rb') as f:
|
||||
PROJ_SPEC = tomllib.load(f)
|
||||
|
||||
REQ_PYTHON_VERSION = PROJ_SPEC['project']['requires-python'].replace('~= ', '')
|
||||
REQ_PYTHON_VERSION: str = PROJ_SPEC['project']['requires-python'].replace('~= ', '')
|
||||
|
||||
|
||||
####################
|
||||
# - Paths
|
||||
####################
|
||||
def proc_path(p_str: str) -> Path:
|
||||
def normalize_path(p_str: str) -> Path:
|
||||
"""Convert a project-root-relative '/'-delimited string path to an absolute path.
|
||||
|
||||
This allows for cross-platform path specification, while retaining normalized use of '/' in configuration.
|
||||
|
||||
Args:
|
||||
p_str: The '/'-delimited string denoting a path relative to the project root.
|
||||
|
||||
Returns:
|
||||
The full absolute path to use when ex. packaging.
|
||||
"""
|
||||
return PATH_ROOT / Path(*p_str.split('/'))
|
||||
|
||||
|
||||
|
@ -57,18 +78,18 @@ PATH_DEV.mkdir(exist_ok=True)
|
|||
|
||||
# Retrieve Build Path
|
||||
## Extension ZIP files will be placed here after packaging.
|
||||
PATH_BUILD = proc_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_builds'])
|
||||
PATH_BUILD = normalize_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_builds'])
|
||||
PATH_BUILD.mkdir(exist_ok=True)
|
||||
|
||||
# Retrieve Wheels Path
|
||||
## Dependency wheels will be downloaded to here, then copied into the extension packages.
|
||||
PATH_WHEELS = proc_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_wheels'])
|
||||
PATH_WHEELS = normalize_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_wheels'])
|
||||
PATH_WHEELS.mkdir(exist_ok=True)
|
||||
|
||||
# Retrieve Local Cache Path
|
||||
## During development, files such as logs will be placed here instead of in the extension's user path.
|
||||
## This is essentially a debugging convenience.
|
||||
PATH_LOCAL = proc_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_local'])
|
||||
PATH_LOCAL = normalize_path(PROJ_SPEC['tool']['bl_ext']['packaging']['path_local'])
|
||||
PATH_LOCAL.mkdir(exist_ok=True)
|
||||
|
||||
####################
|
||||
|
@ -79,3 +100,18 @@ PATH_LOCAL.mkdir(exist_ok=True)
|
|||
PATH_ZIP = PATH_BUILD / (
|
||||
PROJ_SPEC['project']['name'] + '__' + PROJ_SPEC['project']['version'] + '.zip'
|
||||
)
|
||||
|
||||
####################
|
||||
# - Computed Paths
|
||||
####################
|
||||
LogLevel: typ.TypeAlias = int
|
||||
|
||||
|
||||
class InitSettings(pyd.BaseModel):
|
||||
"""Model describing addon initialization settings, describing default settings baked into the release."""
|
||||
|
||||
use_log_file: bool
|
||||
log_file_path: Path
|
||||
log_file_level: LogLevel
|
||||
use_log_console: bool
|
||||
log_console_level: LogLevel
|
||||
|
|
189
scripts/pack.py
189
scripts/pack.py
|
@ -14,35 +14,26 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <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/>.
|
||||
"""Contains tools and procedures that deterministically and reliably package the Blender extension.
|
||||
|
||||
import sys
|
||||
import contextlib
|
||||
This involves parsing and validating the plugin configuration from `pyproject.toml`, generating the extension manifest, downloading the correct platform-specific binary wheels for distribution, and zipping it all up together.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import typing as typ
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
import itertools
|
||||
import subprocess
|
||||
|
||||
import tomli_w
|
||||
|
||||
import info
|
||||
import rich
|
||||
import rich.progress
|
||||
import rich.prompt
|
||||
import tomli_w
|
||||
|
||||
LogLevel: typ.TypeAlias = int
|
||||
|
||||
|
@ -55,10 +46,10 @@ BL_EXT__TYPE = 'add-on'
|
|||
####################
|
||||
# See https://docs.blender.org/manual/en/4.2/extensions/getting_started.html
|
||||
# See https://packaging.python.org/en/latest/guides/writing-pyproject-toml
|
||||
## TODO: More validation and such.
|
||||
_FIRST_MAINTAINER = info.PROJ_SPEC['project']['maintainers'][0]
|
||||
_SPDX_LICENSE_NAME = info.PROJ_SPEC['project']['license']['text']
|
||||
|
||||
## TODO: pydantic model w/validation
|
||||
BL_EXT_MANIFEST = {
|
||||
'schema_version': BL_EXT__SCHEMA_VERSION,
|
||||
# Basics
|
||||
|
@ -95,13 +86,19 @@ BL_EXT_MANIFEST = {
|
|||
####################
|
||||
# - Generate Init Settings
|
||||
####################
|
||||
def generate_init_settings_dict(profile: str) -> dict:
|
||||
## TODO: Use an enum for 'profile'.
|
||||
def generate_init_settings_dict(profile: str) -> info.InitSettings:
|
||||
"""Generate initialization settings from a particular `profile` configured in `pyproject.toml`.
|
||||
|
||||
Args:
|
||||
profile: The string identifier corresponding to an entry in `pyproject.toml`.
|
||||
|
||||
Returns:
|
||||
The `info.InitSettings` structure to be bundled into the addon.
|
||||
"""
|
||||
profile_settings = info.PROJ_SPEC['tool']['bl_ext']['profiles'][profile]
|
||||
|
||||
if profile_settings['use_path_local']:
|
||||
base_path = info.PATH_LOCAL
|
||||
else:
|
||||
base_path = Path('{USER}')
|
||||
base_path = info.PATH_LOCAL if profile_settings['use_path_local'] else Path('USER')
|
||||
|
||||
log_levels = {
|
||||
None: logging.NOTSET,
|
||||
|
@ -112,32 +109,54 @@ def generate_init_settings_dict(profile: str) -> dict:
|
|||
'critical': logging.CRITICAL,
|
||||
}
|
||||
|
||||
return {
|
||||
# File Logging
|
||||
'use_log_file': profile_settings['use_log_file'],
|
||||
'log_file_path': str(base_path / profile_settings['log_file_path']),
|
||||
'log_file_level': log_levels[profile_settings['log_file_level']],
|
||||
# Console Logging
|
||||
'use_log_console': profile_settings['use_log_console'],
|
||||
'log_console_level': log_levels[profile_settings['log_console_level']],
|
||||
}
|
||||
return info.InitSettings(
|
||||
use_log_file=profile_settings['use_log_file'],
|
||||
log_file_path=base_path
|
||||
/ info.normalize_path(profile_settings['log_file_path']),
|
||||
log_file_level=log_levels[profile_settings['log_file_level']],
|
||||
use_log_console=profile_settings['use_log_console'],
|
||||
log_console_level=log_levels[profile_settings['log_console_level']],
|
||||
)
|
||||
|
||||
|
||||
####################
|
||||
# - Wheel Downloader
|
||||
####################
|
||||
def download_wheels() -> dict:
|
||||
def download_wheels(delete_existing_wheels: bool = True) -> None:
|
||||
"""Download universal and binary wheels for all platforms defined in `pyproject.toml`.
|
||||
|
||||
Each blender-supported platform requires specifying a valid list of PyPi platform constraints.
|
||||
These will be used as an allow-list when deciding which binary wheels may be selected for ex. 'mac'.
|
||||
|
||||
It is recommended to start with the most compatible platform tags, then work one's way up to the newest.
|
||||
Depending on how old the compatibility should stretch, one may have to omit / manually compile some wheels.
|
||||
|
||||
There is no exhaustive list of valid platform tags - though this should get you started:
|
||||
- https://stackoverflow.com/questions/49672621/what-are-the-valid-values-for-platform-abi-and-implementation-for-pip-do
|
||||
- Examine https://pypi.org/project/pillow/#files for some widely-supported tags.
|
||||
|
||||
Args:
|
||||
delete_existing_wheels: Whether to delete all wheels already in the directory.
|
||||
This doesn't generally require re-downloading; the pip-cache will generally be hit first.
|
||||
"""
|
||||
if delete_existing_wheels and rich.prompt.Confirm.ask(
|
||||
f'OK to delete "*.whl" in {info.PATH_WHEELS}?'
|
||||
):
|
||||
info.console.log(f'[bold] Deleting Existing Wheels in {info.PATH_WHEELS}')
|
||||
for existing_wheel in info.PATH_WHEELS.rglob('*.whl'):
|
||||
existing_wheel.unlink()
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f_reqlock:
|
||||
reqlock_str = subprocess.check_output(['uv', 'export', '--no-dev'])
|
||||
reqlock_str = subprocess.check_output(['uv', 'export', '--no-dev', '--locked'])
|
||||
f_reqlock.write(reqlock_str)
|
||||
reqlock_path = Path(f_reqlock.name)
|
||||
## TODO: Use-after-close may not work on Windows.
|
||||
|
||||
for platform, pypi_platform_tags in info.PROJ_SPEC['tool']['bl_ext'][
|
||||
'platforms'
|
||||
].items():
|
||||
print(f'[{platform}] Downloading Wheels...')
|
||||
print()
|
||||
print()
|
||||
info.console.rule(f'[bold] Downloading Wheels for {platform}')
|
||||
|
||||
platform_constraints = list(
|
||||
itertools.chain.from_iterable(
|
||||
[
|
||||
|
@ -146,8 +165,7 @@ def download_wheels() -> dict:
|
|||
]
|
||||
)
|
||||
)
|
||||
subprocess.check_call(
|
||||
[
|
||||
cmd = [
|
||||
sys.executable,
|
||||
'-m',
|
||||
'pip',
|
||||
|
@ -161,27 +179,37 @@ def download_wheels() -> dict:
|
|||
':all:',
|
||||
'--python-version',
|
||||
info.REQ_PYTHON_VERSION,
|
||||
*platform_constraints,
|
||||
]
|
||||
+ platform_constraints
|
||||
)
|
||||
print()
|
||||
|
||||
progress = rich.progress.Progress()
|
||||
progress_task_id = progress.add_task(' '.join(cmd))
|
||||
with (
|
||||
rich.progress.Live(progress, console=info.console, transient=False) as live,
|
||||
subprocess.Popen(cmd, stdout=subprocess.PIPE) as process,
|
||||
):
|
||||
for line in process.stdout if process.stdout is not None else []:
|
||||
progress.update(progress_task_id, description=line.decode())
|
||||
live.refresh()
|
||||
|
||||
# Cleanup the Temporary File
|
||||
reqlock_path.unlink()
|
||||
|
||||
|
||||
####################
|
||||
# - Pack Extension to ZIP
|
||||
####################
|
||||
def pack_bl_extension( # noqa: PLR0913
|
||||
def pack_bl_extension(
|
||||
profile: str,
|
||||
replace_if_exists: bool = False,
|
||||
) -> typ.Iterator[Path]:
|
||||
"""Context manager exposing a folder as a (temporary) zip file.
|
||||
) -> None:
|
||||
"""Package a Blender extension, using a particular given `profile` of init settings.
|
||||
|
||||
Configuration data is sourced from `info`, which in turns sources much of its user-facing configuration from `pyproject.toml`.
|
||||
|
||||
Parameters:
|
||||
path_addon_pkg: Path to the folder containing __init__.py of the Blender addon.
|
||||
path_addon_zip: Path to the Addon ZIP to generate.
|
||||
path_pyproject_toml: Path to the `pyproject.toml` of the project.
|
||||
This is made available to the addon, to de-duplicate definition of name,
|
||||
The .zip file is deleted afterwards, unless `remove_after_close` is specified.
|
||||
profile: Identifier matching `pyproject.toml`, which select a predefined set of init settings.
|
||||
replace_if_exists: Replace the zip file if it already exists.
|
||||
"""
|
||||
# Delete Existing ZIP (maybe)
|
||||
if info.PATH_ZIP.is_file():
|
||||
|
@ -190,44 +218,77 @@ def pack_bl_extension( # noqa: PLR0913
|
|||
raise ValueError(msg)
|
||||
info.PATH_ZIP.unlink()
|
||||
|
||||
init_settings: dict = generate_init_settings_dict(profile)
|
||||
init_settings: info.InitSettings = generate_init_settings_dict(profile)
|
||||
|
||||
# Create New ZIP file of the addon directory
|
||||
info.console.rule(f'[bold] Creating zipfile @ {info.PATH_ZIP}')
|
||||
with zipfile.ZipFile(info.PATH_ZIP, 'w', zipfile.ZIP_DEFLATED) as f_zip:
|
||||
# Write Blender Extension Manifest
|
||||
print('Writing Blender Extension Manifest...')
|
||||
with info.console.status('Writing Extension Manifest...'):
|
||||
f_zip.writestr(BL_EXT__MANIFEST_FILENAME, tomli_w.dumps(BL_EXT_MANIFEST))
|
||||
info.console.log('Wrote Extension Manifest.')
|
||||
|
||||
# Write Init Settings
|
||||
print('Writing Init Settings...')
|
||||
with info.console.status('Writing Init Settings...'):
|
||||
f_zip.writestr(
|
||||
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 @ /*
|
||||
print('Writing Addon Files Settings...')
|
||||
with info.console.status('Writing Addon Files...'):
|
||||
for file_to_zip in info.PATH_PKG.rglob('*'):
|
||||
f_zip.write(file_to_zip, file_to_zip.relative_to(info.PATH_PKG.parent))
|
||||
info.console.log('Wrote Addon Files.')
|
||||
|
||||
# Install Wheels @ /wheels/*
|
||||
print('Writing Wheels...')
|
||||
for wheel_to_zip in info.PATH_WHEELS.rglob('*'):
|
||||
# with info.console.status('Writing Wheels...'):
|
||||
# for wheel_to_zip in info.PATH_WHEELS.rglob('*'):
|
||||
# f_zip.write(wheel_to_zip, Path('wheels') / wheel_to_zip.name)
|
||||
# info.console.log('Wrote Wheels.')
|
||||
|
||||
total_wheel_size = sum(
|
||||
f.stat().st_size for f in info.PATH_WHEELS.rglob('*') if f.is_file()
|
||||
)
|
||||
|
||||
progress = rich.progress.Progress(
|
||||
rich.progress.TextColumn(
|
||||
'Writing Wheel: {task.description}...',
|
||||
table_column=rich.progress.Column(ratio=2),
|
||||
),
|
||||
rich.progress.BarColumn(
|
||||
bar_width=None,
|
||||
table_column=rich.progress.Column(ratio=2),
|
||||
),
|
||||
expand=True,
|
||||
)
|
||||
progress_task = progress.add_task('Writing Wheels...', total=total_wheel_size)
|
||||
with rich.progress.Live(progress, console=info.console, transient=True) as live:
|
||||
for wheel_to_zip in info.PATH_WHEELS.rglob('*.whl'):
|
||||
f_zip.write(wheel_to_zip, Path('wheels') / wheel_to_zip.name)
|
||||
progress.update(
|
||||
progress_task,
|
||||
description=wheel_to_zip.name,
|
||||
advance=wheel_to_zip.stat().st_size,
|
||||
)
|
||||
live.refresh()
|
||||
|
||||
info.console.log('Wrote Wheels.')
|
||||
|
||||
# Delete the ZIP
|
||||
print('Packed Blender Extension!')
|
||||
info.console.rule(f'[bold green] Extension Packed to {info.PATH_ZIP}!')
|
||||
|
||||
|
||||
####################
|
||||
# - Run Blender w/Clean Addon Reinstall
|
||||
####################
|
||||
if __name__ == '__main__':
|
||||
profile = sys.argv[1]
|
||||
if sys.argv[1] in ['dev', 'release', 'release-debug']:
|
||||
if not list(info.PATH_WHEELS.iterdir()) or '--download-wheels' in sys.argv:
|
||||
download_wheels()
|
||||
|
||||
profile = sys.argv[1]
|
||||
if sys.argv[1] in ['dev', 'release', 'release-debug']:
|
||||
pack_bl_extension(profile)
|
||||
else:
|
||||
msg = f'Packaging profile "{profile}" is invalid. Refer to source of pack.py for more information'
|
||||
|
|
56
uv.lock
56
uv.lock
|
@ -269,12 +269,12 @@ wheels = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "fake-bpy-module-4-0"
|
||||
version = "20240604"
|
||||
name = "fake-bpy-module"
|
||||
version = "20240927"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/f4/b996342217daec3db18ee60c64ced9e0871ad51ca596bd7c6662687fcdea/fake_bpy_module_4_0-20240604.tar.gz", hash = "sha256:1b11e8c69c5952710f3547401f7a2b2343f4328ed5925df23fb9bf9b6dd83c13", size = 841347 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/4d/938a7b6049b05b5c9861758792c64b10c0bf733c9b8eb8f8721b5abf8105/fake_bpy_module-20240927.tar.gz", hash = "sha256:fd706c4f73283473ff2ae9ec9017fcfc33fc32c9ddd64d9a89b4701d1d9dc8a3", size = 953541 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/78/de406749fd585dbb6e9fe8f120ea4ff5bcc6a1eafef9b479578ac37f9fb1/fake_bpy_module_4.0-20240604-py3-none-any.whl", hash = "sha256:764c32e1fca23b1a58eb2f6db57e945cc18ac3ed5317e6d5088f2d1097a8d8ea", size = 1066475 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/22/fc70e69f3ae6399bdb4dd218e257001a1b67483e59ee7caaaf71e30eb1f2/fake_bpy_module-20240927-py3-none-any.whl", hash = "sha256:123ca66bfe184f4e8b2a6cec3ef66c522acfa0807b1b478bd110687d920fa0ac", size = 1076677 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -835,7 +835,6 @@ dependencies = [
|
|||
{ name = "msgspec", extra = ["toml"] },
|
||||
{ name = "networkx" },
|
||||
{ name = "numba" },
|
||||
{ name = "pip" },
|
||||
{ name = "polars" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-tensor" },
|
||||
|
@ -851,7 +850,8 @@ dependencies = [
|
|||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "commitizen" },
|
||||
{ name = "fake-bpy-module-4-0" },
|
||||
{ name = "fake-bpy-module" },
|
||||
{ name = "pip" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
@ -864,7 +864,6 @@ requires-dist = [
|
|||
{ name = "msgspec", extras = ["toml"], specifier = "==0.18.*" },
|
||||
{ name = "networkx", specifier = "==3.3.*" },
|
||||
{ name = "numba", specifier = "==0.60.*" },
|
||||
{ name = "pip", specifier = ">=24.2" },
|
||||
{ name = "polars", specifier = "==1.8.*" },
|
||||
{ name = "pydantic", specifier = "==2.9.*" },
|
||||
{ name = "pydantic-tensor", specifier = ">=0.2" },
|
||||
|
@ -880,9 +879,10 @@ requires-dist = [
|
|||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "commitizen", specifier = ">=3.25.0" },
|
||||
{ name = "fake-bpy-module-4-0", specifier = ">=20231118" },
|
||||
{ name = "fake-bpy-module", specifier = "==20240927" },
|
||||
{ name = "pip", specifier = ">=24.2" },
|
||||
{ name = "pre-commit", specifier = ">=3.7.0" },
|
||||
{ name = "ruff", specifier = ">=0.4.3" },
|
||||
{ name = "ruff", specifier = ">=0.6.8" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1287,27 +1287,27 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.6.7"
|
||||
version = "0.6.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/3045a526c57cef4b5ec4d5d154692e31429749a49810a53e785de334c4f6/ruff-0.6.7.tar.gz", hash = "sha256:44e52129d82266fa59b587e2cd74def5637b730a69c4542525dfdecfaae38bd5", size = 3073785 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/f9/4ce3e765a72ab8fe0f80f48508ea38b4196daab3da14d803c21349b2d367/ruff-0.6.8.tar.gz", hash = "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18", size = 3084543 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c4/1c5c636f83f905c537785016e9cdd7a36df53c025a2d07940580ecb37bcf/ruff-0.6.7-py3-none-linux_armv6l.whl", hash = "sha256:08277b217534bfdcc2e1377f7f933e1c7957453e8a79764d004e44c40db923f2", size = 10336748 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d9/aa15a56be7ad796f4d7625362aff588f9fc013bbb7323a63571628a2cf2d/ruff-0.6.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c6707a32e03b791f4448dc0dce24b636cbcdee4dd5607adc24e5ee73fd86c00a", size = 9958833 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/25/5dd1c32bfc3ad3136c8ebe84312d1bdd2e6c908ac7f60692ec009b7050a8/ruff-0.6.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:533d66b7774ef224e7cf91506a7dafcc9e8ec7c059263ec46629e54e7b1f90ab", size = 9633369 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/3e/01b25484f3cb08fe6fddedf1f55f3f3c0af861a5b5f5082fbe60ab4b2596/ruff-0.6.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17a86aac6f915932d259f7bec79173e356165518859f94649d8c50b81ff087e9", size = 10637415 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/c9/5bb9b849e4777e0f961de43edf95d2af0ab34999a5feee957be096887876/ruff-0.6.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3f8822defd260ae2460ea3832b24d37d203c3577f48b055590a426a722d50ef", size = 10097389 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/cf/e08f1c290c7d848ddfb2ae811f24f445c18e1d3e50e01c38ffa7f5a50494/ruff-0.6.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba4efe5c6dbbb58be58dd83feedb83b5e95c00091bf09987b4baf510fee5c99", size = 10951440 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2d/ca8aa0da5841913c302d8034c6de0ce56c401c685184d8dd23cfdd0003f9/ruff-0.6.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:525201b77f94d2b54868f0cbe5edc018e64c22563da6c5c2e5c107a4e85c1c0d", size = 11708900 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/fc/9a83c57baee977c82392e19a328b52cebdaf61601af3d99498e278ef5104/ruff-0.6.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8854450839f339e1049fdbe15d875384242b8e85d5c6947bb2faad33c651020b", size = 11258892 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/a3/254cc7afef702c68ae9079290c2a1477ae0e81478589baf745026d8a4eb5/ruff-0.6.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f0b62056246234d59cbf2ea66e84812dc9ec4540518e37553513392c171cb18", size = 12367932 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/55/53f10c1bd8c3b2ae79aed18e62b22c6346f9296aa0ec80489b8442bd06a9/ruff-0.6.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b1462fa56c832dc0cea5b4041cfc9c97813505d11cce74ebc6d1aae068de36b", size = 10838629 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/72/fb335c2b25432c63d15383ecbd7bfc1915e68cdf8d086a08042052144255/ruff-0.6.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:02b083770e4cdb1495ed313f5694c62808e71764ec6ee5db84eedd82fd32d8f5", size = 10648824 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/a8/d57e135a8ad99b6a0c6e2a5c590bcacdd57f44340174f4409c3893368610/ruff-0.6.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c05fd37013de36dfa883a3854fae57b3113aaa8abf5dea79202675991d48624", size = 10174368 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/6f/1a30a6e81dcf2fa9ff3f7011eb87fe76c12a3c6bba74db6a1977d763de1f/ruff-0.6.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f49c9caa28d9bbfac4a637ae10327b3db00f47d038f3fbb2195c4d682e925b14", size = 10514383 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/25/df6f2575bc9fe43a6dedfd8dee12896f09a94303e2c828d5f85856bb69a0/ruff-0.6.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0e1655868164e114ba43a908fd2d64a271a23660195017c17691fb6355d59bb", size = 10902340 },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/62/f2c1031e2fb7b94f9bf0603744e73db4ef90081b0eb1b9639a6feefd52ea/ruff-0.6.7-py3-none-win32.whl", hash = "sha256:a939ca435b49f6966a7dd64b765c9df16f1faed0ca3b6f16acdf7731969deb35", size = 8448033 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/80/193d1604a3f7d75eb1b2a7ce6bf0fdbdbc136889a65caacea6ffb29501b1/ruff-0.6.7-py3-none-win_amd64.whl", hash = "sha256:590445eec5653f36248584579c06252ad2e110a5d1f32db5420de35fb0e1c977", size = 9273543 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a8/4abb5a9f58f51e4b1ea386be5ab2e547035bc1ee57200d1eca2f8909a33e/ruff-0.6.7-py3-none-win_arm64.whl", hash = "sha256:b28f0d5e2f771c1fe3c7a45d3f53916fc74a480698c4b5731f0bea61e52137c8", size = 8618044 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/07/42ee57e8b76ca585297a663a552b4f6d6a99372ca47fdc2276ef72cc0f2f/ruff-0.6.8-py3-none-linux_armv6l.whl", hash = "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2", size = 10404327 },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/d42571ff8156d65086acb72d39aa64cb24181db53b497d0ed6293f43f07a/ruff-0.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c", size = 10018797 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/d7/fa5514a60b03976af972b67fe345deb0335dc96b9f9a9fa4df9890472427/ruff-0.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5", size = 9691303 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c4/d812a74976927e51d0782a47539069657ac78535779bfa4d061c4fc8d89d/ruff-0.6.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f", size = 10719452 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/b6/aa700c4ae6db9b3ee660e23f3c7db596e2b16a3034b797704fba33ddbc96/ruff-0.6.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb", size = 10161353 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/39/0b10075ffcd52ff3a581b9b69eac53579deb230aad300ce8f9d0b58e77bc/ruff-0.6.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f", size = 10980630 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/af/9eb9efc98334f62652e2f9318f137b2667187851911fac3b395365a83708/ruff-0.6.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0", size = 11768996 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/59/8b1369cf7878358952b1c0a1559b4d6b5c824c003d09b0db26d26c9d094f/ruff-0.6.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87", size = 11317469 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/6d/e252e9b11bbca4114c386ee41ad559d0dac13246201d77ea1223c6fea17f/ruff-0.6.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098", size = 12467185 },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/44/7caa223af7d4ea0f0b2bd34acca65a7694a58317714675a2478815ab3f45/ruff-0.6.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0", size = 10887766 },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/ed/394aff3a785f171869158b9d5be61eec9ffb823c3ad5d2bdf2e5f13cb029/ruff-0.6.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750", size = 10711609 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/31/f31d04c842e54699eab7e3b864538fea26e6c94b71806cd10aa49f13e1c1/ruff-0.6.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce", size = 10237621 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/95/a764e84acf11d425f2f23b8b78b4fd715e9c20be4aac157c6414ca859a67/ruff-0.6.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa", size = 10558329 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/76/d4e38846ac9f6dd62dce858a54583911361b5339dcf8f84419241efac93a/ruff-0.6.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44", size = 10954102 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/36/f18c678da6c69f8d022480f3e8ddce6e4a52e07602c1d212056fbd234f8f/ruff-0.6.8-py3-none-win32.whl", hash = "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a", size = 8511090 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/c4/0ca7d8ffa358b109db7d7d045a1a076fd8e5d9cbeae022242d3c060931da/ruff-0.6.8-py3-none-win_amd64.whl", hash = "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263", size = 9350079 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/bd/a8b0c64945a92eaeeb8d0283f27a726a776a1c9d12734d990c5fc7a1278c/ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc", size = 8669595 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
Loading…
Reference in New Issue