feat: Better exceptions and parsing.

main
Sofus Albert Høgsbro Rose 2025-01-14 09:20:36 +01:00
parent 8ba4302b64
commit 8220491d50
Signed by: so-rose
GPG Key ID: AD901CB0F3701434
6 changed files with 343 additions and 153 deletions

View File

@ -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)

View File

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

View File

@ -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()

View File

@ -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: <https://docs.blender.org/manual/en/latest/advanced/extensions/licenses.html>'
)
## 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 = ["<current_year> <proj_name> 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

View File

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

View File

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