From 8220491d5021a87754e2bfe0eb08a1d09d2122f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sofus=20Albert=20H=C3=B8gsbro=20Rose?= Date: Tue, 14 Jan 2025 09:20:36 +0100 Subject: [PATCH] feat: Better exceptions and parsing. --- blext/cli.py | 142 ++++++------ blext/exceptions.py | 94 ++++++++ blext/finders.py | 24 +-- blext/spec.py | 202 ++++++++++++------ .../services/init_settings.py | 29 +-- pyproject.toml | 5 +- 6 files changed, 343 insertions(+), 153 deletions(-) create mode 100644 blext/exceptions.py diff --git a/blext/cli.py b/blext/cli.py index 4f00192..7d43533 100644 --- a/blext/cli.py +++ b/blext/cli.py @@ -19,12 +19,13 @@ import os import subprocess import sys -import typing as typ from pathlib import Path import cyclopts +import pydantic as pyd import rich +from . import exceptions as exc from . import finders, pack, spec, supported, wheels console = rich.console.Console() @@ -42,6 +43,39 @@ PATH_BL_INIT_PY: Path = ( ) +def _parse( + *, + bl_platform: supported.BLPlatform | None = None, + proj_path: Path | None = None, + release_profile: supported.ReleaseProfile = supported.ReleaseProfile.Release, + detect_bl_platform: bool = True, +) -> tuple[supported.BLPlatform | None, spec.BLExtSpec]: + # Parse CLI + with exc.handle(exc.pretty, ValueError): + path_proj_spec = finders.find_proj_spec(proj_path) + + # Parse Blender Extension Specification + with exc.handle(exc.pretty, ValueError, pyd.ValidationError): + universal_blext_spec = spec.BLExtSpec.from_proj_spec( + path_proj_spec=path_proj_spec, + release_profile=release_profile, + ) + + # Deduce BLPlatform + if bl_platform is not None: + _bl_platform = bl_platform + elif bl_platform is None and detect_bl_platform: + with exc.handle(exc.pretty, ValueError): + _bl_platform = finders.detect_local_blplatform() + else: + return (None, universal_blext_spec) + + return ( + _bl_platform, + universal_blext_spec.constrain_to_bl_platform(_bl_platform), + ) + + #################### # - Command: Build #################### @@ -50,8 +84,7 @@ def build( bl_platform: supported.BLPlatform | None = None, proj_path: Path | None = None, release_profile: supported.ReleaseProfile = supported.ReleaseProfile.Release, - _return_blext_spec: typ.Annotated[bool, cyclopts.Parameter(show=False)] = False, -) -> spec.BLExtSpec | None: +) -> None: """[Build] the extension to an installable `.zip` file. Parameters: @@ -60,25 +93,23 @@ def build( release_profile: The release profile to bake into the extension. """ # Parse CLI - if bl_platform is None: - bl_platform = finders.detect_local_blplatform() - path_proj_spec = finders.find_proj_spec(proj_path) - - # Parse Blender Extension Specification - blext_spec = spec.BLExtSpec.from_proj_spec( - path_proj_spec=path_proj_spec, - release_profile=release_profile, - ).constrain_to_bl_platform(bl_platform) - - # Download Wheels - wheels.download_wheels( - blext_spec, + bl_platform, blext_spec = _parse( bl_platform=bl_platform, - no_prompt=False, + proj_path=proj_path, + release_profile=release_profile, ) + # Download Wheels + with exc.handle(exc.pretty, ValueError): + wheels.download_wheels( + blext_spec, + bl_platform=bl_platform, + no_prompt=False, + ) + # Pack the Blender Extension - pack.pack_bl_extension(blext_spec, overwrite=True) + with exc.handle(exc.pretty, ValueError): + pack.pack_bl_extension(blext_spec, overwrite=True) # Validate the Blender Extension console.print() @@ -110,12 +141,11 @@ def build( if return_code == 0: console.print('[✔] Blender Extension Validation Succeeded') else: - msg = 'Packed extension did not validate' - raise ValueError(msg) - - if _return_blext_spec: - return blext_spec - return None + with exc.handle(exc.pretty, ValueError): + msgs = [ + 'Blender failed to validate the packed extension. For more information, see the validation logs (above).', + ] + raise ValueError(*msgs) #################### @@ -126,16 +156,24 @@ def dev( proj_path: Path | None = None, ) -> None: """Launch the local [dev] extension w/Blender.""" + # Parse CLI + release_profile = supported.ReleaseProfile.Dev + bl_platform, blext_spec = _parse( + bl_platform=None, + proj_path=proj_path, + release_profile=release_profile, + ) + # Build the Extension - blext_spec = build( + build( bl_platform=None, proj_path=proj_path, release_profile=supported.ReleaseProfile.Dev, - _return_blext_spec=True, ) # Find Blender - blender_exe = finders.find_blender_exe() + with exc.handle(exc.pretty, ValueError): + blender_exe = finders.find_blender_exe() # Launch Blender ## - Same as running 'blender --python ./blender_scripts/bl_init.py' @@ -188,17 +226,14 @@ def show_spec( proj_path: Path to a `pyproject.toml` or a folder containing a `pyproject.toml`, which specifies the Blender extension. release_profile: The release profile to bake into the extension. """ - # Parse BLExtSpec - path_proj_spec = finders.find_proj_spec(proj_path) - blext_spec = spec.BLExtSpec.from_proj_spec( - path_proj_spec=path_proj_spec, + # Parse CLI + _, blext_spec = _parse( + bl_platform=bl_platform, + proj_path=proj_path, release_profile=release_profile, + detect_bl_platform=False, ) - # Constrain BLExtSpec to BLPlatform - if bl_platform is not None: - blext_spec = blext_spec.constrain_to_bl_platform(bl_platform) - # Show BLExtSpec rich.print(blext_spec) @@ -206,8 +241,8 @@ def show_spec( #################### # - Command: Show Manifest #################### -@app_show.command(name='bl_manifest', group='Information') -def show_bl_manifest( +@app_show.command(name='manifest', group='Information') +def show_manifest( bl_platform: supported.BLPlatform | None = None, proj_path: Path | None = None, release_profile: supported.ReleaseProfile = supported.ReleaseProfile.Release, @@ -220,20 +255,13 @@ def show_bl_manifest( release_profile: The release profile to bake into the extension. """ # Parse CLI - if bl_platform is None: - bl_platform = finders.detect_local_blplatform() - path_proj_spec = finders.find_proj_spec(proj_path) - - # Parse Blender Extension Specification - blext_spec = spec.BLExtSpec.from_proj_spec( - path_proj_spec=path_proj_spec, + _, blext_spec = _parse( + bl_platform=bl_platform, + proj_path=proj_path, release_profile=release_profile, + detect_bl_platform=False, ) - # [Maybe] Constrain BLExtSpec to BLPlatform - if bl_platform is not None: - blext_spec = blext_spec.constrain_to_bl_platform(bl_platform) - # Show BLExtSpec rich.print(blext_spec.manifest_str) @@ -252,20 +280,13 @@ def show_init_settings( release_profile: The release profile to bake into the extension. """ # Parse CLI - if bl_platform is None: - bl_platform = finders.detect_local_blplatform() - path_proj_spec = finders.find_proj_spec(proj_path) - - # Parse Blender Extension Specification - blext_spec = spec.BLExtSpec.from_proj_spec( - path_proj_spec=path_proj_spec, + _, blext_spec = _parse( + bl_platform=bl_platform, + proj_path=proj_path, release_profile=release_profile, + detect_bl_platform=False, ) - # [Maybe] Constrain BLExtSpec to BLPlatform - if bl_platform is not None: - blext_spec = blext_spec.constrain_to_bl_platform(bl_platform) - # Show BLExtSpec rich.print(blext_spec.init_settings_str) @@ -276,7 +297,8 @@ def show_init_settings( @app_show.command(name='path_blender', group='Paths') def show_path_blender() -> None: """Display the found path to the Blender executable.""" - blender_exe = finders.find_blender_exe() + with exc.handle(exc.pretty, ValueError): + blender_exe = finders.find_blender_exe() rich.print(blender_exe) diff --git a/blext/exceptions.py b/blext/exceptions.py new file mode 100644 index 0000000..78041ce --- /dev/null +++ b/blext/exceptions.py @@ -0,0 +1,94 @@ +# blext +# Copyright (C) 2025 blext 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 . + +"""Exception handling for enhancing the end-user experience of dealing with errors.""" + +import contextlib +import sys +from pathlib import Path + +import rich +import rich.markdown +import rich.padding +import pydantic as pyd + + +def sys_exit_1(): + sys.exit(1) + + +@contextlib.contextmanager +def handle( + ex_handler, + *exceptions, + after_ex=sys_exit_1, +): + try: + yield + except exceptions as ex: + ex_handler(ex) + after_ex() + + +#################### +# - Exception Handlers +#################### +def pretty(ex: Exception, show_filename_lineno: bool = False) -> None: + # Parse ValidationError + if isinstance(ex, pyd.ValidationError): + model_name = ex.title + ex_name = f'ValidationError{"s" if ex.error_count() > 1 else ""}' + + ex = ValueError( + *[ + f'{model_name}.' + + ', '.join([str(el) for el in err['loc']]) + + '=' + + str(err['input']) + + ': ' + + err['msg'] + for err in ex.errors() + ] + ) + else: + ex_name = ex.__class__.__name__ + + # Parse Filename and Line# + if show_filename_lineno: + _, exc_obj, exc_tb = sys.exc_info() + filename = ( + Path(exc_tb.tb_frame.f_code.co_filename).name + if exc_tb is not None + else None + ) + lineno = exc_tb.tb_lineno if exc_tb is not None else None + info = f' ({filename}|{lineno}):' if exc_tb is not None else ':' + else: + info = ':' + + # Format Message Tuple + messages = [ + rich.padding.Padding(rich.markdown.Markdown(arg), pad=(0, 0, 0, 4)) + for arg in ex.args + ] + + # Present + rich.print( + f'[bold red]{ex_name}[/bold red]', + info, + *messages, + sep='', + ) diff --git a/blext/finders.py b/blext/finders.py index 7d2eb95..161a8bb 100644 --- a/blext/finders.py +++ b/blext/finders.py @@ -66,21 +66,21 @@ def find_proj_spec(proj_path: Path | None) -> Path: proj_path: Search for a `pyproject.toml` project specification at this path. When `None`, search in the current working directory. """ + # Deduce Project Path if proj_path is None: path_proj_spec = Path.cwd() / 'pyproject.toml' - if not path_proj_spec.is_file(): - symptom = ( - 'does not exist' if not path_proj_spec.exists() else 'is not a file' - ) - msg = "Couldn't find 'pyproject.toml' in the current working directory" + elif proj_path.exists(): + if proj_path.is_file(): + path_proj_spec = proj_path + elif proj_path.is_dir(): + path_proj_spec = proj_path / 'pyproject.toml' - elif proj_path.is_dir() and (proj_path / 'pyproject.toml').is_file(): - path_proj_spec = proj_path / 'pyproject.toml' - - elif not proj_path.is_file(): - symptom = 'does not exist' if not proj_path.exists() else 'is not a file' - msg = f"Provided project specification {symptom} (tried to load '{proj_path}')" - raise ValueError(msg) + if not path_proj_spec.is_file(): + msgs = [ + f'No project specification found at `{path_proj_spec}`.', + 'Please verify the project path.', + ] + raise ValueError(*msgs) return path_proj_spec.resolve() diff --git a/blext/spec.py b/blext/spec.py index 8070d29..f40d471 100644 --- a/blext/spec.py +++ b/blext/spec.py @@ -27,6 +27,40 @@ import tomli_w from . import supported +ValidBLTags: typ.TypeAlias = typ.Literal[ + '3D View', + 'Add Curve', + 'Add Mesh', + 'Animation', + 'Bake', + 'Camera', + 'Compositing', + 'Development', + 'Game Engine', + 'Geometry Nodes', + 'Grease Pencil', + 'Import-Export', + 'Lighting', + 'Material', + 'Modeling', + 'Mesh', + 'Node', + 'Object', + 'Paint', + 'Pipeline', + 'Physics', + 'Render', + 'Rigging', + 'Scene', + 'Sculpt', + 'Sequencer', + 'System', + 'Text Editor', + 'Tracking', + 'User Interface', + 'UV', +] + #################### # - Path Mangling @@ -164,39 +198,7 @@ class BLExtSpec(pyd.BaseModel): # Addon Tags tags: tuple[ - typ.Literal[ - '3D View', - 'Add Curve', - 'Add Mesh', - 'Animation', - 'Bake', - 'Camera', - 'Compositing', - 'Development', - 'Game Engine', - 'Geometry Nodes', - 'Grease Pencil', - 'Import-Export', - 'Lighting', - 'Material', - 'Modeling', - 'Mesh', - 'Node', - 'Object', - 'Paint', - 'Pipeline', - 'Physics', - 'Render', - 'Rigging', - 'Scene', - 'Sculpt', - 'Sequencer', - 'System', - 'Text Editor', - 'Tracking', - 'User Interface', - 'UV', - ], + ValidBLTags, ..., ] = () license: tuple[str, ...] @@ -389,60 +391,83 @@ class BLExtSpec(pyd.BaseModel): ValueError: If the `pyproject.toml` file does not contain the required tables and/or fields. """ - # Parse Sections - ## Parse [project] + #################### + # - Parsing: Stage 1 + #################### + ###: Determine whether all fields are accessible. + + # Parse [project] if proj_spec.get('project') is not None: project = proj_spec['project'] else: - msg = "'pyproject.toml' MUST define '[project]' table" - raise ValueError(msg) + msgs = [ + f'In `{path_proj_root / "pyproject.toml"}`:', + '- `[project]` table is not defined.', + ] + raise ValueError(*msgs) - ## Parse [tool.blext] + # Parse [tool.blext] if ( proj_spec.get('tool') is not None - or proj_spec['tool'].get('blext') is not None + and proj_spec['tool'].get('blext') is not None ): blext_spec = proj_spec['tool']['blext'] else: - msg = "'pyproject.toml' MUST define '[tool.blext]' table" - raise ValueError(msg) + msgs = [ + f'In `{path_proj_root / "pyproject.toml"}`:', + '- `[tool.blext]` table is not defined.', + ] + raise ValueError(*msgs) - ## Parse [tool.blext.profiles] + # Parse [tool.blext.profiles] if proj_spec['tool']['blext'].get('profiles') is not None: release_profiles = blext_spec['profiles'] if release_profile in release_profiles: release_profile_spec = release_profiles[release_profile] else: - msg = f"To parse the profile '{release_profile}' from 'pyproject.toml', it MUST be defined as a key in '[tool.blext.profiles]'" - raise ValueError(msg) + msgs = [ + f'In `{path_proj_root / "pyproject.toml"}`:', + f'- `[tool.blext.profiles.{release_profile}]` table is not defined, yet `{release_profile}` is requested.', + ] + raise ValueError(*msgs) else: - msg = "'pyproject.toml' MUST define '[tool.blext.profiles]'" - raise ValueError(msg) + msgs = [ + f'In `{path_proj_root / "pyproject.toml"}`:', + '- `[tool.blext.profiles]` table is not defined.', + ] + raise ValueError(*msgs) - # Parse Values - ## Parse project.requires-python + #################### + # - Parsing: Stage 2 + #################### + ###: Parse values that require transformations. + + field_parse_errs = [] + + # Parse project.requires-python if project.get('requires-python') is not None: project_requires_python = project['requires-python'].replace('~= ', '') else: - msg = "'pyproject.toml' MUST define 'project.requires-python'" - raise ValueError(msg) + field_parse_errs.append('- `project.requires-python` is not defined.') - ## Parse project.maintainers[0] - if project.get('maintainers') is not None: + # Parse project.maintainers[0] + if project.get('maintainers') is not None and len(project['maintainers']) > 0: first_maintainer = project.get('maintainers')[0] else: first_maintainer = {'name': None, 'email': None} - ## Parse project.license + # Parse project.license if ( project.get('license') is not None and project['license'].get('text') is not None ): _license = project['license']['text'] else: - msg = "'pyproject.toml' MUST define 'project.license.text'" - raise ValueError(msg) + field_parse_errs.append('- `project.license.text` is not defined.') + field_parse_errs.append( + '- Please note that all Blender addons MUST have a GPL-compatible license: ' + ) ## Parse project.urls.homepage if ( @@ -453,13 +478,54 @@ class BLExtSpec(pyd.BaseModel): else: homepage = None - # Conform to BLExt Specification + #################### + # - Parsing: Stage 3 + #################### + ###: Parse field availability and provide for descriptive errors + + if blext_spec.get('platforms') is None: + field_parse_errs += ['- `[tool.blext.platforms]` is not defined.'] + if project.get('name') is None: + field_parse_errs += ['- `project.name` is not defined.'] + if blext_spec.get('pretty_name') is None: + field_parse_errs += ['- `tool.blext.pretty_name` is not defined.'] + if project.get('version') is None: + field_parse_errs += ['- `project.version` is not defined.'] + if project.get('description') is None: + field_parse_errs += ['- `project.description` is not defined.'] + if blext_spec.get('blender_version_min') is None: + field_parse_errs += ['- `tool.blext.blender_version_min` is not defined.'] + if blext_spec.get('blender_version_max') is None: + field_parse_errs += ['- `tool.blext.blender_version_max` is not defined.'] + if blext_spec.get('bl_tags') is None: + field_parse_errs += ['- `tool.blext.bl_tags` is not defined.'] + field_parse_errs += [ + '- Valid `bl_tags` values are: ' + + ', '.join([f'"{el}"' for el in typ.get_args(ValidBLTags)]) + ] + if blext_spec.get('copyright') is None: + field_parse_errs += ['- `tool.blext.copyright` is not defined.'] + field_parse_errs += [ + '- Example: `copyright = [" Contributors`' + ] + + if field_parse_errs: + msgs = [ + f'In `{path_proj_root / "pyproject.toml"}`:', + *field_parse_errs, + ] + raise ValueError(*msgs) + + #################### + # - Parsing: Stage 4 + #################### + ###: With guaranteed existance, do qualitative parsing w/pydantic. return cls( path_proj_root=path_proj_root, req_python_version=project_requires_python, - bl_platform_pypa_tags=blext_spec.get('platforms'), + bl_platform_pypa_tags=blext_spec['platforms'], # Path Locality - use_path_local=release_profile_spec.get('use_path_local'), + use_path_local=release_profile_spec.get('use_path_local', False), # File Logging use_log_file=release_profile_spec.get('use_log_file', False), log_file_path=release_profile_spec.get('log_file_path', 'addon.log'), @@ -468,20 +534,20 @@ class BLExtSpec(pyd.BaseModel): use_log_console=release_profile_spec.get('use_log_console', True), log_console_level=release_profile_spec.get('log_console_level', 'DEBUG'), # Basics - id=project.get('name'), - name=blext_spec.get('pretty_name'), - version=project.get('version'), - tagline=project.get('description'), + id=project['name'], + name=blext_spec['pretty_name'], + version=project['version'], + tagline=project['description'], maintainer=f'{first_maintainer["name"]} <{first_maintainer["email"]}>', # Blender Compatibility - blender_version_min=blext_spec.get('blender_version_min'), - blender_version_max=blext_spec.get('blender_version_max'), + blender_version_min=blext_spec['blender_version_min'], + blender_version_max=blext_spec['blender_version_max'], # Permissions permissions=blext_spec.get('permissions', {}), # Addon Tags - tags=blext_spec.get('bl_tags'), + tags=blext_spec['bl_tags'], license=(f'SPDX:{_license}',), - copyright=blext_spec.get('copyright'), + copyright=blext_spec['copyright'], website=homepage, ) @@ -499,14 +565,14 @@ class BLExtSpec(pyd.BaseModel): release_profile: The profile to load initial settings for. Raises: - ValueError: If the `pyproject.toml` file does not contain the required tables and/or fields. + ValueError: If the `pyproject.toml` file cannot be loaded, or it does not contain the required tables and/or fields. """ # Load File if path_proj_spec.is_file(): with path_proj_spec.open('rb') as f: proj_spec = tomllib.load(f) else: - msg = f"Could not load 'pyproject.toml' at '{path_proj_spec}" + msg = f'Could not load file: `{path_proj_spec}`' raise ValueError(msg) # Parse Extension Specification diff --git a/examples/simple/blext_simple_example/services/init_settings.py b/examples/simple/blext_simple_example/services/init_settings.py index 7e8ce7d..e873635 100644 --- a/examples/simple/blext_simple_example/services/init_settings.py +++ b/examples/simple/blext_simple_example/services/init_settings.py @@ -19,13 +19,14 @@ These settings are transported via a special TOML file that is packed into the extension zip-file when packaging for release. """ -import logging import tomllib import typing as typ from pathlib import Path import pydantic as pyd +from .. import __package__ as base_package + #################### # - Constants #################### @@ -46,18 +47,8 @@ StrLogLevel: typ.TypeAlias = typ.Literal[ ] -def str_to_loglevel(obj: typ.Any) -> LogLevel | typ.Any: - if isinstance(obj, str) and obj in STR_LOG_LEVEL: - return STR_LOG_LEVEL[obj] - return obj - - class InitSettings(pyd.BaseModel): - """Model describing addon initialization settings, describing default settings baked into the release. - - Each parameter - - """ + """Model describing addon initialization settings, describing default settings baked into the release.""" use_log_file: bool log_file_path: Path @@ -66,6 +57,20 @@ class InitSettings(pyd.BaseModel): use_log_console: bool log_console_level: StrLogLevel + @pyd.field_validator('log_file_path', mode='after') + @classmethod + def add_bpy_to_log_file_path(cls, value: Path, _: pyd.ValidationInfo) -> Path: + import bpy + + addon_dir = Path( + bpy.utils.extension_path_user( + base_package, + path='', + create=True, + ) + ) + return addon_dir / value + #################### # - Init Settings Management diff --git a/pyproject.toml b/pyproject.toml index e8b44b1..25827a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blext" -version = "0.1.0" +version = "0.2.0" description = "A fast, convenient project manager for Blender extensions." authors = [ { name = "Sofus Albert Høgsbro Rose", email = "blext@sofusrose.com" }, @@ -49,6 +49,9 @@ blext = "blext.cli:app" [tool.setuptools] packages = ["blext"] +[tool.hatch.build.targets.wheel] +packages = ["blext"] + [tool.uv] package = true