From 37119385658f0b94888a6d3a1191ec70b3a39425 Mon Sep 17 00:00:00 2001 From: Lucidiot Date: Sun, 25 Aug 2019 05:08:13 +0200 Subject: [PATCH] Base implementation --- website_generator/__main__.py | 29 +++++++++ website_generator/actions/__init__.py | 78 ++++++++++++++++++++++++ website_generator/config.py | 36 +++++++++++ website_generator/fields.py | 54 +++++++++++++++++ website_generator/generator.py | 87 +++++++++++++++++++++++++++ website_generator/utils.py | 31 ++++++++++ 6 files changed, 315 insertions(+) create mode 100755 website_generator/__main__.py create mode 100644 website_generator/actions/__init__.py create mode 100644 website_generator/config.py create mode 100644 website_generator/fields.py create mode 100644 website_generator/generator.py create mode 100644 website_generator/utils.py diff --git a/website_generator/__main__.py b/website_generator/__main__.py new file mode 100755 index 0000000..de6dae4 --- /dev/null +++ b/website_generator/__main__.py @@ -0,0 +1,29 @@ +import argparse +from website_generator import WebsiteGenerator + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + 'config', + type=argparse.FileType(), + help='Path to a YAML configuration file.', + ) + parser.add_argument( + '-t', '--tag', + type=str, + action='append', + help='Restrict execution of actions to one or more tags.', + dest='tags', + ) + return parser.parse_args() + + +def main(): + args = parse_args() + generator = WebsiteGenerator(args.config.read()) + generator() + + +if __name__ == '__main__': + main() diff --git a/website_generator/actions/__init__.py b/website_generator/actions/__init__.py new file mode 100644 index 0000000..c2ab7da --- /dev/null +++ b/website_generator/actions/__init__.py @@ -0,0 +1,78 @@ +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)) diff --git a/website_generator/config.py b/website_generator/config.py new file mode 100644 index 0000000..6dcdad8 --- /dev/null +++ b/website_generator/config.py @@ -0,0 +1,36 @@ +from typesystem.fields import Const +import typesystem +from website_generator.fields import PathField +from website_generator.actions import registry + +definitions = typesystem.SchemaDefinitions() + + +class ActionConfig(typesystem.Schema, definitions=definitions): + name = typesystem.String() + type = typesystem.Choice(choices=registry.keys()) + tags = typesystem.Array( + items=typesystem.String(), + unique_items=True, + allow_null=True, + default=list, + ) + + +class Config(typesystem.Schema, definitions=definitions): + # TODO: Use a Version field to parse into distutils.version.LooseVersion + version = Const(const='0.1') + content_root = PathField(allow_files=False, allow_folders=True) + output_root = PathField(allow_files=False, allow_folders=True) + public_root = PathField(must_exist=False, allow_null=True, default='/') + actions = typesystem.Array( + items=typesystem.Reference(to='ActionConfig'), + min_items=1, + ) + use = typesystem.Array( + items=typesystem.String(), + unique_items=True, + allow_null=True, + default=list, + ) + logging = typesystem.Object(additional_properties=True, allow_null=True) diff --git a/website_generator/fields.py b/website_generator/fields.py new file mode 100644 index 0000000..78d9ae7 --- /dev/null +++ b/website_generator/fields.py @@ -0,0 +1,54 @@ +from pathlib import Path +import typesystem + + +class PathField(typesystem.String): + + errors = { + 'type': 'Must be a string or instance of pathlib.Path.', + 'null': 'May not be null.', + 'blank': 'Must not be blank.', + 'max_length': 'Must have no more than {max_length} characters.', + 'min_length': 'Must have at least {min_length} characters.', + 'pattern': 'Must match the pattern /{pattern}/.', + 'format': 'Must be a valid {format}.', + 'not_found': 'Path does not exist.', + 'no_files': 'Path must not point to a file.', + 'no_dirs': 'Path must not point to a directory.', + 'no_symlinks': 'Path must not point to a symbolic link.', + } + + def __init__(self, + *, + must_exist=True, + allow_files=True, + allow_folders=False, + allow_symlinks=True, + **kwargs): + super().__init__(**kwargs) + self.must_exist = must_exist + self.allow_files = must_exist and allow_files + self.allow_folders = must_exist and allow_folders + self.allow_symlinks = must_exist and allow_symlinks + + def validate(self, value, *, strict=False): + if isinstance(value, Path): + value = str(value) + value = super().validate(value, strict=strict) + + if not value: + return value + value = Path(value) + + if not self.allow_symlinks and value.is_symlink(): + self.validation_error("no_symlinks") + + value = value.expanduser().resolve() + if self.must_exist and not value.exists(): + self.validation_error("not_found") + if not self.allow_files and value.is_file(): + self.validation_error("no_files") + if not self.allow_folders and value.is_dir(): + self.validation_error("no_dirs") + + return value diff --git a/website_generator/generator.py b/website_generator/generator.py new file mode 100644 index 0000000..8513feb --- /dev/null +++ b/website_generator/generator.py @@ -0,0 +1,87 @@ +from importlib import import_module +from itertools import chain +from time import time +from typing import Union +import logging.config +import typesystem +from website_generator.config import Config +from website_generator.actions import registry + +DEFAULT_LOGGING = { + 'version': 1, + 'formatters': { + 'print': { + 'format': '%(message)s', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'print', + 'level': 'INFO', + 'stream': 'ext://sys.stdout', + }, + }, + 'loggers': { + 'website_generator': { + 'level': 'INFO', + 'propagate': True, + 'handlers': ['console'], + }, + }, +} +logger = logging.getLogger(__name__) + + +class WebsiteGenerator(object): + + def __init__(self, config: Union[str, Config]): + if not isinstance(config, Config): + config = typesystem.validate_yaml( + config, + validator=Config, + ) + self.config = config + + def __call__(self, tags=[]): + logging.config.dictConfig(self.config.logging or DEFAULT_LOGGING) + + logger.debug('Configuration version {!s}'.format(self.config.version)) + logger.debug('Content root: {!s}'.format(self.config.content_root)) + logger.debug('Output root: {!s}'.format(self.config.output_root)) + + if self.config.use: + logger.debug('Loading modules…') + for name in self.config.use: + logger.debug('Loading {}'.format(name)) + import_module(name) + + action_configs = self.config.actions + if tags: + action_configs = list(filter( + lambda a: any((tag in a.tags for tag in tags)), + action_configs, + )) + else: + tags = list(set(chain(*[ + action.tags for action in action_configs + ]))) + + logger.info('Running {} actions'.format(len(action_configs))) + + start_time = time() + for action_config in action_configs: + logger.info(" {}…".format(action_config.name)) + action_class = registry[action_config.type] + action = action_class( + main_config=self.config, + **action_config, + ) + action() + logger.info(" {}: done".format(action_config.name)) + run_time = time() - start_time + + logger.info('Completed {} actions in {:.1f} seconds'.format( + len(action_configs), + run_time, + )) diff --git a/website_generator/utils.py b/website_generator/utils.py new file mode 100644 index 0000000..7b0a67a --- /dev/null +++ b/website_generator/utils.py @@ -0,0 +1,31 @@ +class Registry(dict): + + unregister = dict.__delitem__ + + def check_key(self, key): + pass + + def check_value(self, value): + pass + + def check(self, key, value): + try: + self.check_key(key) + except KeyError: + raise + except Exception as e: + raise KeyError(e.message) + + try: + self.check_value(value) + except ValueError: + raise + except Exception as e: + raise ValueError(e.message) + + def register(self, key, value): + self.__setitem__(key, value) + + def __setitem__(self, key, value): + self.check(key, value) + super().__setitem__(key, value)