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