__init__.py
Python script, ASCII text executable
1#!/usr/bin/env python3 2 3import os 4import re 5import shutil 6import contextlib 7from datetime import datetime 8import jinja2 9from ruamel.yaml import YAML 10import colorama 11 12 13colorama.init() 14 15 16def _no_date_constructor(loader, node): 17"""Function to prevent the YAML loader from converting dates, keeping them as strings, 18so they can be parsed in a more lenient way. 19""" 20value = loader.construct_scalar(node) 21return value 22 23 24@contextlib.contextmanager 25def in_directory(directory): 26"""Execute a block of code in a different directory. 27 28:param directory: The directory to change to. 29""" 30cwd = os.getcwd() 31os.chdir(directory) 32try: 33yield 34finally: 35os.chdir(cwd) 36 37 38def delete_directory_contents(directory): 39"""Delete all files and directories in a directory recursively, 40but not the directory itself. 41 42:param directory: The directory to clear. 43""" 44for root, dirs, files in os.walk(directory): 45for file in files: 46os.remove(os.path.join(root, file)) 47for dir in dirs: 48shutil.rmtree(os.path.join(root, dir)) 49 50 51def parse_date_string(date_string): 52"""Parse a date/time string into a datetime object. Supports multiple unambiguous formats. 53 54:param date_string: The date/time string to parse. 55:return: A datetime object representing the date/time string. 56""" 57def split_date_and_time(date_string): 58"""Split a date/time string into a date string and a time string. 59 60:param date_string: The date/time string to split. 61:return: A tuple containing the date and time strings. 62""" 63if ":" not in date_string: 64return date_string, "00:00:00" 65 66elements = date_string.partition(":") 67partition_character = " " 68if " " not in date_string: 69partition_character = "-" 70if "-" not in date_string: 71partition_character = "T" 72 73date = elements[0].rpartition(partition_character)[0].strip() 74time = elements[0].rpartition(partition_character)[2].strip() + elements[1] + elements[2].strip() 75time = time.removeprefix("T").removesuffix("Z") 76 77return date, time 78 79time_formats = [ 80# 24-hour ISO 81"%H:%M:%S", 82"%H:%M", 83"%H", 84# Single digit hour 85"-%H:%M:%S", 86"-%H:%M", 87"-%H", 88# 12-hour (AM/PM) 89"%I:%M:%S %p", 90"%I:%M %p", 91"%I %p", 92# Single digit 12-hour 93"-%I:%M:%S %p", 94"-%I:%M %p", 95"-%I %p", 96] 97 98date_formats = [ 99# ISO formats 100"%Y-%m-%d", 101"%y-%m-%d", 102# European formats 103"%d.%m.%Y", 104"%d.%m.%y", 105# American formats 106"%m/%d/%Y", 107"%m/%d/%y", 108# Text-based European formats 109"%d %B %Y", 110"%d %b %Y", 111"%d %B, %Y", 112"%d %b, %Y", 113# Text-based American formats 114"%B %d %Y", 115"%b %d %Y", 116"%B %d, %Y", 117"%b %d, %Y", 118# ISO weekly calendar 119"%G-W%V-%u", 120] 121 122date, time = split_date_and_time(date_string) 123 124time_object = datetime.min.time() 125date_object = datetime.min.date() 126 127for time_format in time_formats: 128try: 129time_object = datetime.strptime(time, time_format) 130except ValueError: 131pass 132for date_format in date_formats: 133try: 134date_object = datetime.strptime(date, date_format) 135except ValueError: 136pass 137 138return datetime.combine(date_object, time_object.time()) 139 140 141class Document: 142"""A type representing a document, which can be text or binary.""" 143def __init__(self, file_name, url_transform=lambda x: x): 144"""Create a new document object. 145 146:param file_name: The name of the file to read. 147:param url_transform: Function to change the file name to a different form. 148""" 149self.file_name = file_name 150self.encoding = "utf-8" 151# If the file is text, read it. 152self.front_matter = YAML() 153self.front_matter.Constructor.add_constructor("tag:yaml.org,2002:timestamp", _no_date_constructor) 154self.content = "" 155self.date = datetime.fromtimestamp(os.path.getmtime(file_name)) 156try: 157with open(file_name, "r", encoding=self.encoding) as f: 158print(colorama.Style.RESET_ALL, colorama.Style.BRIGHT, colorama.Fore.LIGHTWHITE_EX, f"Loading document {file_name}".ljust(shutil.get_terminal_size().columns), sep="") 159 160# Parse front matter if available. 161front_matter = "" 162initial_line = f.readline() 163if initial_line == "---\n": 164print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Front matter found", sep="") 165line = "" 166while line != "---\n": 167line = f.readline() 168if line != "---\n": 169front_matter += line 170print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Front matter loaded", sep="") 171 172if front_matter: 173self.front_matter = self.front_matter.load(front_matter) 174 175print(self.front_matter, type(self.front_matter)) 176 177if "DATE" in self.front_matter: 178self.date = parse_date_string(self.front_matter["DATE"]) 179else: # put it back 180self.content = initial_line 181 182print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Reading content", sep="") 183 184self.content += f.read() 185 186print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Content loaded", sep="") 187print(colorama.Style.RESET_ALL, colorama.Style.DIM, self.content[:128] + "..." if len(self.content) > 128 else self.content) 188except UnicodeDecodeError: 189print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Text decoding failed, assuming binary", sep="") 190self.encoding = None 191with open(file_name, "rb") as f: 192self.content = f.read() 193print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Binary content loaded", sep="") 194 195print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, colorama.Style.DIM, f"Transforming URL {self.file_name} ->", end=" ", sep="") 196self.file_name = url_transform(self.file_name) 197print(colorama.Style.RESET_ALL, colorama.Style.BRIGHT, colorama.Fore.LIGHTYELLOW_EX, self.file_name) 198 199print(colorama.Style.RESET_ALL, end="") 200 201def __repr__(self): 202return f"Document({self.file_name})" 203 204def __str__(self): 205return self.content 206 207def __getitem__(self, item): 208"""Get an item from the front matter of the document""" 209return self.front_matter[item] 210 211 212class Index: 213"""A type representing an index of documents.""" 214def __init__(self, directory, recursive=False, url_transform=lambda x: x, sort_by=lambda x: x.file_name, exclude=None): 215"""Create a new index object. 216 217:param directory: The directory to read the files from. 218:param recursive: Whether to read files from subdirectories. 219:param url_transform: Function to change the file name to a different form. 220:param sort_by: Function returning a key to sort the documents by. 221:param exclude: Regular expression to exclude files from the index. 222""" 223self.directory = directory 224# Temporarily move to the specified directory in order to read the files. 225if exclude: 226regex = re.compile(exclude) 227else: 228regex = re.compile("(?!)") 229with in_directory(directory): 230if recursive: 231self.file_names = [os.path.join(dir_path, f) for dir_path, dir_name, filenames in os.walk(".") for f in filenames if not regex.search(f)] 232else: 233self.file_names = [i for i in os.listdir() if os.path.isfile(i) and not regex.search(i)] 234 235self.documents = sorted([Document(i, url_transform) for i in self.file_names], key=sort_by) 236self.__current_index = 0 237 238def __iter__(self): 239self.__current_index = 0 240return self 241 242def __next__(self): 243if self.__current_index >= len(self.documents): 244raise StopIteration 245else: 246self.__current_index += 1 247return self.documents[self.__current_index - 1] 248 249def __repr__(self): 250return f"Index({self.directory}): {self.documents}" 251 252def __len__(self): 253return len(self.documents) 254 255 256class Site: 257"""A type representing a website.""" 258def __init__(self, build_dir, template_dir="templates"): 259"""Create a new site object. 260 261:param build_dir: The directory to build the site in. 262:param template_dir: The directory to read the templates from. 263""" 264self.build_dir = build_dir 265self.template_engine = jinja2.Environment(loader=jinja2.FileSystemLoader(template_dir)) 266self.pages = {} 267self.context = {} 268 269def add_page(self, location, page): 270"""Add a page to the site. 271 272:param location: The location the page should be saved to. 273:param page: The page object itself. 274""" 275if location.endswith("/"): 276location += "index.html" 277location = location.lstrip("/") # interpret it as site root, not OS root 278self.pages[location] = page 279 280def add_from_index(self, index, location, template=None, static=False, **kwargs): 281"""Add pages to the site from an index. 282 283:param index: The index to read the documents from. 284:param location: The location to save the pages to. 285:param template: The template to use for the pages. 286:param static: Whether to treat them as static files. 287:param kwargs: Additional keyword arguments to pass to the template when rendering. 288""" 289location = location.lstrip("/") # interpret it as site root, not OS root 290kwargs = {**self.context, **kwargs} 291if static: 292for document in index: 293self.pages[os.path.join(location, document.file_name)] = Static(self, document) 294else: 295for document in index: 296self.pages[os.path.join(location, document.file_name)] = Page(self, template, document, **kwargs) 297 298def filter(self, name): 299"""Decorator to add a filter to the template engine. 300 301:param name: The name the filter will be used with in Jinja2. 302""" 303def decorator(func): 304self.template_engine.filters[name] = func 305return func 306 307return decorator 308 309def test(self, name): 310"""Decorator to add a test to the template engine. 311 312:param name: The name the test will be used with in Jinja2. 313""" 314def decorator(func): 315self.template_engine.tests[name] = func 316return func 317 318return decorator 319 320def build(self): 321"""Build the site in its directory.""" 322# Clear the build directory if it exists. 323if os.path.isdir(self.build_dir): 324delete_directory_contents(self.build_dir) 325for location, page in self.pages.items(): 326# Create the required directories. 327os.makedirs(os.path.join(self.build_dir, os.path.dirname(location)), exist_ok=True) 328if isinstance(page, str): 329with open(os.path.join(self.build_dir, location), "w") as f: 330f.write(page) 331elif isinstance(page, bytes): 332with open(os.path.join(self.build_dir, location), "wb") as f: 333f.write(page) 334else: 335raise ValueError(f"{type(page)} cannot be used as a document") 336 337 338class Page(str): 339"""A type representing a page, which is a rendered template.""" 340def __new__(cls, site, template, document=None, **kwargs): 341kwargs = {**site.context, **kwargs} 342return site.template_engine.get_template(template).render(document=document, **kwargs) 343 344 345class Static(bytes): 346"""A type representing a static file, which is binary content that is not templated.""" 347def __new__(cls, site, document): 348return document.content 349