##
#
# Copyright (C) 2018 Matt Molyneaux
#
# This file is part of Exhibition.
#
# Exhibition is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Exhibition 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Exhibition. If not, see <https://www.gnu.org/licenses/>.
#
##
from http.server import HTTPServer, SimpleHTTPRequestHandler
from importlib import import_module
import io
import logging
import os
import pathlib
import shutil
import threading
from ruamel.yaml import YAML
logger = logging.getLogger("exhibition")
SITE_YAML_PATH = "site.yaml"
yaml_parser = YAML(typ="safe")
DATA_EXTRACTORS = {
".yaml": yaml_parser.load,
".json": yaml_parser.load,
}
[docs]class Config:
"""
Configuration object that implements a dict-like interface
If a key cannot be found in this instance, the parent :class:`Config` will
be searched (and its parent, etc.)
"""
def __init__(self, data=None, parent=None):
"""
:param data:
Can be one of a string, a file-like object, a dict-like object, or
``None``. The first two will be assumed as YAML
:param parent:
Parent :class:`Config` or ``None`` if this is the root configuration object
"""
self.parent = parent
self._base_config = {}
if data:
self.load(data)
[docs] def load(self, data):
"""
Load data into configutation object
:param data:
If a string or file-like object, ``data`` is parsed as if it were
YAML data. If a dict-like object, ``data`` is added to the internal
dictionary.
Otherwise an :class:`AssertionError` exception is raised
"""
if isinstance(data, (str, io.IOBase)):
self._base_config.update(yaml_parser.load(data))
elif isinstance(data, dict):
self._base_config.update(data)
else:
raise AssertionError("data needs to be a string, file-like, or dict-like object")
[docs] @classmethod
def from_path(cls, path):
"""Load YAML data from a file"""
with open(path) as f:
obj = cls(f)
return obj
def __getitem__(self, key):
try:
return self._base_config[key]
except KeyError:
if self.parent is None:
raise
else:
return self.parent[key]
def __setitem__(self, key, value):
self._base_config[key] = value
def __contains__(self, key):
return key in self.keys()
def __len__(self):
return len(list(self.keys()))
def __iter__(self):
return self.keys()
[docs] def keys(self):
_keys_set = set()
for k in self._base_config.keys():
_keys_set.add(k)
yield k
if self.parent is not None:
for k in self.parent.keys():
if k not in _keys_set:
_keys_set.add(k)
yield k
[docs] def values(self):
for k in self.keys():
yield self[k]
[docs] def items(self):
for k in self.keys():
yield (k, self[k])
[docs] def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
[docs] def update(self, *args, **kwargs):
self._base_config.update(*args, **kwargs)
[docs] def copy(self):
klass = type(self)
return klass(self._base_config.copy(), self.parent)
def __repr__(self):
return "<%s: %s>" % (self.__class__.__name__, self._base_config.keys())
[docs]class Node:
"""
A node represents a file or directory
"""
_meta_names = ["meta.yaml", "meta.yml"]
_index_file = "index.html"
_strip_exts = ["html"]
_meta_header = "---\n"
_meta_footer = "---\n"
_dir_mode = 0o755
_file_mode = 0o644
_content_start = None
_content = None
_data = None
_marks = None
def __init__(self, path, parent, meta=None):
"""
:param path:
A :class:`pathlib.Path` that is either the ``content_path`` or a child of it.
:param parent:
Either another :class:`Node` or ``None``
:param meta:
A dict-like object that will be passed to a :class:`Config` instance
"""
self.path_obj = path
self.parent = parent
self.children = {}
self.is_leaf = self.path_obj.is_file()
self._meta = Config({}, getattr(self.parent, "meta", None))
if meta:
self._meta.update(meta)
if self.parent:
self.parent.add_child(self)
[docs] @classmethod
def from_path(cls, path, parent=None, meta=None):
"""
Given a :class:`pathlib.Path`, create a Node from that path as well as
any children
If the path is not a file or a dir, an :class:`AssertionError` is raised
:param path:
A :class:`pathlib.Path` that is either the ``content_path`` or a child of it.
:param parent:
Either another :class:`Node` or ``None``
:param meta:
A dict-like object that will be passed to a :class:`Config` instance
"""
# path should be a pathlib object
assert path.is_file() or path.is_dir()
node = cls(path, parent=parent, meta=meta)
if path.is_dir():
for child in path.iterdir():
ignored = False
for glob in node.meta.get("ignore", []):
if child in path.glob(glob):
ignored = True
break
if ignored:
continue
if child.name in cls._meta_names and child.is_file():
with child.open() as co:
node.meta.load(co)
else:
cls.from_path(child, node)
return node
def __repr__(self):
return "<%s: %s>" % (self.__class__.__name__, self.path_obj.name)
@property
def full_path(self):
"""
Full path of node when deployed
"""
if self.parent is None:
return self.meta["deploy_path"]
else:
return str(pathlib.Path(self.parent.full_path, self.path_obj.name))
@property
def full_url(self):
"""Get full URL for node, including trailing slash"""
if self.parent is None:
base_url = self.meta.get("base_url", "/")
if not base_url.startswith("/"):
base_url = "/" + base_url
if not base_url.endswith("/"):
base_url = base_url + "/"
return base_url
elif self.is_leaf:
if self.path_obj.name == self._index_file:
name = ""
elif self.path_obj.suffix in self._strip_exts:
name = self.path_obj.stem
else:
name = self.path_obj.name
return "".join([self.parent.full_url, name])
else:
return "".join([self.parent.full_url, self.path_obj.name, "/"])
[docs] def walk(self, include_self=False):
"""Walk through Node tree"""
if include_self:
yield self
for child in self.children.values():
yield child
for grandchild in child.walk():
yield grandchild
[docs] def render(self):
"""
Process node and either create the directory or write contents of file to ``deploy_path``
"""
if not self.is_leaf:
pathlib.Path(self.full_path).mkdir(self._dir_mode)
return
file_obj = pathlib.Path(self.full_path)
file_obj.touch(self._file_mode)
content = self.get_content()
with file_obj.open("w" if type(content) is str else "wb") as fo:
fo.write(content)
[docs] def get_content(self):
"""
Get the actual content of the Node
First calls :meth:`process_meta` to find the end any front matter that
might be present and then returns the rest of the file
If ``filter`` has been specified in :attr:`meta`, that filter will be
used to further process the content.
"""
if self._content is not None:
return self._content
self.process_meta()
content_filter = self.meta.get("filter")
with self.path_obj.open("rb") as file_obj:
file_obj.seek(self._content_start)
self._content = file_obj.read()
try:
self._content = self._content.decode("utf-8")
except UnicodeDecodeError:
return self._content
if content_filter is not None:
filter_module = import_module(content_filter)
filter_glob = self.meta.get("filter-glob", filter_module.DEFAULT_GLOB)
if self.path_obj in self.path_obj.parent.glob(filter_glob):
self._content = filter_module.content_filter(self, self._content)
return self._content
@property
def meta(self):
"""
Configuration object
Automatically loads front-matter if applicable
"""
self.process_meta()
return self._meta
@property
def marks(self):
"""
Marked sections from content
Calls :meth:`get_content` to process content if that hasn't been done
already
"""
if self._marks is not None:
return self._marks
self._marks = {}
# make sure that _marks gets populated
self.get_content()
return self._marks
@property
def data(self):
"""Extracts data from contents of file
For example, a YAML file
"""
if self.path_obj.is_dir():
return
if self._data is None:
data = self.get_content()
func = DATA_EXTRACTORS[self.path_obj.suffix]
self._data = func(data)
return self._data
[docs] def add_child(self, child):
"""
Add a child to the current Node
If the child doesn't already have its :attr:`parent` set to this Node,
then an :class:`AssertionError` is raised.
"""
assert child.parent == self
self.children[child.path_obj.name] = child
@property
def siblings(self):
"""Returns all children of the parent Node, except for itself"""
return {k: v for k, v in self.parent.children.items() if v is not self}
[docs]def gen(settings):
"""
Generate site
Deletes ``deploy_path`` first.
"""
shutil.rmtree(settings["deploy_path"], True)
root_node = Node.from_path(pathlib.Path(settings["content_path"]), meta=settings)
for item in root_node.walk(True):
logger.info("Rendering %s", item.full_url)
item.render()
[docs]def serve(settings):
"""
Serves the generated site from ``deploy_path``
Respects settings like ``base_url`` if present.
"""
logger = logging.getLogger("exhibition.server")
class ExhibitionHTTPRequestHandler(SimpleHTTPRequestHandler):
def translate_path(self, path):
path = path.strip("/")
if settings.get("base_url"):
base = settings["base_url"].strip("/")
if not path.startswith(base):
return ""
path = path.lstrip(base).strip("/")
path = os.path.join(settings["deploy_path"], path)
return path
server_address = ('localhost', 8000)
httpd = HTTPServer(server_address, ExhibitionHTTPRequestHandler)
logger.warning("Listening on http://%s:%s", *server_address)
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
return (httpd, t)