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