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