oscillode/oscillode/utils/bl_cache/bl_prop.py

256 lines
8.7 KiB
Python

# oscillode
# Copyright (C) 2024 oscillode Project Contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Defines `BLProp`, a high-level wrapper for interacting with Blender properties."""
import dataclasses
import functools
import typing as typ
from blender_maxwell.utils import bl_instance, logger
from . import managed_cache
from .bl_prop_type import BLPropInfo, BLPropType
from .signal import Signal
log = logger.get(__name__)
####################
# - Blender Property (Abstraction)
####################
@dataclasses.dataclass(kw_only=True, frozen=True)
class BLProp:
"""A mid-level representation of a Blender property, which implements unsafe requests from flexible high-level fields using cache operations and normalized Blender type operations.
Attributes:
name: The name of the Blender property, as one uses it.
prop_info: Specifies the property's particular behavior, including subtype and UI.
prop_type: The type to associate with the property.
Especially relevant for structured deserialization.
bl_prop_type: Identifier encapsulating which Blender property used for data storage, and how.
"""
name: str
prop_info: BLPropInfo ## TODO: Validate / Typing
prop_type: type
bl_prop_type: BLPropType
####################
# - Computed
####################
@functools.cached_property
def bl_name(self):
"""Deduces the actual attribute name at which the Blender property will be available."""
return f'blfield__{self.name}'
@functools.cached_property
def enum_cache_key(self):
"""Deduces an attribute name for use by the persistent cache component of `EnumProperty.items`.
For dynamic enums, a persistent cache is not enough - a non-persistent cache must also be used to guarantee that returned strings will not dereference.
**Letting dynamic enum strings dereference causes Blender to crash**.
Use of a non-persistent cache alone introduces a massive startup burden, as _all_ of the potentially expensive `EnumProperty.items` methods must re-run.
Should any depend on ex. internet connectivity, which is no longer available, elaborate failure modes may trigger.
By using this key, we can persist `items` for re-caching on startup, to reap the benefits of both schemes and make dynamic `EnumProperty` usable in practice.
"""
return self.name + '__enum_cache'
@functools.cached_property
def str_cache_key(self):
"""Deduce an internal name for string-search names distinct from the property name.
Compared to dynamic enums, string-search names are very gentle.
However, the mechanism is otherwise almost same, so similar logic makes a lot of sense.
"""
return self.name + '__str_cache'
@functools.cached_property
def display_name(self):
"""Deduce a display name for the Blender property, assigned to the `name=` attribute."""
return (
'[JSON] ' if self.bl_prop_type == BLPropType.Serialized else ''
) + f'BLField: {self.name}'
@functools.cached_property
def is_enum_many(self):
return self.bl_prop_type in [BLPropType.SetEnum, BLPropType.SetDynEnum]
####################
# - Low-Level Methods
####################
def encode(self, value: typ.Any):
"""Encode a value for compatibility with this Blender property, using the encapsulated types.
A convenience method for `BLPropType.encode()`.
"""
return self.bl_prop_type.encode(value)
@functools.cached_property
def default_value(self) -> typ.Any:
return self.prop_info.get('default')
def decode(self, value: typ.Any):
"""Encode a value for compatibility with this Blender property, using the encapsulated types.
A convenience method for `BLPropType.decode()`.
"""
return self.bl_prop_type.decode(value, self.prop_type)
####################
# - Initialization
####################
def init_bl_type(
self,
bl_type: type[bl_instance.BLInstance],
depends_on: frozenset[str] = frozenset(),
enum_depends_on: frozenset[str] | None = None,
strsearch_depends_on: frozenset[str] | None = None,
) -> None:
"""Declare the Blender property on a Blender class, ensuring that the property will be available to all `bl_instance.BLInstance` respecting instances of that class.
- **Declare BLField**: Runs `bl_type.declare_blfield()` to ensure that `on_prop_changed` will invalidate the cache for this property.
- **Set Property**: Runs `bl_type.set_prop()` to ensure that the Blender property will be available on instances of `bl_type`.
Parameters:
obj_type: The exact object type that will be stored in the Blender property.
**Must** be chosen such that `BLPropType.from_type(obj_type) == self`.
"""
# Parse KWArgs for Blender Property
kwargs_prop = self.bl_prop_type.parse_kwargs(
self.prop_type,
self.prop_info,
)
# Set Blender Property
bl_type.declare_blfield(
self.name,
self.bl_name,
use_dynamic_enum=self.prop_info.get('enum_dynamic', False),
use_str_search=self.prop_info.get('str_search', False),
)
bl_type.set_prop(
self.bl_name,
self.bl_prop_type.bl_prop,
# Property Options
name=self.display_name,
**kwargs_prop,
) ## TODO: Parse __doc__ for property descs
for src_prop_name in depends_on:
bl_type.declare_blfield_dep(src_prop_name, self.name)
if self.prop_info.get('enum_dynamic', False) and enum_depends_on is not None:
for src_prop_name in enum_depends_on:
bl_type.declare_blfield_dep(
src_prop_name, self.name, method='reset_enum'
)
if self.prop_info.get('str_search', False) and strsearch_depends_on is not None:
for src_prop_name in strsearch_depends_on:
bl_type.declare_blfield_dep(
src_prop_name, self.name, method='reset_strsearch'
)
####################
# - Instance Methods
####################
def read_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> typ.Any:
"""Read the non-persistent cache value for this property.
Notes:
**Never reads cached BLPointers**; invokes `self.read()` instead.
`BLPointer`s must align perfectly with Blender's internal logic, and as such, the cache cannot get involved, else we risk access-after-free crashes.
Returns:
Generally, the cache value, with two exceptions.
- `Signal.CacheNotReady`: When either `bl_instance` is None, or it doesn't yet have a unique `bl_instance.instance_id`.
Indicates that the instance is not yet ready for use.
For nodes, `init()` has not yet run.
For sockets, `preinit()` has not yet run.
- `Signal.CacheEmpty`: When the cache has no entry.
A good idea might be to fill it immediately with `self.write_nonpersist(bl_instance)`.
"""
if self.bl_prop_type is BLPropType.BLPointer:
return self.read(bl_instance)
return managed_cache.read(
bl_instance,
self.bl_name,
use_nonpersist=True,
use_persist=False,
)
def read(self, bl_instance: bl_instance.BLInstance) -> typ.Any:
"""Read the persisted Blender property value for this property, from a particular `BLInstance`.
Parameters:
bl_instance: The Blender object to
**NOTE**: `bl_instance` must not be `None`, as neighboring methods sometimes allow.
"""
persisted_value = self.decode(
managed_cache.read(
bl_instance,
self.bl_name,
use_nonpersist=False,
use_persist=True,
)
)
if persisted_value is not Signal.CacheEmpty:
return persisted_value
msg = f"{self.name}: Can't read BLProp from instance {bl_instance}"
raise ValueError(msg)
def write(self, bl_instance: bl_instance.BLInstance, value: typ.Any) -> None:
managed_cache.write(
bl_instance,
self.bl_name,
self.encode(value),
use_nonpersist=False,
use_persist=True,
)
if self.bl_prop_type is BLPropType.BLPointer:
return
self.write_nonpersist(bl_instance, value)
def write_nonpersist(
self, bl_instance: bl_instance.BLInstance, value: typ.Any
) -> None:
if self.bl_prop_type is BLPropType.BLPointer:
return ## We can't always write here, so we can only do nothing.
managed_cache.write(
bl_instance,
self.bl_name,
value,
use_nonpersist=True,
use_persist=False,
)
def invalidate_nonpersist(self, bl_instance: bl_instance.BLInstance | None) -> None:
if self.bl_prop_type is BLPropType.BLPointer:
return
managed_cache.invalidate_nonpersist(
bl_instance,
self.bl_name,
)