By using this site, you agree to have cookies stored on your device, strictly for functional purposes, such as storing your session and preferences.

Dismiss

 __init__.py

View raw Download
text/x-script.python • 13.29 kiB
Python script, ASCII text executable
        
            
1
#!/usr/bin/env python3
2
3
import os
4
import re
5
import shutil
6
import contextlib
7
import typing
8
from datetime import datetime
9
import jinja2
10
from ruamel.yaml import YAML
11
import colorama
12
13
14
colorama.init()
15
16
17
def _no_date_constructor(loader, node):
18
"""Function to prevent the YAML loader from converting dates, keeping them as strings,
19
so they can be parsed in a more lenient way.
20
"""
21
value = loader.construct_scalar(node)
22
return value
23
24
25
@contextlib.contextmanager
26
def in_directory(directory):
27
"""Execute a block of code in a different directory.
28
29
:param directory: The directory to change to.
30
"""
31
cwd = os.getcwd()
32
os.chdir(directory)
33
try:
34
yield
35
finally:
36
os.chdir(cwd)
37
38
39
def delete_directory_contents(directory):
40
"""Delete all files and directories in a directory recursively,
41
but not the directory itself.
42
43
:param directory: The directory to clear.
44
"""
45
for root, dirs, files in os.walk(directory):
46
for file in files:
47
os.remove(os.path.join(root, file))
48
for dir in dirs:
49
shutil.rmtree(os.path.join(root, dir))
50
51
52
def 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
"""
58
def 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
"""
64
if ":" not in date_string:
65
return date_string, "00:00:00"
66
67
elements = date_string.partition(":")
68
partition_character = " "
69
if " " not in date_string:
70
partition_character = "-"
71
if "-" not in date_string:
72
partition_character = "T"
73
74
date = elements[0].rpartition(partition_character)[0].strip()
75
time = elements[0].rpartition(partition_character)[2].strip() + elements[1] + elements[2].strip()
76
time = time.removeprefix("T").removesuffix("Z")
77
78
return date, time
79
80
time_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
99
date_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
123
date, time = split_date_and_time(date_string)
124
125
time_object = datetime.min.time()
126
date_object = datetime.min.date()
127
128
for time_format in time_formats:
129
try:
130
time_object = datetime.strptime(time, time_format)
131
except ValueError:
132
pass
133
for date_format in date_formats:
134
try:
135
date_object = datetime.strptime(date, date_format)
136
except ValueError:
137
pass
138
139
return datetime.combine(date_object, time_object.time())
140
141
142
class Document:
143
"""A type representing a document, which can be text or binary."""
144
def __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
"""
150
self.file_name = file_name
151
self.encoding = "utf-8"
152
# If the file is text, read it.
153
self.front_matter = YAML()
154
self.front_matter.Constructor.add_constructor("tag:yaml.org,2002:timestamp", _no_date_constructor)
155
self.content = ""
156
self.date = datetime.fromtimestamp(os.path.getmtime(file_name))
157
try:
158
with open(file_name, "r", encoding=self.encoding) as f:
159
print(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.
162
front_matter = ""
163
if front_matter_enabled:
164
initial_line = f.readline()
165
if initial_line == "---\n":
166
print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Front matter found", sep="")
167
line = ""
168
while line != "---\n":
169
line = f.readline()
170
if line != "---\n":
171
front_matter += line
172
print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Front matter loaded", sep="")
173
174
if front_matter and front_matter_enabled:
175
self.front_matter = self.front_matter.load(front_matter)
176
177
print(self.front_matter, type(self.front_matter))
178
179
if "DATE" in self.front_matter:
180
self.date = parse_date_string(self.front_matter["DATE"])
181
elif front_matter_enabled: # put it back
182
self.content = initial_line
183
184
print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Reading content", sep="")
185
186
self.content += f.read()
187
188
print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Content loaded", sep="")
189
print(colorama.Style.RESET_ALL, colorama.Style.DIM, self.content[:128] + "..." if len(self.content) > 128 else self.content)
190
except UnicodeDecodeError:
191
print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Text decoding failed, assuming binary", sep="")
192
self.encoding = None
193
with open(file_name, "rb") as f:
194
self.content = f.read()
195
print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Binary content loaded", sep="")
196
197
print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, colorama.Style.DIM, f"Transforming URL {self.file_name} ->", end=" ", sep="")
198
self.file_name = url_transform(self.file_name)
199
print(colorama.Style.RESET_ALL, colorama.Style.BRIGHT, colorama.Fore.LIGHTYELLOW_EX, self.file_name)
200
201
print(colorama.Style.RESET_ALL, end="")
202
203
def __repr__(self):
204
return f"Document({self.file_name})"
205
206
def __str__(self):
207
return self.content
208
209
def __getitem__(self, item: str):
210
"""Get an item from the front matter of the document"""
211
return self.front_matter[item]
212
213
def __setitem__(self, item: str, value: typing.Any):
214
"""Set an item in the front matter of the document"""
215
self.front_matter[item] = value
216
217
def __delitem__(self, item: str):
218
"""Delete an item from the front matter of the document"""
219
del self.front_matter[item]
220
221
def __contains__(self, item: str):
222
"""Check if an item is in the front matter of the document"""
223
return item in self.front_matter
224
225
226
class Index:
227
"""A type representing an index of documents."""
228
def __init__(self, directory: typing.Union[str, bytes, os.PathLike], recursive: bool = False,
229
url_transform: typing.Callable = lambda x: x, sort_by: typing.Callable = lambda x: x.file_name,
230
exclude: 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
"""
239
self.directory = directory
240
self.static = static
241
# Temporarily move to the specified directory in order to read the files.
242
if exclude:
243
regex = re.compile(exclude)
244
else:
245
regex = re.compile("(?!)")
246
with in_directory(directory):
247
if recursive:
248
self.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)]
249
else:
250
self.file_names = [i for i in os.listdir() if os.path.isfile(i) and not regex.search(i)]
251
252
self.documents = sorted([Document(i, url_transform, front_matter_enabled=not static) for i in self.file_names], key=sort_by)
253
self.__current_index = 0
254
255
def __iter__(self):
256
self.__current_index = 0
257
return self
258
259
def __next__(self):
260
if self.__current_index >= len(self.documents):
261
raise StopIteration
262
else:
263
self.__current_index += 1
264
return self.documents[self.__current_index - 1]
265
266
def __repr__(self):
267
return f"Index({self.directory}): {self.documents}"
268
269
def __len__(self):
270
return len(self.documents)
271
272
273
class Site:
274
"""A type representing a website."""
275
def __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
"""
281
self.build_dir: str = build_dir
282
self.template_engine: jinja2.Environment = jinja2.Environment(loader=jinja2.FileSystemLoader(template_dir))
283
self.pages: dict[str, typing.Union[Static, Page]] = {}
284
self.context: dict[str, typing.Any] = {}
285
286
def 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
"""
292
if location.endswith("/"):
293
location += "index.html"
294
location = location.lstrip("/") # interpret it as site root, not OS root
295
self.pages[location] = page
296
297
def 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
"""
306
location = location.lstrip("/") # interpret it as site root, not OS root
307
kwargs = {**self.context, **kwargs}
308
if index.static:
309
for document in index:
310
self.pages[os.path.join(location, document.file_name)] = Static(self, document)
311
else:
312
for document in index:
313
self.pages[os.path.join(location, document.file_name)] = Page(self, template, document, **kwargs)
314
315
def 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
"""
320
def decorator(func):
321
self.template_engine.filters[name] = func
322
return func
323
324
return decorator
325
326
def 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
"""
331
def decorator(func):
332
self.template_engine.tests[name] = func
333
return func
334
335
return decorator
336
337
def build(self):
338
"""Build the site in its directory."""
339
# Clear the build directory if it exists.
340
if os.path.isdir(self.build_dir):
341
delete_directory_contents(self.build_dir)
342
for location, page in self.pages.items():
343
# Create the required directories.
344
os.makedirs(os.path.join(self.build_dir, os.path.dirname(location)), exist_ok=True)
345
if isinstance(page, str):
346
with open(os.path.join(self.build_dir, location), "w") as f:
347
f.write(page)
348
elif isinstance(page, bytes):
349
with open(os.path.join(self.build_dir, location), "wb") as f:
350
f.write(page)
351
else:
352
raise ValueError(f"{type(page)} cannot be used as a document")
353
354
355
class Page(str):
356
"""A type representing a page, which is a rendered template."""
357
def __new__(cls, site: Site, template: str, document: Document = None, **kwargs):
358
kwargs = {**site.context, **kwargs}
359
return site.template_engine.get_template(template).render(document=document, **kwargs)
360
361
362
class Static(bytes):
363
"""A type representing a static file, which is binary content that is not templated."""
364
def __new__(cls, site: Site, document: Document):
365
return document.content
366