render-engine 2025.11.1a1__py3-none-any.whl → 2026.1.1a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '2025.11.1a1'
32
- __version_tuple__ = version_tuple = (2025, 11, 1, 'a1')
31
+ __version__ = version = '2026.1.1a2'
32
+ __version_tuple__ = version_tuple = (2026, 1, 1, 'a2')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -27,6 +27,7 @@ class BaseObject:
27
27
  plugins: list[Callable] | None
28
28
  plugin_settings: dict = {"plugins": defaultdict(dict)}
29
29
  skip_site_map: bool = False
30
+ metadata: dict = dict()
30
31
 
31
32
  @property
32
33
  def _title(self) -> str:
@@ -1,7 +1,9 @@
1
1
  import copy
2
2
  import datetime
3
3
  import logging
4
+ import os
4
5
  from collections.abc import Callable, Generator
6
+ from multiprocessing.pool import ThreadPool
5
7
  from pathlib import Path
6
8
  from typing import Any
7
9
 
@@ -10,10 +12,9 @@ from more_itertools import batched
10
12
  from render_engine_parser import BasePageParser
11
13
  from slugify import slugify
12
14
 
13
- from render_engine.content_managers import ContentManager, FileContentManager
14
-
15
15
  from ._base_object import BaseObject
16
16
  from .archive import Archive
17
+ from .content_managers import ContentManager, FileContentManager
17
18
  from .feeds import RSSFeed
18
19
  from .page import Page
19
20
  from .plugins import PluginManager
@@ -245,6 +246,28 @@ class Collection(BaseObject):
245
246
  def __iter__(self):
246
247
  yield from self.content_manager
247
248
 
249
+ def __len__(self):
250
+ return len(self.content_manager)
251
+
252
+ @property
253
+ def all_content(self) -> Generator:
254
+ """All of the content that is associated with the Collection including Pages, Feed, and Archives"""
255
+ yield from self
256
+
257
+ if getattr(self, "has_archive", False):
258
+ for archive in self.archives:
259
+ yield archive
260
+ if archive.is_index:
261
+ # In order to avoid collision with parallel processing we need to do a copy.
262
+ # A deepcopy is not necessary because we only care about not overwriting the
263
+ # slug on the original.
264
+ index = copy.copy(archive)
265
+ index.slug = "index"
266
+ yield index
267
+
268
+ if feed := getattr(self, "feed", None):
269
+ yield feed
270
+
248
271
  def _run_collection_plugins(self, site, hook_type: str):
249
272
  """
250
273
  Run plugins for a collection
@@ -261,32 +284,28 @@ class Collection(BaseObject):
261
284
  return
262
285
  method(collection=self, site=site, settings=self.plugin_manager.plugin_settings)
263
286
 
264
- def render(self) -> None:
265
- """Iterate through Pages and Check for Collections and Feeds"""
287
+ def _render(self, entry):
288
+ """
289
+ Renders 1 entry in the Collection
266
290
 
267
- for entry in self:
291
+ :param entry: The entry to process
292
+ """
293
+ if not isinstance(entry, RSSFeed) and not isinstance(entry, Archive):
268
294
  entry.plugin_manager = copy.deepcopy(self.plugin_manager)
269
295
 
270
- for route in entry.routes:
271
- entry.site = self.site
272
- entry.render(route, self.site.theme_manager)
296
+ entry.site = self.site
297
+ entry.render(self.site.theme_manager)
273
298
 
274
- if getattr(self, "has_archive", False):
275
- for archive in self.archives:
276
- archive.site = self.site
277
- logging.debug("Adding Archive: %s", archive.__class__.__name__)
278
-
279
- for _ in self.routes:
280
- archive.render(self.routes[0], self.site.theme_manager)
281
-
282
- if archive.is_index:
283
- archive.slug = "index"
284
- archive.render(self.routes[0], self.site.theme_manager)
285
- feed: RSSFeed
286
- if hasattr(self, "Feed"):
287
- feed = self.feed
288
- feed.site = self.site
289
- feed.render(route="./", theme_manager=self.site.theme_manager)
299
+ def render(self) -> None:
300
+ """Iterate through Pages and Check for Archives and Feeds"""
301
+
302
+ # Use a ThreadPool to process all of the entries in the collection in parallel.
303
+ # This is limited to the number of CPUs available. The easiest way to implement
304
+ # this parallelization for a single task is to use the imap_unordered method of
305
+ # the ThreadPool. This is, effectively, a generator so we need to loop over it
306
+ # for them to run. Since there is no actual return value to look at we just `pass`.
307
+ for entry in ThreadPool(processes=os.cpu_count()).imap_unordered(self._render, self.all_content):
308
+ pass
290
309
 
291
310
  def create_entry(
292
311
  self, filepath: Path = None, editor: str = None, content: str = None, metadata: dict = None
@@ -19,4 +19,21 @@ class ContentManager(ABC):
19
19
  @abstractmethod
20
20
  def create_entry(self, filepath: Path = None, editor: str = None, metadata: dict = None, content: str = None):
21
21
  """Create a new entry"""
22
- ...
22
+ pass
23
+
24
+ def find_entry(self, **kwargs):
25
+ """
26
+ Find an entry
27
+
28
+ :param kwargs: List of attributes to search by
29
+ :return: Page if it was found otherwise None
30
+ """
31
+ for page in self:
32
+ if all(getattr(page, attr, None) == value for attr, value in kwargs.items()):
33
+ return page
34
+ return None
35
+
36
+ @abstractmethod
37
+ def update_entry(self, *, page, **kwargs):
38
+ """Update an entry"""
39
+ pass
@@ -36,8 +36,16 @@ class FileContentManager(ContentManager):
36
36
  def pages(self, value: Iterable):
37
37
  self._pages = value
38
38
 
39
+ def __len__(self):
40
+ return len(list(self.pages))
41
+
39
42
  def create_entry(
40
- self, filepath: Path = None, editor: str = None, metadata: dict = None, content: str = None
43
+ self,
44
+ filepath: Path = None,
45
+ editor: str = None,
46
+ metadata: dict = None,
47
+ content: str = None,
48
+ update: bool = False,
41
49
  ) -> str:
42
50
  """
43
51
  Create a new entry for the Collection
@@ -46,12 +54,33 @@ class FileContentManager(ContentManager):
46
54
  :param editor: Editor to open to edit the entry.
47
55
  :param content: The content for the entry
48
56
  :param metadata: Metadata for the new entry
57
+ :param update: Allow overwriting the existing file
49
58
  """
50
59
  if not filepath:
51
60
  raise ValueError("filepath needs to be specified.")
52
61
 
62
+ if not update and filepath.exists():
63
+ raise RuntimeError(f"File at {filepath} exists and update is disabled.")
64
+
53
65
  parsed_content = self.collection.Parser.create_entry(content=content, **metadata)
54
66
  filepath.write_text(parsed_content)
55
67
  if editor:
56
68
  subprocess.run([editor, filepath])
57
69
  return f"New entry created at {filepath} ."
70
+
71
+ def update_entry(self, page, *, content: str = None, **kwargs) -> str:
72
+ """
73
+ Update an entry
74
+
75
+ :param page: Page object to update
76
+ :param content: Content for the updated page
77
+ :param kwargs: Attributes to be included in the updated page
78
+ :return: String indicating that the page was updated.
79
+ """
80
+ self.create_entry(filepath=page.content_path, metadata=kwargs, content=content, update=True)
81
+ if self._pages:
82
+ self._pages = [
83
+ existing_page for existing_page in self._pages if page.content_path != existing_page.content_path
84
+ ]
85
+ self._pages.append(self.collection.get_page(page.content_path))
86
+ return f"Entry at {page.content_path} updated."
render_engine/page.py CHANGED
@@ -149,20 +149,23 @@ class BasePage(BaseObject):
149
149
  def __repr__(self) -> str:
150
150
  return f"<Page: {self._title}>"
151
151
 
152
- def render(self, route: str | Path, theme_manager: ThemeManager) -> int:
152
+ def render(self, theme_manager: ThemeManager) -> int:
153
153
  """Render the page to the file system"""
154
- path = Path(self.site.output_path) / Path(route) / Path(self.path_name)
155
- path.parent.mkdir(parents=True, exist_ok=True)
156
- settings = dict()
157
- if (pm := getattr(self, "plugin_manager", None)) and pm is not None:
158
- settings = {**self.site.plugin_manager.plugin_settings, "route": route}
159
- pm.hook.render_content(page=self, settings=settings, site=self.site)
160
- self.rendered_content = self._render_content(theme_manager.engine)
161
- # pass the route to the plugin settings
162
- if pm is not None:
163
- pm.hook.post_render_content(page=self.__class__, settings=settings, site=self.site)
164
-
165
- return path.write_text(self.rendered_content)
154
+ rc = 0
155
+ for route in self.routes:
156
+ path = Path(self.site.output_path) / Path(route) / Path(self.path_name)
157
+ path.parent.mkdir(parents=True, exist_ok=True)
158
+ settings = dict()
159
+ if (pm := getattr(self, "plugin_manager", None)) and pm is not None:
160
+ settings = {**self.site.plugin_manager.plugin_settings, "route": route}
161
+ pm.hook.render_content(page=self, settings=settings, site=self.site)
162
+ self.rendered_content = self._render_content(theme_manager.engine)
163
+ # pass the route to the plugin settings
164
+ if pm is not None:
165
+ pm.hook.post_render_content(page=self.__class__, settings=settings, site=self.site)
166
+
167
+ rc += path.write_text(self.rendered_content)
168
+ return rc
166
169
 
167
170
 
168
171
  class Page(BasePage):
@@ -204,10 +207,10 @@ class Page(BasePage):
204
207
 
205
208
  content: Any
206
209
  content_path: Path | str | None
207
- Parser: type[BasePageParser] = BasePageParser
208
210
  inherit_plugins: bool
209
211
  parser_extras: dict[str, Any] | None
210
212
  title: str
213
+ Parser: type[BasePageParser] = BasePageParser
211
214
 
212
215
  def __init__(
213
216
  self,
@@ -229,19 +232,21 @@ class Page(BasePage):
229
232
 
230
233
  # Parse Content from the Content Path or the Content
231
234
  if content_path := (content_path or getattr(self, "content_path", None)):
232
- attrs, self.content = self.Parser.parse_content_path(content_path)
235
+ self.metadata, self.content = self.Parser.parse_content_path(content_path)
233
236
 
234
237
  elif content := (content or getattr(self, "content", None)):
235
- attrs, self.content = self.Parser.parse_content(content)
238
+ self.metadata, self.content = self.Parser.parse_content(content)
236
239
 
237
240
  else:
238
- attrs = {}
241
+ self.metadata = {}
239
242
  self.content = None
240
243
 
241
244
  # Set the attributes
242
- for key, val in attrs.items():
245
+ for key, val in self.metadata.items():
243
246
  setattr(self, key.lower(), val)
244
247
 
248
+ self.content_path = content_path
249
+
245
250
  @property
246
251
  def _content(self) -> Any:
247
252
  """
render_engine/site.py CHANGED
@@ -306,14 +306,11 @@ class Site:
306
306
  args = []
307
307
  match entry:
308
308
  case Page():
309
- for route in entry.routes:
310
- progress.update(
311
- task_add_route,
312
- description=f"[blue]Adding[gold]Route: [blue]{entry._slug}",
313
- )
314
-
315
- # self._render_output(route, entry)
316
- args = [route, self.theme_manager]
309
+ progress.update(
310
+ task_add_route,
311
+ description=f"[blue]Adding[gold]Route: [blue]{entry._slug}",
312
+ )
313
+ args = [self.theme_manager]
317
314
  case Collection():
318
315
  progress.update(
319
316
  task_add_route,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: render_engine
3
- Version: 2025.11.1a1
3
+ Version: 2026.1.1a2
4
4
  Summary: A Flexible Static Site Generator for Python
5
5
  Project-URL: homepage, https://github.com/render-engine/render-engine/
6
6
  Project-URL: repository, https://github.com/render-engine/render-engine/
@@ -1,24 +1,24 @@
1
1
  render_engine/.gitignore,sha256=74oa8YR8gNxKxB6lCtoqmgtB2xpZnWM539Qsl4wI_lg,12
2
2
  render_engine/__init__.py,sha256=3fgua4ZA9o1pvQ5unhY1gRARLXFqAu019NEYqZTjP20,154
3
3
  render_engine/__main__.py,sha256=uI7aBBZz0qSDwwwD11nS5oltWsuLw9hStfYo8O1aNws,144
4
- render_engine/__version__.py,sha256=BCpwT8NcnBJ07_dARP0flYekyhShasjtVTPdA443a-A,720
5
- render_engine/_base_object.py,sha256=DIyLdQ6gS4a0DP46zwGYkYIyewh8uRFznJmaUch7d8M,3546
4
+ render_engine/__version__.py,sha256=pI2d9qsCwWR0-5U4R9QwGAWwJN7Kas0dzZtgcWUBLBU,718
5
+ render_engine/_base_object.py,sha256=B05rDWJUe5GM5FF94NeYY71KweBHY-IYel1lSBwLOrU,3574
6
6
  render_engine/archive.py,sha256=S3-kCmDNVKkEfKDKxcEk-sXkBD0vS0RDnFfPunYkU8g,2072
7
7
  render_engine/blog.py,sha256=f9GqFUFsta0KZnFhCiajobpfQyALqvgI5sbLm6zt1zw,1571
8
- render_engine/collection.py,sha256=eqeVxZ_ASrOra7kayhAkPSHocpM1mbxwhsH6ZtK-oMk,11294
8
+ render_engine/collection.py,sha256=63KL7kAyJYsS8isbugqT6Ii-CUTxj5GST-f7yZJzZaA,12189
9
9
  render_engine/engine.py,sha256=GOtUiq4ny5GHaLSCeH5u1Zk1JnWJVh63vK7etJiwS20,2843
10
10
  render_engine/feeds.py,sha256=i-VHsb6pRplMzaenBn6oeqh9yI_N4WVUAExPox6iJgw,921
11
11
  render_engine/hookspecs.py,sha256=GhOpw0zTQjfwWOFYYbJ4P7Cvq-oy1MmTPHmd90dr0kg,2292
12
12
  render_engine/links.py,sha256=pKmQMTz8-yGX8IecHcrlF3Dkejk7cptaO3qCkQiHB9I,2560
13
- render_engine/page.py,sha256=l6sKWNJ4gBtC_ONEc0u479q3znL-8Q7U_phNqXqmh6w,8988
13
+ render_engine/page.py,sha256=o753fqux-VMOHmprp6RFZOHqRt8Gjtc32WX3h5WLohA,9153
14
14
  render_engine/plugins.py,sha256=NXM8QTbbRV-DwgpQRoIhILijJBN4SyYg2Rkk1LUAuZM,4703
15
15
  render_engine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
- render_engine/site.py,sha256=aarHkveSVDOy7hRPzrx_ga3DmmirZQcLvcJdyw-b8lY,13329
16
+ render_engine/site.py,sha256=22ir1dc2qWpmfRyG2hmTzB8BInZkp37LSaRo97xufto,13186
17
17
  render_engine/site_map.py,sha256=I1p_yMDMy1jpIivgNgZptnsxZa8NkcrpciVJE3DlFlQ,5422
18
18
  render_engine/themes.py,sha256=TFG1rd34QCBvBWfeDbawgsn6kprmjsDTa1pdDSwDMic,4207
19
19
  render_engine/content_managers/__init__.py,sha256=z1x99J0GNcfqYFrugD0EleiZR6b-sfM6zViDTH1iF0s,161
20
- render_engine/content_managers/base_content_manager.py,sha256=umb2Tze7UE7ON74hOe_WiykOk9-xTPw40_l3W5Cy5_U,620
21
- render_engine/content_managers/file_content_manager.py,sha256=pKyldWKGocI2WAY8H_6d9dWY26hUxm4N5_oavO_FxJ0,1884
20
+ render_engine/content_managers/base_content_manager.py,sha256=9DiQFN461jfdmcQF_0rQwgdNcUzaMDQaLFVbbiZf2e8,1088
21
+ render_engine/content_managers/file_content_manager.py,sha256=kD5D99E16pN6PobDo8isqmPYdJyoRSZo9BP9iZaBwRI,2963
22
22
  render_engine/extras/__init__.py,sha256=L4jr4A7Jl-ODnSx1q2fP3_dBo37Dw6yepNRddu1nFNo,72
23
23
  render_engine/parsers/markdown.py,sha256=0jpixCaoHaL0IRSvFIljJIRCvFkXoKTEYQNK38LwMDU,287
24
24
  render_engine/render_engine_templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -36,7 +36,7 @@ render_engine/render_engine_templates/base_templates/_page.html,sha256=jjrY2BAwl
36
36
  render_engine/render_engine_templates/components/footer.html,sha256=HkPGGhfN0HcYm7t8zgXWCQ3bsCbT8FxT4_n2-9e1zUE,74
37
37
  render_engine/render_engine_templates/components/page_title.html,sha256=l8aE1TY94UPHXHqAyy6jv4IoN2Hv9cbrTPh7ILkMyxg,137
38
38
  render_engine/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
- render_engine-2025.11.1a1.dist-info/METADATA,sha256=xmk3ud0hPXdvQ4Svm3QHsCgwyNYbC6-vU_OBd24PvPA,11895
40
- render_engine-2025.11.1a1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
41
- render_engine-2025.11.1a1.dist-info/top_level.txt,sha256=aNGALDMsFyrusho04AvUjSivsgEE9tQp_LP_jGr312Q,14
42
- render_engine-2025.11.1a1.dist-info/RECORD,,
39
+ render_engine-2026.1.1a2.dist-info/METADATA,sha256=FfVYaHJMWBGPe6UDAV97e7y7YHfKLL9fR1KUl1urc8w,11894
40
+ render_engine-2026.1.1a2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
41
+ render_engine-2026.1.1a2.dist-info/top_level.txt,sha256=aNGALDMsFyrusho04AvUjSivsgEE9tQp_LP_jGr312Q,14
42
+ render_engine-2026.1.1a2.dist-info/RECORD,,