diff --git a/.gitignore b/.gitignore index a6b0e50..9771cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ dev +build *.blend[0-9] .cached-dependencies diff --git a/.python-version b/.python-version index 9919bf8..b6d8b76 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10.13 +3.11.8 diff --git a/pyproject.toml b/pyproject.toml index df02088..0bb2082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,16 +6,25 @@ authors = [ { name = "Sofus Albert Høgsbro Rose", email = "blender-maxwell@sofusrose.com" } ] dependencies = [ - "tidy3d>=2.6.1", - "pydantic>=2.6.4", - "sympy>=1.12", - "scipy>=1.12.0", - "trimesh>=4.2.0", - "networkx>=3.2.1", - "rtree>=1.2.0", + "tidy3d~=2.6.1", + "pydantic~=2.6.4", + "sympy~=1.12", + "scipy~=1.12.0", + "trimesh~=4.2.0", + "networkx~=3.2.1", + "rtree~=1.2.0", + + # Pin Blender 4.1.0-Compatible Versions + ## The dependency resolver will report if anything is wonky. + "urllib3==1.26.8", + "requests==2.27.1", + "numpy==1.24.3", + "idna==3.3", + "charset-normalizer==2.0.10", + "certifi==2021.10.8", ] readme = "README.md" -requires-python = "~= 3.10" +requires-python = "~= 3.11" license = { text = "AGPL-3.0-or-later" } #################### @@ -26,13 +35,18 @@ managed = true virtual = true dev-dependencies = [ "ruff>=0.3.2", + "fake-bpy-module-4-0>=20231118", ## TODO: Update to Blender 4.1.0 ] +[tool.rye.scripts] +dev = "python ./scripts/run.py" + + #################### # - Tooling: Ruff #################### [tool.ruff] -target-version = "py312" +target-version = "py311" line-length = 79 [tool.ruff.lint] @@ -77,14 +91,15 @@ select = [ "PT", # flake8-pytest-style ## pytest-Specific Checks ] ignore = [ - "B008", # FastAPI uses this for Depends(), Security(), etc. . - "E701", # class foo(Parent): pass or if simple: return are perfectly elegant "COM812", # Conflicts w/Formatter "ISC001", # Conflicts w/Formatter "Q000", # Conflicts w/Formatter "Q001", # Conflicts w/Formatter "Q002", # Conflicts w/Formatter "Q003", # Conflicts w/Formatter + "B008", # FastAPI uses this for Depends(), Security(), etc. . + "E701", # class foo(Parent): pass or if simple: return are perfectly elegant + "ERA001", # 'Commented-out code' seems to be just about anything to ruff ] #################### diff --git a/requirements-dev.lock b/requirements-dev.lock index 195b0b2..2184b93 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -14,9 +14,9 @@ boto3==1.23.1 botocore==1.26.10 # via boto3 # via s3transfer -certifi==2024.2.2 +certifi==2021.10.8 # via requests -charset-normalizer==3.3.2 +charset-normalizer==2.0.10 # via requests click==8.0.3 # via dask @@ -31,6 +31,7 @@ cycler==0.12.1 # via matplotlib dask==2023.10.1 # via tidy3d +fake-bpy-module-4-0==20231118 fonttools==4.49.0 # via matplotlib fsspec==2024.2.0 @@ -40,7 +41,7 @@ h5netcdf==1.0.2 h5py==3.10.0 # via h5netcdf # via tidy3d -idna==3.6 +idna==3.3 # via requests importlib-metadata==6.11.0 # via dask @@ -57,11 +58,10 @@ matplotlib==3.8.3 mpmath==1.3.0 # via sympy networkx==3.2.1 -numpy==1.26.4 +numpy==1.24.3 # via contourpy # via h5py # via matplotlib - # via pandas # via scipy # via shapely # via trimesh @@ -99,10 +99,10 @@ pyyaml==6.0.1 # via dask # via responses # via tidy3d -requests==2.31.0 +requests==2.27.1 # via responses # via tidy3d -responses==0.25.0 +responses==0.23.1 # via tidy3d rich==12.5.1 # via tidy3d @@ -124,12 +124,14 @@ toolz==0.12.1 # via dask # via partd trimesh==4.2.0 +types-pyyaml==6.0.12.20240311 + # via responses typing-extensions==4.10.0 # via pydantic # via pydantic-core tzdata==2024.1 # via pandas -urllib3==1.26.18 +urllib3==1.26.8 # via botocore # via requests # via responses diff --git a/requirements.lock b/requirements.lock index 64d051f..f7ee076 100644 --- a/requirements.lock +++ b/requirements.lock @@ -14,9 +14,9 @@ boto3==1.23.1 botocore==1.26.10 # via boto3 # via s3transfer -certifi==2024.2.2 +certifi==2021.10.8 # via requests -charset-normalizer==3.3.2 +charset-normalizer==2.0.10 # via requests click==8.0.3 # via dask @@ -40,7 +40,7 @@ h5netcdf==1.0.2 h5py==3.10.0 # via h5netcdf # via tidy3d -idna==3.6 +idna==3.3 # via requests importlib-metadata==6.11.0 # via dask @@ -57,11 +57,10 @@ matplotlib==3.8.3 mpmath==1.3.0 # via sympy networkx==3.2.1 -numpy==1.26.4 +numpy==1.24.3 # via contourpy # via h5py # via matplotlib - # via pandas # via scipy # via shapely # via trimesh @@ -99,10 +98,10 @@ pyyaml==6.0.1 # via dask # via responses # via tidy3d -requests==2.31.0 +requests==2.27.1 # via responses # via tidy3d -responses==0.25.0 +responses==0.23.1 # via tidy3d rich==12.5.1 # via tidy3d @@ -123,12 +122,14 @@ toolz==0.12.1 # via dask # via partd trimesh==4.2.0 +types-pyyaml==6.0.12.20240311 + # via responses typing-extensions==4.10.0 # via pydantic # via pydantic-core tzdata==2024.1 # via pandas -urllib3==1.26.18 +urllib3==1.26.8 # via botocore # via requests # via responses diff --git a/run.py b/run.py deleted file mode 100644 index 7f69c58..0000000 --- a/run.py +++ /dev/null @@ -1,108 +0,0 @@ -import zipfile -import contextlib -import shutil -import sys -from pathlib import Path - -import bpy -import addon_utils - -PATH_ROOT = Path(__file__).resolve().parent - -#################### -# - Defined Constants -#################### -ADDON_NAME = "blender_maxwell" -PATH_BLEND = PATH_ROOT / "demo.blend" -PATH_ADDON_DEPS = PATH_ROOT / ".cached-dependencies" - -#################### -# - Computed Constants -#################### -PATH_ADDON = PATH_ROOT / ADDON_NAME -PATH_ADDON_ZIP = PATH_ROOT / (ADDON_NAME + ".zip") - -#################### -# - Utilities -#################### -@contextlib.contextmanager -def zipped_directory(path_dir: Path, path_zip: Path): - """Context manager that exposes a zipped version of a directory, - then deletes the .zip file afterwards. - """ - # Delete Existing ZIP file (if exists) - if path_zip.is_file(): path_zip.unlink() - - # Create a (new) ZIP file of the addon directory - with zipfile.ZipFile(path_zip, 'w', zipfile.ZIP_DEFLATED) as f_zip: - for file_to_zip in path_dir.rglob('*'): - f_zip.write(file_to_zip, file_to_zip.relative_to(path_dir.parent)) - - # Delete the ZIP - try: - yield path_zip - finally: - path_zip.unlink() - -#################### -# - main() -#################### -if __name__ == "__main__": - # Check and uninstall the addon if it's enabled - is_loaded_by_default, is_loaded_now = addon_utils.check(ADDON_NAME) - if is_loaded_now: - # Disable the Addon - addon_utils.disable(ADDON_NAME, default_set=True, handle_error=None) - - # Completey Delete the Addon - for mod in addon_utils.modules(): - if mod.__name__ == ADDON_NAME: - # Delete Addon from Blender Python Tree - shutil.rmtree(Path(mod.__file__).parent) - - # Reset All Addons - addon_utils.reset_all() - - # Save User Preferences & Break - bpy.ops.wm.save_userpref() - break - - # Quit Blender (hard-flush Python environment) - ## - Python environments are not made to be partially flushed. - ## - This is the only truly reliable way to avoid all bugs. - ## - See https://github.com/JacquesLucke/blender_vscode - bpy.ops.wm.quit_blender() - try: - raise RuntimeError - except: - sys.exit(42) - - with zipped_directory(PATH_ADDON, PATH_ADDON_ZIP) as path_zipped: - # Install the ZIPped Addon - bpy.ops.preferences.addon_install(filepath=str(path_zipped)) - - # Enable the Addon - addon_utils.enable( - ADDON_NAME, - default_set=True, - persistent=True, - handle_error=None, - ) - - # Save User Preferences - bpy.ops.wm.save_userpref() - - # Load the .blend - bpy.ops.wm.open_mainfile(filepath=str(PATH_BLEND)) - - # Ensure Addon-Specific Dependency Cache is Importable - ## - In distribution, the addon keeps this folder in the Blender script tree. - ## - For testing, we need to hack sys.path here. - ## - This avoids having to install all deps with every reload. - if str(PATH_ADDON_DEPS) not in sys.path: - sys.path.insert(0, str(PATH_ADDON_DEPS)) - - # Modify any specific settings, if needed - # Example: bpy.context.preferences.addons[addon_name].preferences.your_setting = "your_value" - - diff --git a/run.sh b/run.sh deleted file mode 100644 index 7a061df..0000000 --- a/run.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -blender --python run.py -if [ $? -eq 42 ]; then - echo - echo - echo - echo - echo - blender --python run.py -fi diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/bl_run.py b/scripts/bl_run.py new file mode 100644 index 0000000..5206550 --- /dev/null +++ b/scripts/bl_run.py @@ -0,0 +1,155 @@ +"""Blender startup script ensuring correct addon installation. + +See +""" + +import shutil +import sys +import traceback +from pathlib import Path + +import bpy + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import info +import pack + +## TODO: Preferences item that allows using BLMaxwell 'starter.blend' as Blender's default starter blendfile. + + +#################### +# - Addon Functions +#################### +def delete_addon_if_loaded(addon_name: str) -> None: + """Strongly inspired by Blender's addon_utils.py.""" + should_restart_blender = False + + # Check if Python Module is Loaded + mod = sys.modules.get(addon_name) + # if (mod := sys.modules.get(addon_name)) is None: + # ## It could still be loaded-by-default; then, it's in the prefs list + # is_loaded_now = False + # loads_by_default = addon_name in bpy.context.preferences.addons + # else: + # ## BL sets __addon_enabled__ on module of enabled addons. + # ## BL sets __addon_persistent__ on module of load-by-default addons. + # is_loaded_now = getattr(mod, '__addon_enabled__', False) + # loads_by_default = getattr(mod, '__addon_persistent__', False) + + # Unregister Modules and Mark Disabled & Non-Persistent + ## This effectively disables it + if mod is not None: + mod.__addon_enabled__ = False + mod.__addon_persistent__ = False + try: + mod.unregister() + except BaseException: + traceback.print_exc() + should_restart_blender = True + + # Remove Addon + ## Remove Addon from Preferences + ## - Unsure why addon_utils has a while, but let's trust the process... + while addon_name in bpy.context.preferences.addons: + addon = bpy.context.preferences.addons.get(addon_name) + if addon: + bpy.context.preferences.addons.remove(addon) + + ## Physically Excise Addon Code + for addons_path in bpy.utils.script_paths(subdir='addons'): + addon_path = Path(addons_path) / addon_name + if addon_path.exists(): + shutil.rmtree(addon_path) + should_restart_blender = True + + ## Save User Preferences + bpy.ops.wm.save_userpref() + + # Quit (Restart) Blender - hard-flush Python environment + ## - Python environments are not made to be partially flushed. + ## - This is the only truly reliable way to avoid all bugs. + ## - See + ## - By passing STATUS_UNINSTALLED_ADDON, we report that it's clean now. + if should_restart_blender: + bpy.ops.wm.quit_blender() + sys.exit(info.STATUS_UNINSTALLED_ADDON) + + +def install_addon(addon_name: str, addon_zip: Path) -> None: + """Strongly inspired by Blender's addon_utils.py.""" + # Check if Addon is Installable + if any( + [ + (mod := sys.modules.get(addon_name)) is not None, + addon_name in bpy.context.preferences.addons, + any( + (Path(addon_path) / addon_name).exists() + for addon_path in bpy.utils.script_paths(subdir='addons') + ), + ] + ): + ## TODO: Check if addon file path exists? + in_pref_addons = addon_name in bpy.context.preferences.addons + existing_files_found = { + addon_path: (Path(addon_path) / addon_name).exists() + for addon_path in bpy.utils.script_paths(subdir='addons') + if (Path(addon_path) / addon_name).exists() + } + msg = f"Addon (module = '{mod}') is not installable (in preferences.addons: {in_pref_addons}) (existing files found: {existing_files_found})" + raise ValueError(msg) + + # Install Addon + bpy.ops.preferences.addon_install(filepath=str(addon_zip)) + if not any( + (Path(addon_path) / addon_name).exists() + for addon_path in bpy.utils.script_paths(subdir='addons') + ): + msg = f"Couldn't install addon {addon_name}" + raise RuntimeError(msg) + + # Enable Addon + bpy.ops.preferences.addon_enable(module=addon_name) + if addon_name not in bpy.context.preferences.addons: + msg = f"Couldn't enable addon {addon_name}" + raise RuntimeError(msg) + + # Set Dev Path for Addon Dependencies + addon_prefs = bpy.context.preferences.addons[addon_name].preferences + addon_prefs.use_default_path_addon_pydeps = False + addon_prefs.path_addon_pydeps = info.PATH_ADDON_DEV_DEPS + + # Save User Preferences + bpy.ops.wm.save_userpref() + + +#################### +# - Entrypoint +#################### +if __name__ == '__main__': + # Delete Addon (maybe; possibly restart) + delete_addon_if_loaded(info.ADDON_NAME) + + # Signal that Live-Printing can Start + print(info.SIGNAL_START_CLEAN_BLENDER) # noqa: T201 + + # Install and Enable Addon + install_failed = False + with pack.zipped_addon( + info.PATH_ADDON_PKG, + info.PATH_ADDON_ZIP, + info.PATH_ROOT / 'pyproject.toml', + info.PATH_ROOT / 'requirements.lock', + ) as path_zipped: + try: + install_addon(info.ADDON_NAME, path_zipped) + except Exception as exe: + traceback.print_exc() + install_failed = True + + # Load Development .blend + ## TODO: We need a better (also final-deployed-compatible) solution for what happens when a user opened a .blend file without installing dependencies! + if not install_failed: + bpy.ops.wm.open_mainfile(filepath=str(info.PATH_ADDON_DEV_BLEND)) + else: + bpy.ops.wm.quit_blender() + sys.exit(info.STATUS_NOINSTALL_ADDON) diff --git a/scripts/info.py b/scripts/info.py new file mode 100644 index 0000000..ce21788 --- /dev/null +++ b/scripts/info.py @@ -0,0 +1,52 @@ +import tomllib +from pathlib import Path + +PATH_ROOT = Path(__file__).resolve().parent.parent +PATH_RUN = PATH_ROOT / 'scripts' / 'run.py' +PATH_BL_RUN = PATH_ROOT / 'scripts' / 'bl_run.py' + +PATH_BUILD = PATH_ROOT / 'build' +PATH_BUILD.mkdir(exist_ok=True) + +PATH_DEV = PATH_ROOT / 'dev' +PATH_DEV.mkdir(exist_ok=True) + +#################### +# - BL_RUN stdout Signals +#################### +SIGNAL_START_CLEAN_BLENDER = 'SIGNAL__blender_is_clean' + +#################### +# - BL_RUN Exit Codes +#################### +STATUS_UNINSTALLED_ADDON = 42 +STATUS_NOINSTALL_ADDON = 68 + +#################### +# - Addon Information +#################### +with (PATH_ROOT / 'pyproject.toml').open('rb') as f: + PROJ_SPEC = tomllib.load(f) + +ADDON_NAME = PROJ_SPEC['project']['name'] +ADDON_VERSION = PROJ_SPEC['project']['version'] + +#################### +# - Packaging Information +#################### +PATH_ADDON_PKG = PATH_ROOT / 'src' / ADDON_NAME +PATH_ADDON_ZIP = ( + PATH_ROOT / 'build' / (ADDON_NAME + '__' + ADDON_VERSION + '.zip') +) + +PATH_ADDON_BLEND_STARTER = PATH_ADDON_PKG / 'blenders' / 'starter.blend' + + # Install the ZIPped Addon +#################### +# - Development Information +#################### +PATH_ADDON_DEV_BLEND = PATH_DEV / 'demo.blend' + +PATH_ADDON_DEV_DEPS = PATH_DEV / '.cached-dev-dependencies' +PATH_ADDON_DEV_DEPS.mkdir(exist_ok=True) + diff --git a/scripts/pack.py b/scripts/pack.py new file mode 100644 index 0000000..9cce486 --- /dev/null +++ b/scripts/pack.py @@ -0,0 +1,93 @@ +import contextlib +import tempfile +import typing as typ +import zipfile +from pathlib import Path + +import info + +_PROJ_VERSION_STR = str( + tuple(int(el) for el in info.PROJ_SPEC['project']['version'].split('.')) +) +_PROJ_DESC_STR = info.PROJ_SPEC['project']['description'] + +BL_INFO_REPLACEMENTS = { + "'version': (0, 0, 0),": f"'version': {_PROJ_VERSION_STR},", + "'description': 'Placeholder',": f"'description': '{_PROJ_DESC_STR}',", +} + + +@contextlib.contextmanager +def zipped_addon( + path_addon_pkg: Path, + path_addon_zip: Path, + path_pyproject_toml: Path, + path_requirements_lock: Path, + replace_if_exists: bool = False, +) -> typ.Iterator[Path]: + """Context manager exposing a folder as a (temporary) zip file. + The .zip file is deleted afterwards. + """ + # Delete Existing ZIP (maybe) + if path_addon_zip.is_file(): + if replace_if_exists: + msg = 'File already exists where ZIP would be made' + raise ValueError(msg) + path_addon_zip.unlink() + + # Create New ZIP file of the addon directory + with zipfile.ZipFile(path_addon_zip, 'w', zipfile.ZIP_DEFLATED) as f_zip: + # Install Addon Files @ /* + for file_to_zip in path_addon_pkg.rglob('*'): + # Dynamically Alter 'bl_info' in __init__.py + ## This is the only way to propagate ex. version information + if str(file_to_zip.relative_to(path_addon_pkg)) == '__init__.py': + with ( + file_to_zip.open('r') as f_init, + tempfile.NamedTemporaryFile(mode='w') as f_tmp, + ): + initpy = f_init.read() + for to_replace, replacement in BL_INFO_REPLACEMENTS.items(): + initpy = initpy.replace(to_replace, replacement) + f_tmp.write(initpy) + + # Write to ZIP + f_zip.writestr( + str(file_to_zip.relative_to(path_addon_pkg.parent)), + initpy, + ) + + # Write File to Zip + else: + f_zip.write( + file_to_zip, file_to_zip.relative_to(path_addon_pkg.parent) + ) + + # Install pyproject.toml @ /pyproject.toml of Addon + f_zip.write( + path_pyproject_toml, + str( + ( + Path(path_addon_pkg.name) + / Path(path_pyproject_toml.name) + ) + .with_suffix('') + .with_suffix('.toml') + ), + ) + + # Install requirements.lock @ /requirements.txt of Addon + f_zip.write( + path_requirements_lock, + str( + (Path(path_addon_pkg.name) / Path(path_requirements_lock.name)) + .with_suffix('') + .with_suffix('.txt') + ), + ) + + # Delete the ZIP + try: + yield path_addon_zip + finally: + path_addon_zip.unlink() diff --git a/scripts/run.py b/scripts/run.py new file mode 100644 index 0000000..64848bd --- /dev/null +++ b/scripts/run.py @@ -0,0 +1,54 @@ +import os +import subprocess +from pathlib import Path + +import info + + +#################### +# - Blender Runner +#################### +def run_blender(py_script: Path, print_live: bool = False): + process = subprocess.Popen( + ['blender', '--python', str(py_script)], + env = os.environ | {'PYTHONUNBUFFERED': '1'}, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + output = [] + printing_live = print_live + + # Process Real-Time Output + for line in iter(process.stdout.readline, b''): + if not line: + break + + if printing_live: + print(line, end='') # noqa: T201 + elif ( + info.SIGNAL_START_CLEAN_BLENDER in line + #or 'Traceback (most recent call last)' in line + ): + printing_live = True + print(''.join(output)) # noqa: T201 + else: + output.append(line) + + # Wait for the process to finish and get the exit code + process.wait() + return process.returncode, output + + +#################### +# - Run Blender w/Clean Addon Reinstall +#################### +if __name__ == '__main__': + return_code, output = run_blender(info.PATH_BL_RUN, print_live=False) + if return_code == info.STATUS_UNINSTALLED_ADDON: + return_code, output = run_blender(info.PATH_BL_RUN, print_live=True) + if return_code == info.STATUS_NOINSTALL_ADDON: + msg = f"Couldn't install addon {info.ADDON_NAME}" + raise ValueError(msg) + elif return_code != 0: + print(''.join(output)) # noqa: T201 diff --git a/src/blender_maxwell/__init__.py b/src/blender_maxwell/__init__.py index 06fc523..b916652 100644 --- a/src/blender_maxwell/__init__.py +++ b/src/blender_maxwell/__init__.py @@ -1,84 +1,97 @@ +import tomllib +from pathlib import Path + +import bpy + +from . import operators_nodeps, preferences, registration +from .utils import pydeps +from .utils import logger as _logger + +log = _logger.get() +PATH_ADDON_ROOT = Path(__file__).resolve().parent +with (PATH_ADDON_ROOT / 'pyproject.toml').open('rb') as f: + PROJ_SPEC = tomllib.load(f) + +#################### +# - Addon Information +#################### +# The following parameters are replaced when packing the addon ZIP +## - description +## - version bl_info = { - "name": "Maxwell Simulation and Visualization", - "blender": (4, 0, 2), - "category": "Node", - "description": "Custom node trees for defining and visualizing Maxwell simulation.", - "author": "Sofus Albert Høgsbro Rose", - "version": (0, 1), - "wiki_url": "https://git.sofus.io/dtu-courses/bsc_thesis", - "tracker_url": "https://git.sofus.io/dtu-courses/bsc_thesis/issues", + 'name': 'Maxwell PDE Sim and Viz', + 'blender': (4, 1, 0), + 'category': 'Node', + 'description': 'Placeholder', + 'author': 'Sofus Albert Høgsbro Rose', + 'version': (0, 0, 0), + 'wiki_url': 'https://git.sofus.io/dtu-courses/bsc_thesis', + 'tracker_url': 'https://git.sofus.io/dtu-courses/bsc_thesis/issues', } +## bl_info MUST readable via. ast.parse +## See scripts/pack.py::BL_INFO_REPLACEMENTS for active replacements +## The mechanism is a 'dumb' - output of 'ruff fmt' MUST be basis for replacing + + +def ADDON_PREFS(): + return bpy.context.preferences.addons[ + PROJ_SPEC['project']['name'] + ].preferences + #################### -# - sys.path Library Inclusion +# - Load and Register Addon #################### -import sys -sys.path.insert(0, "/home/sofus/src/college/bsc_ge/thesis/code/.cached-dependencies") -## ^^ Placeholder +BL_REGISTER__BEFORE_DEPS = [ + *operators_nodeps.BL_REGISTER, + *preferences.BL_REGISTER, +] -#################### -# - Module Import -#################### -if "bpy" not in locals(): - import bpy - import nodeitems_utils - try: - from . import node_trees + +def BL_REGISTER__AFTER_DEPS(path_deps: Path): + with pydeps.importable_addon_deps(path_deps): + from . import node_trees, operators + return [ + *operators.BL_REGISTER, + *node_trees.BL_REGISTER, + ] + + +def BL_KEYMAP_ITEM_DEFS(path_deps: Path): + with pydeps.importable_addon_deps(path_deps): from . import operators - from . import preferences - except ImportError: - import sys - sys.path.insert(0, "/home/sofus/src/college/bsc_ge/thesis/code/blender-maxwell") - import node_trees - import operators - import preferences -else: - import importlib - - importlib.reload(node_trees) + return [ + *operators.BL_KMI_REGISTER, + ] #################### # - Registration #################### -BL_REGISTER = [ - *node_trees.BL_REGISTER, - *operators.BL_REGISTER, - *preferences.BL_REGISTER, -] -BL_KMI_REGISTER = [ - *operators.BL_KMI_REGISTER, -] -BL_NODE_CATEGORIES = [ - *node_trees.BL_NODE_CATEGORIES, -] - -km = bpy.context.window_manager.keyconfigs.addon.keymaps.new( - name='Node Editor', - space_type="NODE_EDITOR", -) -REGISTERED_KEYMAPS = [] def register(): - global REGISTERED_KEYMAPS - - for cls in BL_REGISTER: - bpy.utils.register_class(cls) - - for kmi_def in BL_KMI_REGISTER: - kmi = km.keymap_items.new( - *kmi_def["_"], - ctrl=kmi_def["ctrl"], - shift=kmi_def["shift"], - alt=kmi_def["alt"], - ) - REGISTERED_KEYMAPS.append(kmi) - -def unregister(): - for cls in reversed(BL_REGISTER): - bpy.utils.unregister_class(cls) - - for kmi in REGISTERED_KEYMAPS: - km.keymap_items.remove(kmi) + # Register Barebones Addon for Dependency Installation + registration.register_classes(BL_REGISTER__BEFORE_DEPS) -if __name__ == "__main__": - register() + # Retrieve PyDeps Path from Addon Preferences + addon_prefs = ADDON_PREFS() + path_pydeps = addon_prefs.path_addon_pydeps + + # If Dependencies are Satisfied, Register Everything + if pydeps.check_pydeps(path_pydeps): + registration.register_classes(BL_REGISTER__AFTER_DEPS()) + registration.register_keymap_items(BL_KEYMAP_ITEM_DEFS()) + else: + # Delay Registration + registration.delay_registration( + registration.EVENT__DEPS_SATISFIED, + classes_cb=BL_REGISTER__AFTER_DEPS, + keymap_item_defs_cb=BL_KEYMAP_ITEM_DEFS, + ) + + # TODO: A popup before the addon fully loads or something like that? + ## TODO: Communicate that deps must be installed and all that? + + +def unregister(): + registration.unregister_classes() + registration.unregister_keymap_items() diff --git a/src/blender_maxwell/blends/__init__.py b/src/blender_maxwell/blends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/blender_maxwell/blends/bl_append.py b/src/blender_maxwell/blends/bl_append.py new file mode 100644 index 0000000..e69de29 diff --git a/src/blender_maxwell/blends/starter.blend b/src/blender_maxwell/blends/starter.blend new file mode 100644 index 0000000..6397a7c --- /dev/null +++ b/src/blender_maxwell/blends/starter.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6475230422328c74d45fc9f4db25ca3d1d0ec49f6cc3f539a4ced68fd3a533fd +size 741696 diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/schemas/managed_obj.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/schemas/managed_obj.py index 925b6ad..da3cfa4 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/schemas/managed_obj.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/schemas/managed_obj.py @@ -1,13 +1,9 @@ import typing as typ -import typing as typx -import pydantic as pyd - -import bpy - -from ..bl import ManagedObjName, SocketName +from ..bl import ManagedObjName from ..managed_obj_type import ManagedObjType + class ManagedObj(typ.Protocol): managed_obj_type: ManagedObjType @@ -30,4 +26,3 @@ class ManagedObj(typ.Protocol): Else, do nothing. """ - pass diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/schemas/socket_def.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/schemas/socket_def.py index 3010eb8..33763db 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/schemas/socket_def.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/contracts/schemas/socket_def.py @@ -4,9 +4,9 @@ import bpy from ..socket_types import SocketType + @typ.runtime_checkable class SocketDef(typ.Protocol): socket_type: SocketType - - def init(self, bl_socket: bpy.types.NodeSocket) -> None: - ... + + def init(self, bl_socket: bpy.types.NodeSocket) -> None: ... diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py index e5eb923..c2c5d36 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/node_tree.py @@ -9,10 +9,12 @@ from . import contracts as ct #################### MemAddr = int + class DeltaNodeLinkCache(typ.TypedDict): added: set[MemAddr] removed: set[MemAddr] + class NodeLinkCache: def __init__(self, node_tree: bpy.types.NodeTree): # Initialize Parameters @@ -21,46 +23,47 @@ class NodeLinkCache: self.link_ptrs = set() self.link_ptrs_from_sockets = {} self.link_ptrs_to_sockets = {} - + # Fill Cache self.regenerate() - + def remove(self, link_ptrs: set[MemAddr]) -> None: for link_ptr in link_ptrs: self.link_ptrs.remove(link_ptr) self.link_ptrs_to_links.pop(link_ptr, None) - + def regenerate(self) -> DeltaNodeLinkCache: current_link_ptrs_to_links = { link.as_pointer(): link for link in self._node_tree.links } current_link_ptrs = set(current_link_ptrs_to_links.keys()) - + # Compute Delta added_link_ptrs = current_link_ptrs - self.link_ptrs removed_link_ptrs = self.link_ptrs - current_link_ptrs - + # Update Caches Incrementally self.remove(removed_link_ptrs) - + self.link_ptrs |= added_link_ptrs for link_ptr in added_link_ptrs: link = current_link_ptrs_to_links[link_ptr] - + self.link_ptrs_to_links[link_ptr] = link self.link_ptrs_from_sockets[link_ptr] = link.from_socket self.link_ptrs_to_sockets[link_ptr] = link.to_socket - - return {"added": added_link_ptrs, "removed": removed_link_ptrs} + + return {'added': added_link_ptrs, 'removed': removed_link_ptrs} + #################### # - Node Tree Definition #################### class MaxwellSimTree(bpy.types.NodeTree): bl_idname = ct.TreeType.MaxwellSim.value - bl_label = "Maxwell Sim Editor" + bl_label = 'Maxwell Sim Editor' bl_icon = ct.Icon.SimNodeEditor.value - + #################### # - Lock Methods #################### @@ -69,116 +72,117 @@ class MaxwellSimTree(bpy.types.NodeTree): node.locked = False for bl_socket in [*node.inputs, *node.outputs]: bl_socket.locked = False - + #################### # - Init Methods #################### def on_load(self): """Run by Blender when loading the NodeSimTree, ex. on file load, on creation, etc. . - + It's a bit of a "fake" function - in practicality, it's triggered on the first update() function. """ ## TODO: Consider tying this to an "on_load" handler - self._node_link_cache = NodeLinkCache(self) - - + if hasattr(self, '_node_link_cache'): + self._node_link_cache.regenerate() + else: + self._node_link_cache = NodeLinkCache(self) + #################### # - Update Methods #################### def sync_node_removed(self, node: bpy.types.Node): """Run by `Node.free()` when a node is being removed. - + Removes node input links from the internal cache (so we don't attempt to update non-existant sockets). """ for bl_socket in node.inputs.values(): # Retrieve Socket Links (if any) - self._node_link_cache.remove({ - link.as_pointer() - for link in bl_socket.links - }) + self._node_link_cache.remove( + {link.as_pointer() for link in bl_socket.links} + ) ## ONLY Input Socket Links are Removed from the NodeLink Cache ## - update() handles link-removal from still-existing node just fine. ## - update() does NOT handle link-removal of non-existant nodes. - + def update(self): """Run by Blender when 'something changes' in the node tree. - + Updates an internal node link cache, then updates sockets that just lost/gained an input link. """ - if not hasattr(self, "_node_link_cache"): + if not hasattr(self, '_node_link_cache'): self.on_load() ## We presume update() is run before the first link is altered. ## - Else, the first link of the session will not update caches. ## - We remain slightly unsure of the semantics. - ## - More testing needed to prevent this 'first-link bug'. + ## - Therefore, self.on_load() is also called as a load_post handler. return - + # Compute Changes to NodeLink Cache delta_links = self._node_link_cache.regenerate() - + link_alterations = { - "to_remove": [], - "to_add": [], + 'to_remove': [], + 'to_add': [], } - for link_ptr in delta_links["removed"]: - from_socket = self._node_link_cache.link_ptrs_from_sockets[link_ptr] + for link_ptr in delta_links['removed']: + from_socket = self._node_link_cache.link_ptrs_from_sockets[ + link_ptr + ] to_socket = self._node_link_cache.link_ptrs_to_sockets[link_ptr] - + # Update Socket Caches self._node_link_cache.link_ptrs_from_sockets.pop(link_ptr, None) self._node_link_cache.link_ptrs_to_sockets.pop(link_ptr, None) - + # Trigger Report Chain on Socket that Just Lost a Link ## Aka. Forward-Refresh Caches Relying on Linkage if not ( consent_removal := to_socket.sync_link_removed(from_socket) ): # Did Not Consent to Removal: Queue Add Link - link_alterations["to_add"].append((from_socket, to_socket)) - - for link_ptr in delta_links["added"]: + link_alterations['to_add'].append((from_socket, to_socket)) + + for link_ptr in delta_links['added']: link = self._node_link_cache.link_ptrs_to_links.get(link_ptr) - if link is None: continue - + if link is None: + continue + # Trigger Report Chain on Socket that Just Gained a Link ## Aka. Forward-Refresh Caches Relying on Linkage - - if not ( - consent_added := link.to_socket.sync_link_added(link) - ): + + if not (consent_added := link.to_socket.sync_link_added(link)): # Did Not Consent to Addition: Queue Remove Link - link_alterations["to_remove"].append(link) - + link_alterations['to_remove'].append(link) + # Execute Queued Operations ## - Especially undoing undesirable link changes. ## - This is important for locked graphs, whose links must not change. - for link in link_alterations["to_remove"]: + for link in link_alterations['to_remove']: self.links.remove(link) - for from_socket, to_socket in link_alterations["to_add"]: + for from_socket, to_socket in link_alterations['to_add']: self.links.new(from_socket, to_socket) - + # If Queued Operations: Regenerate Cache ## - This prevents the next update() from picking up on alterations. - if link_alterations["to_remove"] or link_alterations["to_add"]: + if link_alterations['to_remove'] or link_alterations['to_add']: self._node_link_cache.regenerate() + #################### # - Post-Load Handler #################### -def initialize_sim_tree_node_link_cache(scene: bpy.types.Scene): - """Whenever a file is loaded, create/regenerate the NodeLinkCache in all trees. - """ +def initialize_sim_tree_node_link_cache(_: bpy.types.Scene): + """Whenever a file is loaded, create/regenerate the NodeLinkCache in all trees.""" for node_tree in bpy.data.node_groups: - if node_tree.bl_idname == "MaxwellSimTree": - if not hasattr(node_tree, "_node_link_cache"): - node_tree._node_link_cache = NodeLinkCache(node_tree) - else: - node_tree._node_link_cache.regenerate() + if node_tree.bl_idname == 'MaxwellSimTree': + node_tree.on_load() + #################### # - Blender Registration #################### bpy.app.handlers.load_post.append(initialize_sim_tree_node_link_cache) +## TODO: Move to top-level registration. BL_REGISTER = [ MaxwellSimTree, diff --git a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py index 15bd0a1..9066877 100644 --- a/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py +++ b/src/blender_maxwell/node_trees/maxwell_sim_nodes/nodes/base.py @@ -1,12 +1,11 @@ -import uuid - -import typing as typ -import typing_extensions as typx -import json import inspect +import json +import typing as typ +import uuid import bpy import pydantic as pyd +import typing_extensions as typx from .. import contracts as ct from .. import sockets diff --git a/src/blender_maxwell/operators/__init__.py b/src/blender_maxwell/operators/__init__.py index 90a1f04..ee6095f 100644 --- a/src/blender_maxwell/operators/__init__.py +++ b/src/blender_maxwell/operators/__init__.py @@ -1,10 +1,6 @@ -from . import install_deps -from . import uninstall_deps from . import connect_viewer BL_REGISTER = [ - *install_deps.BL_REGISTER, - *uninstall_deps.BL_REGISTER, *connect_viewer.BL_REGISTER, ] BL_KMI_REGISTER = [ diff --git a/src/blender_maxwell/operators/uninstall_deps.py b/src/blender_maxwell/operators/bl_append.py similarity index 100% rename from src/blender_maxwell/operators/uninstall_deps.py rename to src/blender_maxwell/operators/bl_append.py diff --git a/src/blender_maxwell/operators/connect_viewer.py b/src/blender_maxwell/operators/connect_viewer.py index b76de95..fb94ecd 100644 --- a/src/blender_maxwell/operators/connect_viewer.py +++ b/src/blender_maxwell/operators/connect_viewer.py @@ -1,18 +1,19 @@ import bpy -class BlenderMaxwellConnectViewer(bpy.types.Operator): - bl_idname = "blender_maxwell.connect_viewer" - bl_label = "Connect Viewer to Active" - bl_description = "Connect active node to Viewer Node" + +class ConnectViewerNode(bpy.types.Operator): + bl_idname = 'blender_maxwell.connect_viewer_node' + bl_label = 'Connect Viewer to Active' + bl_description = 'Connect active node to Viewer Node' bl_options = {'REGISTER', 'UNDO'} - + @classmethod def poll(cls, context): space = context.space_data return ( space.type == 'NODE_EDITOR' and space.node_tree is not None - and space.node_tree.bl_idname == "MaxwellSimTreeType" + and space.node_tree.bl_idname == 'MaxwellSimTreeType' ) def invoke(self, context, event): @@ -24,44 +25,45 @@ class BlenderMaxwellConnectViewer(bpy.types.Operator): location=(mlocx, mlocy), ) select_node = context.selected_nodes[0] - + for node in node_tree.nodes: - if node.bl_idname == "ViewerNodeType": + if node.bl_idname == 'ViewerNodeType': viewer_node = node break else: - viewer_node = node_tree.nodes.new("ViewerNodeType") + viewer_node = node_tree.nodes.new('ViewerNodeType') viewer_node.location.x = select_node.location.x + 250 viewer_node.location.y = select_node.location.y select_node.select = False - + new_link = True for link in viewer_node.inputs[0].links: if link.from_node.name == select_node.name: new_link = False continue node_tree.links.remove(link) - + if new_link: node_tree.links.new(select_node.outputs[0], viewer_node.inputs[0]) return {'FINISHED'} + #################### # - Blender Registration #################### BL_REGISTER = [ - BlenderMaxwellConnectViewer, + ConnectViewerNode, ] BL_KMI_REGISTER = [ - dict( - _=( - BlenderMaxwellConnectViewer.bl_idname, - "LEFTMOUSE", - "PRESS", + { + '_': ( + ConnectViewerNode.bl_idname, + 'LEFTMOUSE', + 'PRESS', ), - ctrl=True, ## CTRL - shift=True, ## Shift - alt=False, ## Alt - ), + 'ctrl': True, + 'shift': True, + 'alt': False, + }, ] diff --git a/src/blender_maxwell/operators/install_deps.py b/src/blender_maxwell/operators/install_deps.py deleted file mode 100644 index 85f5209..0000000 --- a/src/blender_maxwell/operators/install_deps.py +++ /dev/null @@ -1,62 +0,0 @@ -import sys -import subprocess -from pathlib import Path - -import bpy - -from . import types - -class BlenderMaxwellInstallDependenciesOperator(bpy.types.Operator): - bl_idname = types.BlenderMaxwellInstallDependencies - bl_label = "Install Dependencies for Blender Maxwell Addon" - - def execute(self, context): - addon_dir = Path(__file__).parent.parent - requirements_path = addon_dir / 'requirements.txt' - #addon_specific_folder = addon_dir / '.dependencies' - addon_specific_folder = Path("/home/sofus/src/college/bsc_ge/thesis/code/.cached-dependencies") - - # Create the Addon-Specific Folder - addon_specific_folder.mkdir(parents=True, exist_ok=True) - - # Determine Path to Blender's Bundled Python - python_exec = Path(sys.executable) - ## bpy.app.binary_path_python was deprecated in 2.91. - ## sys.executable points to the correct bundled Python. - ## See - - # Install Dependencies w/Bundled pip - try: - subprocess.check_call([ - str(python_exec), '-m', - 'pip', 'install', - '-r', str(requirements_path), - '--target', str(addon_specific_folder), - ]) - self.report( - {'INFO'}, - "Dependencies for 'blender_maxwell' installed successfully." - ) - except subprocess.CalledProcessError as e: - self.report( - {'ERROR'}, - f"Failed to install dependencies: {str(e)}" - ) - return {'CANCELLED'} - - # Install Dependencies w/Bundled pip - if str(addon_specific_folder) not in sys.path: - sys.path.insert(0, str(addon_specific_folder)) - - return {'FINISHED'} - - - -#################### -# - Blender Registration -#################### -BL_REGISTER = [ - BlenderMaxwellInstallDependenciesOperator, -] - -BL_KMI_REGISTER = [] diff --git a/src/blender_maxwell/operators/types.py b/src/blender_maxwell/operators/types.py deleted file mode 100644 index 1ea1d70..0000000 --- a/src/blender_maxwell/operators/types.py +++ /dev/null @@ -1,9 +0,0 @@ -import bpy - -#################### -# - Blender Types -#################### -BlenderMaxwellInstallDependencies = "blender_maxwell.install_dependencies" -BlenderMaxwellUninstallDependencies = "blender_maxwell.uninstall_dependencies" -BlenderMaxwellConnectViewer = "blender_maxwell.connect_viewer" -BlenderMaxwellRefreshRDAuth = "blender_maxwell.refresh_td_auth" diff --git a/src/blender_maxwell/operators_nodeps/__init__.py b/src/blender_maxwell/operators_nodeps/__init__.py new file mode 100644 index 0000000..76a0da7 --- /dev/null +++ b/src/blender_maxwell/operators_nodeps/__init__.py @@ -0,0 +1,7 @@ +from . import install_deps +from . import uninstall_deps + +BL_REGISTER = [ + *install_deps.BL_REGISTER, + *uninstall_deps.BL_REGISTER, +] diff --git a/src/blender_maxwell/operators_nodeps/install_deps.py b/src/blender_maxwell/operators_nodeps/install_deps.py new file mode 100644 index 0000000..6521f9d --- /dev/null +++ b/src/blender_maxwell/operators_nodeps/install_deps.py @@ -0,0 +1,66 @@ +import subprocess +import sys +from pathlib import Path + +import bpy + +from .. import registration + + +class InstallPyDeps(bpy.types.Operator): + bl_idname = 'blender_maxwell.nodeps__install_py_deps' + bl_label = 'Install BLMaxwell Python Deps' + + path_addon_pydeps: bpy.props.StringProperty( + name='Path to Addon Python Dependencies' + ) + path_addon_reqs: bpy.props.StringProperty( + name='Path to Addon Python Dependencies' + ) + + def execute(self, _: bpy.types.Context): + path_addon_pydeps = Path(self.path_addon_pydeps) + path_addon_reqs = Path(self.path_addon_reqs) + + # Create the Addon-Specific Folder (if Needed) + ## It MUST, however, have a parent already + path_addon_pydeps.mkdir(parents=False, exist_ok=True) + + # Determine 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 + python_exec = Path(sys.executable) + + # Install Deps w/Bundled pip + try: + subprocess.check_call( + [ + str(python_exec), + '-m', + 'pip', + 'install', + '-r', + str(path_addon_reqs), + '--target', + str(path_addon_pydeps), + ] + ) + except subprocess.CalledProcessError as e: + msg = f'Failed to install dependencies: {str(e)}' + self.report({'ERROR'}, msg) + return {'CANCELLED'} + + registration.run_delayed_registration( + registration.EVENT__ON_DEPS_INSTALLED, + path_addon_pydeps, + ) + return {'FINISHED'} + + +#################### +# - Blender Registration +#################### +BL_REGISTER = [ + InstallPyDeps, +] diff --git a/src/blender_maxwell/operators_nodeps/uninstall_deps.py b/src/blender_maxwell/operators_nodeps/uninstall_deps.py new file mode 100644 index 0000000..3d98b4e --- /dev/null +++ b/src/blender_maxwell/operators_nodeps/uninstall_deps.py @@ -0,0 +1,37 @@ +import shutil + +import bpy + +from ..utils import pydeps +from .. import registration + + +class UninstallPyDeps(bpy.types.Operator): + bl_idname = 'blender_maxwell.nodeps__uninstall_py_deps' + bl_label = 'Uninstall BLMaxwell Python Deps' + + path_addon_pydeps: bpy.props.StringProperty( + name='Path to Addon Python Dependencies' + ) + + def execute(self, _: bpy.types.Context): + if ( + pydeps.check_pydeps() + and self.path_addon_pydeps.exists() + and self.path_addon_pydeps.is_dir() + ): + # CAREFUL!! + shutil.rmtree(self.path_addon_pydeps) + else: + msg = "Can't uninstall pydeps" + raise RuntimeError(msg) + + return {'FINISHED'} + + +#################### +# - Blender Registration +#################### +BL_REGISTER = [ + UninstallPyDeps, +] diff --git a/src/blender_maxwell/preferences.py b/src/blender_maxwell/preferences.py index 22aafa2..f6236b4 100644 --- a/src/blender_maxwell/preferences.py +++ b/src/blender_maxwell/preferences.py @@ -1,14 +1,173 @@ +import tomllib +from pathlib import Path + import bpy -from .operators import types as operators_types +from . import registration +from .operators_nodeps import install_deps, uninstall_deps +from .utils import logger as _logger +from .utils import pydeps +#################### +# - Constants +#################### +log = _logger.get() +PATH_ADDON_ROOT = Path(__file__).resolve().parent +with (PATH_ADDON_ROOT / 'pyproject.toml').open('rb') as f: + PROJ_SPEC = tomllib.load(f) + + +#################### +# - Preferences +#################### class BlenderMaxwellAddonPreferences(bpy.types.AddonPreferences): - bl_idname = "blender_maxwell" - - def draw(self, context): + bl_idname = PROJ_SPEC['project']['name'] ## MUST match addon package name + + #################### + # - Properties + #################### + # Default PyDeps Path + use_default_path_addon_pydeps: bpy.props.BoolProperty( + name='Use Default PyDeps Path', + description='Whether to use the default PyDeps path', + default=True, + update=lambda self, context: self.sync_use_default_path_addon_pydeps( + context + ), + ) + cache_path_addon_pydeps: bpy.props.StringProperty( + name='Cached Addon PyDeps Path', + default=(_default_pydeps_path := str(pydeps.DEFAULT_PATH_DEPS)), + ) ## Cache for use when toggling use of default pydeps path. + ## Must default to same as raw_path_* if default=True on use_default_* + + # Custom PyDeps Path + raw_path_addon_pydeps: bpy.props.StringProperty( + name='Addon PyDeps Path', + description='Path to Addon Python Dependencies', + subtype='FILE_PATH', + default=_default_pydeps_path, + update=lambda self, context: self.sync_path_addon_pydeps(context), + ) + prev_raw_path_addon_pydeps: bpy.props.StringProperty( + name='Previous Addon PyDeps Path', + default=_default_pydeps_path, + ) ## Use to restore raw_path_addon_pydeps after non-validated change. + + # TODO: LOGGING SETTINGS + + #################### + # - Property Sync + #################### + def sync_use_default_path_addon_pydeps(self, _: bpy.types.Context): + # Switch to Default + if self.use_default_path_addon_pydeps: + self.cache_path_addon_pydeps = self.raw_path_addon_pydeps + self.raw_path_addon_pydeps = str( + pydeps.DEFAULT_PATH_DEPS.resolve() + ) + + # Switch from Default + else: + self.raw_path_addon_pydeps = self.cache_path_addon_pydeps + self.cache_path_addon_pydeps = '' + + def sync_path_addon_pydeps(self, _: bpy.types.Context): + # Error if Default Path is in Use + if self.use_default_path_addon_pydeps: + self.raw_path_addon_pydeps = self.prev_raw_path_addon_pydeps + msg = "Can't update pydeps path while default path is being used" + raise ValueError(msg) + + # Error if Dependencies are All Installed + if pydeps.DEPS_OK: + self.raw_path_addon_pydeps = self.prev_raw_path_addon_pydeps + msg = "Can't update pydeps path while dependencies are installed" + raise ValueError(msg) + + # Update PyDeps + ## This also updates pydeps.DEPS_OK and pydeps.DEPS_ISSUES. + ## The result is used to run any delayed registrations... + ## ...which might be waiting for deps to be satisfied. + if pydeps.check_pydeps(self.path_addon_pydeps): + registration.run_delayed_registration( + registration.EVENT__DEPS_SATISFIED, + self.path_addon_pydeps, + ) + self.prev_raw_path_addon_pydeps = self.raw_path_addon_pydeps + + #################### + # - Property Methods + #################### + @property + def path_addon_pydeps(self) -> Path: + return Path(bpy.path.abspath(self.raw_path_addon_pydeps)) + + @path_addon_pydeps.setter + def path_addon_pydeps(self, value: Path) -> None: + self.raw_path_addon_pydeps = str(value.resolve()) + + #################### + # - UI + #################### + def draw(self, _: bpy.types.Context) -> None: layout = self.layout - layout.operator(operators_types.BlenderMaxwellInstallDependencies, text="Install Dependencies") - layout.operator(operators_types.BlenderMaxwellUninstallDependencies, text="Uninstall Dependencies") + num_pydeps_issues = ( + len(pydeps.DEPS_ISSUES) if pydeps.DEPS_ISSUES is not None else 0 + ) + + # Box: Dependency Status + box = layout.box() + ## Row: Header + row = box.row(align=True) + row.alignment = 'CENTER' + row.label(text='Addon-Specific Python Deps') + + ## Row: Toggle Default PyDeps Path + row = box.row(align=True) + row.enabled = not pydeps.DEPS_OK + row.prop( + self, + 'use_default_path_addon_pydeps', + 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_path_addon_pydeps + ) + row.prop(self, 'raw_path_addon_pydeps', text='PyDeps Install Path') + + ## Row: More Information Panel + row = box.row(align=True) + header, panel = row.panel('pydeps_issues', default_closed=True) + header.label(text=f'Dependency 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) + row.enabled = not pydeps.DEPS_OK + op = row.operator( + install_deps.InstallPyDeps.bl_idname, + text='Install PyDeps', + ) + op.path_addon_pydeps = str(self.path_addon_pydeps) + op.path_addon_reqs = str(pydeps.PATH_REQS) + + ## Row: Uninstall + row = box.row(align=True) + row.enabled = pydeps.DEPS_OK + op = row.operator( + uninstall_deps.UninstallPyDeps.bl_idname, + text='Uninstall PyDeps', + ) + op.path_addon_pydeps = str(self.path_addon_pydeps) + #################### # - Blender Registration diff --git a/src/blender_maxwell/registration.py b/src/blender_maxwell/registration.py new file mode 100644 index 0000000..5599b32 --- /dev/null +++ b/src/blender_maxwell/registration.py @@ -0,0 +1,113 @@ +import typing as typ +from pathlib import Path + +import bpy + +from .utils import logger as _logger + +log = _logger.get() + +# TODO: More types for these things! +DelayedRegKey: typ.TypeAlias = str +BLClass: typ.TypeAlias = typ.Any ## TODO: Better Type +BLKeymapItem: typ.TypeAlias = typ.Any ## TODO: Better Type +KeymapItemDef: typ.TypeAlias = typ.Any ## TODO: Better Type + +#################### +# - Globals +#################### +BL_KEYMAP: bpy.types.KeyMap | None = None + +REG__CLASSES: list[BLClass] = [] +REG__KEYMAP_ITEMS: list[BLKeymapItem] = [] + +DELAYED_REGISTRATIONS: dict[DelayedRegKey, typ.Callable[[Path], None]] = {} + +#################### +# - Constants +#################### +EVENT__DEPS_SATISFIED: str = 'on_deps_satisfied' + + +#################### +# - Class Registration +#################### +def register_classes(bl_register: list): + for cls in bl_register: + if cls.bl_idname in REG__CLASSES: + msg = f'Skipping register of {cls.bl_idname}' + log.info(msg) + continue + + bpy.utils.register_class(cls) + REG__CLASSES.append(cls) + + +def unregister_classes(): + for cls in reversed(REG__CLASSES): + bpy.utils.unregister_class(cls) + + REG__CLASSES.clear() + + +#################### +# - Keymap Registration +#################### +def register_keymap_items(keymap_item_defs: list[dict]): + # Lazy-Load BL_NODE_KEYMAP + global BL_KEYMAP # noqa: PLW0603 + if BL_KEYMAP is None: + BL_KEYMAP = bpy.context.window_manager.keyconfigs.addon.keymaps.new( + name='Node Editor', + space_type='NODE_EDITOR', + ) + + # Register Keymaps + for keymap_item_def in keymap_item_defs: + keymap_item = BL_KEYMAP.keymap_items.new( + *keymap_item_def['_'], + ctrl=keymap_item_def['ctrl'], + shift=keymap_item_def['shift'], + alt=keymap_item_def['alt'], + ) + REG__KEYMAP_ITEMS.append(keymap_item) + + + +def unregister_keymap_items(): + global BL_KEYMAP # noqa: PLW0603 + + # Unregister Keymaps + for keymap_item in reversed(REG__KEYMAP_ITEMS): + BL_KEYMAP.keymap_items.remove(keymap_item) + + # Lazy-Unload BL_NODE_KEYMAP + if BL_KEYMAP is not None: + REG__KEYMAP_ITEMS.clear() + BL_KEYMAP = None + + +#################### +# - Delayed Registration Semantics +#################### +def delay_registration( + delayed_reg_key: DelayedRegKey, + classes_cb: typ.Callable[[Path], list[BLClass]], + keymap_item_defs_cb: typ.Callable[[Path], list[KeymapItemDef]], +) -> None: + 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_deps: Path): + register_classes(classes_cb(path_deps)) + register_keymap_items(keymap_item_defs_cb(path_deps)) + + DELAYED_REGISTRATIONS[delayed_reg_key] = register_cb + + +def run_delayed_registration( + delayed_reg_key: DelayedRegKey, path_deps: Path +) -> None: + register_cb = DELAYED_REGISTRATIONS.pop(delayed_reg_key) + register_cb(path_deps) diff --git a/src/blender_maxwell/requirements.txt b/src/blender_maxwell/requirements.txt deleted file mode 100644 index 99e58a0..0000000 --- a/src/blender_maxwell/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -tidy3d==2.5.2 -pydantic==2.6.0 -sympy==1.12 -scipy==1.12.0 -trimesh==4.1.4 -networkx==3.2.1 -Rtree==1.2.0 diff --git a/src/blender_maxwell/utils/analyze_geonodes.py b/src/blender_maxwell/utils/analyze_geonodes.py index 17ed1a8..d836132 100644 --- a/src/blender_maxwell/utils/analyze_geonodes.py +++ b/src/blender_maxwell/utils/analyze_geonodes.py @@ -1,7 +1,5 @@ import typing_extensions as typx -import bpy - INVALID_BL_SOCKET_TYPES = { "NodeSocketGeometry", } diff --git a/src/blender_maxwell/utils/blender_type_enum.py b/src/blender_maxwell/utils/blender_type_enum.py index 253791e..194321b 100644 --- a/src/blender_maxwell/utils/blender_type_enum.py +++ b/src/blender_maxwell/utils/blender_type_enum.py @@ -1,5 +1,6 @@ import enum + class BlenderTypeEnum(str, enum.Enum): def _generate_next_value_(name, start, count, last_values): return name diff --git a/src/blender_maxwell/utils/extra_sympy_units.py b/src/blender_maxwell/utils/extra_sympy_units.py index b561c60..5a811ff 100644 --- a/src/blender_maxwell/utils/extra_sympy_units.py +++ b/src/blender_maxwell/utils/extra_sympy_units.py @@ -3,6 +3,7 @@ import functools import sympy as sp import sympy.physics.units as spu + #################### # - Useful Methods #################### diff --git a/src/blender_maxwell/utils/logger.py b/src/blender_maxwell/utils/logger.py new file mode 100644 index 0000000..9f56633 --- /dev/null +++ b/src/blender_maxwell/utils/logger.py @@ -0,0 +1,26 @@ +import logging + +LOGGER = logging.getLogger('blender_maxwell') + + +def get(): + if LOGGER is None: + # Set Sensible Defaults + LOGGER.setLevel(logging.DEBUG) + #FORMATTER = logging.Formatter( + # '%(asctime)-15s %(levelname)8s %(name)s %(message)s' + #) + + # Add Stream Handler + STREAM_HANDLER = logging.StreamHandler() + #STREAM_HANDLER.setFormatter(FORMATTER) + LOGGER.addHandler(STREAM_HANDLER) + + return LOGGER + +def set_level(level): + LOGGER.setLevel(level) +def enable_logfile(): + raise NotImplementedError +def disable_logfile(): + raise NotImplementedError diff --git a/src/blender_maxwell/utils/pydantic_sympy.py b/src/blender_maxwell/utils/pydantic_sympy.py index fbf62ac..454c5e5 100644 --- a/src/blender_maxwell/utils/pydantic_sympy.py +++ b/src/blender_maxwell/utils/pydantic_sympy.py @@ -1,10 +1,8 @@ -import typing as typ -import typing_extensions as typx - import pydantic as pyd -from pydantic_core import core_schema as pyd_core_schema import sympy as sp import sympy.physics.units as spu +import typing_extensions as typx +from pydantic_core import core_schema as pyd_core_schema from . import extra_sympy_units as spux @@ -45,8 +43,7 @@ class _SympyExpr: def validate_from_expr(value: AllowedSympyExprs) -> AllowedSympyExprs: if not ( - isinstance(value, sp.Expr) - or isinstance(value, sp.MatrixBase) + isinstance(value, sp.Expr | sp.MatrixBase) ): msg = f"Value {value} is not a `sympy` expression" raise ValueError(msg) @@ -98,8 +95,7 @@ def ConstrSympyExpr( ## - def validate_expr(expr: AllowedSympyExprs): if not ( - isinstance(expr, sp.Expr) - or isinstance(expr, sp.MatrixBase), + isinstance(expr, sp.Expr | sp.MatrixBase), ): ## NOTE: Must match AllowedSympyExprs union elements. msg = f"expr '{expr}' is not an allowed Sympy expression ({AllowedSympyExprs})" @@ -143,7 +139,7 @@ def ConstrSympyExpr( if ( allowed_matrix_shapes and isinstance(expr, sp.MatrixBase) - ) and not (expr.shape in allowed_matrix_shapes): + ) and expr.shape not in allowed_matrix_shapes: msgs.add(f"allowed_matrix_shapes={allowed_matrix_shapes} does not match expression {expr} with shape {expr.shape}") # Error or Return diff --git a/src/blender_maxwell/utils/pydeps.py b/src/blender_maxwell/utils/pydeps.py new file mode 100644 index 0000000..27cc79c --- /dev/null +++ b/src/blender_maxwell/utils/pydeps.py @@ -0,0 +1,117 @@ +import contextlib +import importlib.metadata +import os +import sys +from pathlib import Path + +from . import logger as _logger + +log = _logger.get() + +#################### +# - Constants +#################### +PATH_ADDON_ROOT = Path(__file__).resolve().parent.parent +PATH_REQS = PATH_ADDON_ROOT / 'requirements.txt' +DEFAULT_PATH_DEPS = PATH_ADDON_ROOT / '.addon_dependencies' +DEFAULT_PATH_DEPS.mkdir(exist_ok=True) + +#################### +# - Globals +#################### +DEPS_OK: bool | None = None +DEPS_ISSUES: list[str] | None = None + + +#################### +# - sys.path Context Manager +#################### +@contextlib.contextmanager +def importable_addon_deps(path_deps: Path): + os_path = os.fspath(path_deps) + sys.path.insert(0, os_path) + try: + yield + finally: + sys.path.remove(os_path) + + +#################### +# - Check PyDeps +#################### +def _check_pydeps( + path_requirementstxt: Path, + path_deps: Path, +) -> dict[str, tuple[str, str]]: + """Check if packages defined in a 'requirements.txt' file are currently installed. + + Returns a list of any issues (if empty, then all dependencies are correctly satisfied). + """ + + def conform_pypi_package_deplock(deplock: str): + """Conforms a == de-lock to match if pypi considers them the same (PyPi is case-insensitive and considers -/_ to be the same) + + See """ + return deplock.lower().replace('_', '-') + + with path_requirementstxt.open('r') as file: + required_depslock = { + conform_pypi_package_deplock(line) + for raw_line in file.readlines() + if (line := raw_line.strip()) and not line.startswith('#') + } + + # Investigate Issues + installed_deps = importlib.metadata.distributions( + path=[str(path_deps.resolve())] ## resolve() is just-in-case + ) + installed_depslock = { + conform_pypi_package_deplock( + f'{dep.metadata["Name"]}=={dep.metadata["Version"]}' + ) + for dep in installed_deps + } + + # Determine Missing/Superfluous/Conflicting + req_not_inst = required_depslock - installed_depslock + inst_not_req = installed_depslock - required_depslock + 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] + } + + # Assemble and Return Issues + 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 + ] + + +#################### +# - Refresh PyDeps +#################### +def check_pydeps(path_deps: Path): + global DEPS_OK # noqa: PLW0603 + global DEPS_ISSUES # noqa: PLW0603 + + if len(_issues := _check_pydeps(PATH_REQS, path_deps)) > 0: + #log.debug('Package Check Failed:', end='\n\t') + #log.debug(*_issues, sep='\n\t') + + DEPS_OK = False + DEPS_ISSUES = _issues + else: + DEPS_OK = True + DEPS_ISSUES = _issues + + return DEPS_OK diff --git a/src/blender_maxwell/utils/tdcloud.py b/src/blender_maxwell/utils/tdcloud.py index ccefa77..5c07c0b 100644 --- a/src/blender_maxwell/utils/tdcloud.py +++ b/src/blender_maxwell/utils/tdcloud.py @@ -2,10 +2,10 @@ - SimulationTask: - Tidy3D Stub: """ -from dataclasses import dataclass -import typing as typ -import functools import datetime as dt +import functools +import typing as typ +from dataclasses import dataclass import tidy3d as td import tidy3d.web as td_web @@ -284,7 +284,7 @@ class TidyCloudTasks: raise RuntimeError(msg) # Upload Simulation to Cloud Task - if not upload_progress_cb is None: + if upload_progress_cb is not None: upload_progress_cb = lambda uploaded_bytes: None try: cloud_task.upload_simulation(