diff --git a/website_generator/actions/__init__.py b/website_generator/actions/__init__.py index c2ab7da..5831071 100644 --- a/website_generator/actions/__init__.py +++ b/website_generator/actions/__init__.py @@ -1,78 +1 @@ -from abc import ABCMeta, abstractmethod -import json -from website_generator.utils import Registry - - -class ActionMetaclass(ABCMeta): - - def __new__(cls, name, *args, **kwargs): - newclass = ABCMeta.__new__(cls, name, *args, **kwargs) - if name != 'Action': - register(newclass) - return newclass - - -class Action(metaclass=ActionMetaclass): - - def __init__(self, **params): - for name, value in params.items(): - setattr(self, name, value) - - @abstractmethod - def __call__(self): - pass - - -class ActionRegistry(Registry): - - def check_key(self, key): - assert isinstance(key, str), 'Action type must be a string.' - - def check_value(self, value): - assert callable(value), 'Action must be a callable.' - - def register(self, key, value=None): - """ - Register a generator action. - May be used a register(action) if the action has a defined type - (using the action.type attribute), or as register(type, action) - or register(action, type) to set a custom type. - """ - if value: - try: - # Try register(type, action) - return super().register(key, value) - except (KeyError, ValueError): - # Try register(action, type) - try: - return super().register(value, key) - except (KeyError, ValueError): - pass - raise - - if not getattr(key, 'type', None): - raise KeyError( - 'Action does not have a type. ' - 'Set the type attribute or use register(type, action)' - ) - - return super().register(key.type, key) - - -registry = ActionRegistry() -register = registry.register -unregister = registry.unregister - - -class DebugAction(Action): - type = 'debug' - - def __call__(self): - def _default(obj): - if hasattr(obj, '__dict__'): - return vars(obj) - try: - return dict(obj) - except Exception: - return str(obj) - print(json.dumps(vars(self), default=_default, indent=4)) +from website_generator.actions.base import * # noqa: F401, F403 diff --git a/website_generator/actions/base.py b/website_generator/actions/base.py new file mode 100644 index 0000000..07e049e --- /dev/null +++ b/website_generator/actions/base.py @@ -0,0 +1,82 @@ +from abc import ABCMeta, abstractmethod +import json +from website_generator.utils import Registry + + +class ActionMetaclass(ABCMeta): + + def __new__(cls, name, *args, **kwargs): + newclass = ABCMeta.__new__(cls, name, *args, **kwargs) + if name != 'Action': + register(newclass) + return newclass + + +class Action(metaclass=ActionMetaclass): + + def __init__(self, **params): + for name, value in params.items(): + setattr(self, name, value) + + @abstractmethod + def __call__(self): + pass + + +class ActionRegistry(Registry): + + def check_key(self, key): + assert isinstance(key, str), 'Action type must be a string.' + + def check_value(self, value): + assert callable(value), 'Action must be a callable.' + + def register(self, key, value=None): + """ + Register a generator action. + May be used a register(action) if the action has a defined type + (using the action.type attribute), or as register(type, action) + or register(action, type) to set a custom type. + """ + if value: + try: + # Try register(type, action) + return super().register(key, value) + except (KeyError, ValueError): + # Try register(action, type) + try: + return super().register(value, key) + except (KeyError, ValueError): + pass + raise + + if not getattr(key, 'type', None): + raise KeyError( + 'Action does not have a type. ' + 'Set the type attribute or use register(type, action)' + ) + + return super().register(key.type, key) + + +registry = ActionRegistry() +register = registry.register +unregister = registry.unregister + + +class DebugAction(Action): + """ + A simple action that prints its execution context, + for debugging purposes. + """ + type = 'debug' + + def __call__(self): + def _default(obj): + if hasattr(obj, '__dict__'): + return vars(obj) + try: + return dict(obj) + except Exception: + return str(obj) + print(json.dumps(vars(self), default=_default, indent=4)) diff --git a/website_generator/actions/files.py b/website_generator/actions/files.py new file mode 100644 index 0000000..5399191 --- /dev/null +++ b/website_generator/actions/files.py @@ -0,0 +1,29 @@ +from website_generator.fields import PathMatcherField, PathField +from website_generator.config import ActionConfig +from website_generator.actions import Action +import shutil +import typesystem + + +class CopyActionConfig(ActionConfig): + src = PathMatcherField() + dest = PathField(allow_null=True, must_exist=False, allow_folders=True) + overwite = typesystem.Boolean(default=True) + + +class CopyAction(Action): + type = 'copy' + + def __init__(self, **params): + config = CopyActionConfig.validate(params) + params['src'] = config.src + params['dest'] = config.dest + params['overwrite'] = config.overwrite + super().__init__(**params) + + def __call__(self): + for source_path in self.src.all(): + if source_path.is_dir(): + shutil.copytree(str(source_path), str(self.dest)) + else: + shutil.copy2(str(source_path), str(self.dest)) diff --git a/website_generator/actions/jinja2.py b/website_generator/actions/jinja2.py new file mode 100644 index 0000000..d75f021 --- /dev/null +++ b/website_generator/actions/jinja2.py @@ -0,0 +1,44 @@ +from website_generator.fields import PathMatcherField, PathField +from website_generator.config import ActionConfig +from website_generator.actions import Action +from jinja2 import Environment, FilesystemLoader, select_autoescape +import typesystem + + +class JinjaActionConfig(ActionConfig): + template = PathMatcherField() + dest = PathField(allow_null=True, must_exist=False, allow_folders=True) + environment = typesystem.Object(additional_properties=True, default=dict) + + +class JinjaAction(Action): + type = 'jinja2' + + def __init__(self, **params): + config = JinjaActionConfig.validate(params) + params['template'] = config.template + params['dest'] = config.dest + super().__init__(**params) + + def build_environment(self): + """ + Build a Jinja2 environment for templates. + """ + return Environment( + loader=FilesystemLoader(self.main_config.content_root), + ) + + def render_template(self, env, template_path, dest_path): + template = env.get_template(template_path) + + def __call__(self): + env = self.build_environment() + if self.dest.is_dir() or str(self.dest).endswith('/'): + self.dest.mkdir(parents=True, exist_ok=True) + for template_path in self.src.all(): + self.render_template(env, template_path, self.dest / template_path.name) + else: + template_path = + template = env.get_template(template_path) + + diff --git a/website_generator/fields.py b/website_generator/fields.py index 78d9ae7..3711c0f 100644 --- a/website_generator/fields.py +++ b/website_generator/fields.py @@ -1,4 +1,5 @@ from pathlib import Path +from website_generator.utils import PathMatcher import typesystem @@ -52,3 +53,38 @@ class PathField(typesystem.String): self.validation_error("no_dirs") return value + + +class PathMatcherField(typesystem.Union): + + def __init__(self, *, **kwargs): + super().__init__([ + typesystem.String(), + typesystem.Array( + items=typesystem.String(), + min_items=1, + ), + typesystem.Object( + properties={ + 'include': typesystem.Union([ + typesystem.String(), + typesystem.Array( + items=typesystem.String(), + min_items=1, + ), + ]), + 'exclude': typesystem.Union([ + typesystem.String(), + typesystem.Array( + items=typesystem.String(), + min_items=1, + ), + ]), + }, + min_properties=1, + required=['include'], + ), + ], **kwargs) + + def validate(self, *args, **kwargs): + return PathMatcher(super().validate(*args, **kwargs)) diff --git a/website_generator/utils.py b/website_generator/utils.py index 7b0a67a..e69102f 100644 --- a/website_generator/utils.py +++ b/website_generator/utils.py @@ -1,3 +1,7 @@ +from glob import glob +import fnmatch + + class Registry(dict): unregister = dict.__delitem__ @@ -29,3 +33,61 @@ class Registry(dict): def __setitem__(self, key, value): self.check(key, value) super().__setitem__(key, value) + + +class PathMatcher(object): + """ + A configurable glob. + """ + + def __init__(self, config): + if isinstance(config, str): + self.include = [config, ] + elif isinstance(config, list): + self.include = config + elif not isinstance(config, dict): + raise ValueError('Invalid pattern configuration') + + if not config.get('include'): + raise ValueError('At least one inclusion pattern required') + elif isinstance(config['include'], str): + self.include = [config['include'], ] + elif isinstance(config['include'], list): + self.include = config['include'] + else: + raise ValueError( + '"include" must be a pattern or list of patterns' + ) + + config.setdefault('exclude', []) + if isinstance(config['exclude'], str): + self.exclude = [config['exclude'], ] + elif isinstance(config['exclude'], list): + self.exclude = config['exclude'] + else: + raise ValueError( + '"exclude" must be a pattern or list of patterns' + ) + + self._found = None + + def all(self): + if self._found is not None: + return self._found + self._found = set() + + for include_pattern in self.include: + self._found |= set(glob(include_pattern)) + + for exclude_pattern in self.exclude: + self._found -= set(fnmatch.filter(self._found, exclude_pattern)) + + self._found = list(map(Path, self._found)) + return self._found + + def get(self): + if not self.all(): + raise ValueError('No matching paths found') + if len(self.all()) != 1: + raise ValueError('More than one matching path found') + return self.all()[0]