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