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.61 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, dont_delete: typing.Optional[list[str]] = None):
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
:param dont_delete: A list of files and directories to not delete.
45
"""
46
for root, dirs, files in os.walk(directory):
47
for file in files:
48
if file not in dont_delete:
49
os.remove(os.path.join(root, file))
50
for dir in dirs:
51
if dir not in dont_delete:
52
shutil.rmtree(os.path.join(root, dir))
53
54
55
def 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
"""
61
def 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
"""
67
if ":" not in date_string:
68
return date_string, "00:00:00"
69
70
elements = date_string.partition(":")
71
partition_character = " "
72
if " " not in date_string:
73
partition_character = "-"
74
if "-" not in date_string:
75
partition_character = "T"
76
77
date = elements[0].rpartition(partition_character)[0].strip()
78
time = elements[0].rpartition(partition_character)[2].strip() + elements[1] + elements[2].strip()
79
time = time.removeprefix("T").removesuffix("Z")
80
81
return date, time
82
83
time_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
102
date_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
126
date, time = split_date_and_time(date_string)
127
128
time_object = datetime.min.time()
129
date_object = datetime.min.date()
130
131
for time_format in time_formats:
132
try:
133
time_object = datetime.strptime(time, time_format)
134
except ValueError:
135
pass
136
for date_format in date_formats:
137
try:
138
date_object = datetime.strptime(date, date_format)
139
except ValueError:
140
pass
141
142
return datetime.combine(date_object, time_object.time())
143
144
145
class Document:
146
"""A type representing a document, which can be text or binary."""
147
def __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
"""
153
self.file_name = file_name
154
self.encoding = "utf-8"
155
# If the file is text, read it.
156
self.front_matter = YAML()
157
self.front_matter.Constructor.add_constructor("tag:yaml.org,2002:timestamp", _no_date_constructor)
158
self.content = ""
159
self.date = datetime.fromtimestamp(os.path.getmtime(file_name))
160
try:
161
with open(file_name, "r", encoding=self.encoding) as f:
162
print(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.
165
front_matter = ""
166
if front_matter_enabled:
167
initial_line = f.readline()
168
if initial_line == "---\n":
169
print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Front matter found", sep="")
170
line = ""
171
while line != "---\n":
172
line = f.readline()
173
if line != "---\n":
174
front_matter += line
175
print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Front matter loaded", sep="")
176
177
if front_matter and front_matter_enabled:
178
self.front_matter = self.front_matter.load(front_matter)
179
180
print(self.front_matter, type(self.front_matter))
181
182
if "DATE" in self.front_matter:
183
self.date = parse_date_string(self.front_matter["DATE"])
184
elif front_matter_enabled: # put it back
185
self.content = initial_line
186
187
print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Reading content", sep="")
188
189
self.content += f.read()
190
191
print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Content loaded", sep="")
192
print(colorama.Style.RESET_ALL, colorama.Style.DIM, self.content[:128] + "..." if len(self.content) > 128 else self.content)
193
except UnicodeDecodeError:
194
print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, "Text decoding failed, assuming binary", sep="")
195
self.encoding = None
196
with open(file_name, "rb") as f:
197
self.content = f.read()
198
print(colorama.Style.RESET_ALL, colorama.Fore.GREEN, "Binary content loaded", sep="")
199
200
print(colorama.Style.RESET_ALL, colorama.Fore.CYAN, colorama.Style.DIM, f"Transforming URL {self.file_name} ->", end=" ", sep="")
201
self.file_name = url_transform(self.file_name)
202
print(colorama.Style.RESET_ALL, colorama.Style.BRIGHT, colorama.Fore.LIGHTYELLOW_EX, self.file_name)
203
204
print(colorama.Style.RESET_ALL, end="")
205
206
def __repr__(self):
207
return f"Document({self.file_name})"
208
209
def __str__(self):
210
return self.content
211
212
def __getitem__(self, item: str):
213
"""Get an item from the front matter of the document"""
214
return self.front_matter[item]
215
216
def __setitem__(self, item: str, value: typing.Any):
217
"""Set an item in the front matter of the document"""
218
self.front_matter[item] = value
219
220
def __delitem__(self, item: str):
221
"""Delete an item from the front matter of the document"""
222
del self.front_matter[item]
223
224
def __contains__(self, item: str):
225
"""Check if an item is in the front matter of the document"""
226
return item in self.front_matter
227
228
229
class Index:
230
"""A type representing an index of documents."""
231
def __init__(self, directory: typing.Union[str, bytes, os.PathLike], recursive: bool = False,
232
url_transform: typing.Callable = lambda x: x, sort_by: typing.Callable = lambda x: x.file_name, reverse: bool = False,
233
exclude: 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
"""
242
self.directory = directory
243
self.static = static
244
# Temporarily move to the specified directory in order to read the files.
245
if exclude:
246
regex = re.compile(exclude)
247
else:
248
regex = re.compile("(?!)")
249
with in_directory(directory):
250
if recursive:
251
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)]
252
else:
253
self.file_names = [i for i in os.listdir() if os.path.isfile(i) and not regex.search(i)]
254
255
self.documents = sorted([Document(i, url_transform, front_matter_enabled=not static) for i in self.file_names], key=sort_by, reverse=reverse)
256
self.__current_index = 0
257
258
def __iter__(self):
259
self.__current_index = 0
260
return self
261
262
def __next__(self):
263
if self.__current_index >= len(self.documents):
264
raise StopIteration
265
else:
266
self.__current_index += 1
267
return self.documents[self.__current_index - 1]
268
269
def __repr__(self):
270
return f"Index({self.directory}): {self.documents}"
271
272
def __len__(self):
273
return len(self.documents)
274
275
276
class Site:
277
"""A type representing a website."""
278
def __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
"""
284
self.build_dir: str = build_dir
285
self.template_engine: jinja2.Environment = jinja2.Environment(loader=jinja2.FileSystemLoader(template_dir))
286
self.pages: dict[str, typing.Union[Static, Page]] = {}
287
self.context: dict[str, typing.Any] = {}
288
289
def 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
"""
295
if location.endswith("/"):
296
location += "index.html"
297
location = location.lstrip("/") # interpret it as site root, not OS root
298
self.pages[location] = page
299
300
def 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
"""
309
location = location.lstrip("/") # interpret it as site root, not OS root
310
kwargs = {**self.context, **kwargs}
311
if index.static:
312
for document in index:
313
self.pages[os.path.join(location, document.file_name)] = Static(self, document)
314
else:
315
for document in index:
316
self.pages[os.path.join(location, document.file_name)] = Page(self, template, document, **kwargs)
317
318
def 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
"""
323
def decorator(func):
324
self.template_engine.filters[name] = func
325
return func
326
327
return decorator
328
329
def 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
"""
334
def decorator(func):
335
self.template_engine.tests[name] = func
336
return func
337
338
return decorator
339
340
def build(self, dont_delete: typing.Optional[list[str]] = None):
341
"""Build the site in its directory."""
342
# Clear the build directory if it exists.
343
if os.path.isdir(self.build_dir):
344
delete_directory_contents(self.build_dir, dont_delete=dont_delete)
345
for location, page in self.pages.items():
346
# Create the required directories.
347
os.makedirs(os.path.join(self.build_dir, os.path.dirname(location)), exist_ok=True)
348
if isinstance(page, str):
349
with open(os.path.join(self.build_dir, location), "w") as f:
350
f.write(page)
351
elif isinstance(page, bytes):
352
with open(os.path.join(self.build_dir, location), "wb") as f:
353
f.write(page)
354
else:
355
raise ValueError(f"{type(page)} cannot be used as a document")
356
357
358
class Page(str):
359
"""A type representing a page, which is a rendered template."""
360
def __new__(cls, site: Site, template: str, document: Document = None, **kwargs):
361
kwargs = {**site.context, **kwargs}
362
return site.template_engine.get_template(template).render(document=document, **kwargs)
363
364
365
class Static(bytes):
366
"""A type representing a static file, which is binary content that is not templated."""
367
def __new__(cls, site: Site, document: Document):
368
return document.content
369