2025-12-01
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import sys
|
||||
import pathlib
|
||||
from typing import *
|
||||
import bpy
|
||||
import typing
|
||||
import types
|
||||
import importlib
|
||||
from . import prefs, logging
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Wildcard:
|
||||
pass
|
||||
|
||||
|
||||
class DoNotMatch:
|
||||
pass
|
||||
|
||||
|
||||
MatchCriteriaType = typing.Union[
|
||||
str, typing.List[str], typing.Type[Wildcard], typing.Type[DoNotMatch]
|
||||
]
|
||||
"""
|
||||
The MatchCriteriaType is a type definition for the parameters of the `hook` decorator.
|
||||
|
||||
The matching parameters can use multiple types to detect how the matching criteria
|
||||
would work.
|
||||
|
||||
* `str`: would perform an exact string match.
|
||||
* `typing.Iterator[str]`: would perform an exact string match with any of the given strings.
|
||||
* `typing.Type[Wildcard]`: would match any type for this parameter. This would be used so a hook
|
||||
is called for any value.
|
||||
* `typing.Type[DoNotMatch]`: would ignore this hook when matching the hook parameter. This is the default
|
||||
value for the matching criteria and would normally not be set directly in a
|
||||
production configuration.
|
||||
"""
|
||||
|
||||
MatchingRulesType = typing.Dict[str, MatchCriteriaType]
|
||||
"""
|
||||
Hooks are stored as `_asset_pipeline_rules' attribute on the function.
|
||||
The MatchingRulesType is the type definition of the `_asset_pipeline_rules` attribute.
|
||||
"""
|
||||
|
||||
HookFunction = typing.Callable[[typing.Any], None]
|
||||
|
||||
|
||||
def _match_hook_parameter(
|
||||
hook_criteria: MatchCriteriaType, match_query: typing.Optional[str]
|
||||
) -> bool:
|
||||
if hook_criteria == None:
|
||||
return True
|
||||
if hook_criteria == DoNotMatch:
|
||||
return match_query is None
|
||||
if hook_criteria == Wildcard:
|
||||
return True
|
||||
if isinstance(hook_criteria, str):
|
||||
return match_query == hook_criteria
|
||||
if isinstance(hook_criteria, list):
|
||||
return match_query in hook_criteria
|
||||
return False
|
||||
|
||||
|
||||
class Hooks:
|
||||
def __init__(self):
|
||||
self._hooks: typing.List[HookFunction] = []
|
||||
|
||||
def matches(
|
||||
self,
|
||||
hook: HookFunction,
|
||||
merge_mode: typing.Optional[str] = None,
|
||||
merge_status: typing.Optional[str] = None,
|
||||
**kwargs: typing.Optional[str],
|
||||
) -> bool:
|
||||
assert not kwargs
|
||||
rules = typing.cast(MatchingRulesType, getattr(hook, '_asset_pipeline_rules'))
|
||||
return all(
|
||||
(
|
||||
_match_hook_parameter(rules['merge_mode'], merge_mode),
|
||||
_match_hook_parameter(rules['merge_status'], merge_status),
|
||||
)
|
||||
)
|
||||
|
||||
def filter(self, **kwargs: typing.Optional[str]) -> typing.Iterator[HookFunction]:
|
||||
for hook in self._hooks:
|
||||
if self.matches(hook=hook, **kwargs):
|
||||
yield hook
|
||||
|
||||
def execute_hooks(
|
||||
self, merge_mode: str = None, merge_status: str = None, *args, **kwargs
|
||||
) -> None:
|
||||
for hook in self._hooks:
|
||||
if self.matches(hook, merge_mode=merge_mode, merge_status=merge_status):
|
||||
hook(*args, **kwargs)
|
||||
|
||||
def import_hook(self, path: List[str]) -> None:
|
||||
logger = logging.get_logger()
|
||||
with SystemPathInclude(path) as _include:
|
||||
try:
|
||||
import hooks as production_hooks
|
||||
|
||||
importlib.reload(production_hooks)
|
||||
self.register_hooks(production_hooks)
|
||||
except ModuleNotFoundError:
|
||||
logger.warning(f"Did not find `hooks.py` configuration file at {path}")
|
||||
|
||||
def load_hooks(self, context):
|
||||
prod_hooks = get_production_hook_dir()
|
||||
asset_hooks = get_asset_hook_dir()
|
||||
for path in [prod_hooks.resolve().__str__(), asset_hooks.resolve().__str__()]:
|
||||
self.import_hook([path])
|
||||
|
||||
def register(self, func: HookFunction) -> None:
|
||||
if func not in self._hooks:
|
||||
self._hooks.append(func)
|
||||
|
||||
def register_hooks(self, module: types.ModuleType) -> None:
|
||||
"""
|
||||
Register all hooks inside the given module.
|
||||
"""
|
||||
for module_item_str in dir(module):
|
||||
module_item = getattr(module, module_item_str)
|
||||
if not isinstance(module_item, types.FunctionType):
|
||||
continue
|
||||
if module_item.__module__ != module.__name__:
|
||||
continue
|
||||
if not hasattr(module_item, "_asset_pipeline_rules"):
|
||||
continue
|
||||
self.register(module_item)
|
||||
|
||||
|
||||
def get_production_hook_dir() -> Path:
|
||||
root_dir = Path(prefs.project_root_dir_get())
|
||||
asset_dir = root_dir.joinpath("svn/pro/")
|
||||
if not asset_dir.exists():
|
||||
raise Exception(f"Directory {str(asset_dir)} doesn't exist")
|
||||
hook_dir = asset_dir.joinpath("config/asset_pipeline")
|
||||
hook_dir.mkdir(parents=True, exist_ok=True)
|
||||
return hook_dir
|
||||
|
||||
|
||||
def get_asset_hook_dir() -> Path:
|
||||
return Path(bpy.data.filepath).parent
|
||||
|
||||
|
||||
def hook(
|
||||
merge_mode: MatchCriteriaType = None,
|
||||
merge_status: MatchCriteriaType = None,
|
||||
) -> typing.Callable[[types.FunctionType], types.FunctionType]:
|
||||
"""
|
||||
Decorator to add custom logic when pushing/pulling an asset.
|
||||
|
||||
Hooks are used to extend the configuration that would be not part of the core logic of the asset pipeline.
|
||||
"""
|
||||
rules = {
|
||||
'merge_mode': merge_mode,
|
||||
'merge_status': merge_status,
|
||||
}
|
||||
|
||||
def wrapper(func: types.FunctionType) -> types.FunctionType:
|
||||
setattr(func, '_asset_pipeline_rules', rules)
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class SystemPathInclude:
|
||||
"""
|
||||
Resource class to temporary include system paths to `sys.paths`.
|
||||
|
||||
Usage:
|
||||
```
|
||||
paths = [pathlib.Path("/home/guest/my_python_scripts")]
|
||||
with SystemPathInclude(paths) as t:
|
||||
import my_module
|
||||
reload(my_module)
|
||||
```
|
||||
|
||||
It is possible to nest multiple SystemPathIncludes.
|
||||
"""
|
||||
|
||||
def __init__(self, paths_to_add: List[pathlib.Path]):
|
||||
# TODO: Check if all paths exist and are absolute.
|
||||
self.__paths = paths_to_add
|
||||
self.__original_sys_path: List[str] = []
|
||||
|
||||
def __enter__(self):
|
||||
self.__original_sys_path = sys.path
|
||||
new_sys_path = []
|
||||
for path_to_add in self.__paths:
|
||||
# Do not add paths that are already in the sys path.
|
||||
path_to_add_str = str(path_to_add)
|
||||
if path_to_add_str in self.__original_sys_path:
|
||||
continue
|
||||
new_sys_path.append(path_to_add_str)
|
||||
new_sys_path.extend(self.__original_sys_path)
|
||||
sys.path = new_sys_path
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
sys.path = self.__original_sys_path
|
||||
Reference in New Issue
Block a user