blurry-cli 0.12.0__tar.gz → 0.13.0__tar.gz

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.
Files changed (31) hide show
  1. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/PKG-INFO +1 -1
  2. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/__init__.py +140 -72
  3. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/images.py +29 -21
  4. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/markdown/__init__.py +2 -2
  5. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/plugins/jinja_plugins/blurry_image_extension.py +1 -1
  6. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/sitemap.py +1 -3
  7. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/pyproject.toml +1 -1
  8. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/LICENSE +0 -0
  9. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/README.md +0 -0
  10. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/__main__.py +0 -0
  11. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/async_typer.py +0 -0
  12. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/cli.py +0 -0
  13. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/constants.py +0 -0
  14. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/markdown/front_matter.py +0 -0
  15. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/markdown/renderer_functions/__init__.py +0 -0
  16. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/markdown/renderer_functions/render_video.py +0 -0
  17. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/open_graph.py +0 -0
  18. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/plugins/__init__.py +0 -0
  19. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/plugins/html_plugins/__init__.py +0 -0
  20. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/plugins/html_plugins/minify_html_plugin.py +0 -0
  21. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/plugins/jinja_plugins/__init__.py +0 -0
  22. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/plugins/markdown_plugins/__init__.py +0 -0
  23. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/plugins/markdown_plugins/container_plugin.py +0 -0
  24. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/plugins/markdown_plugins/punctuation_plugin.py +0 -0
  25. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/plugins/markdown_plugins/python_code_plugin.py +0 -0
  26. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/py.typed +0 -0
  27. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/schema_validation.py +0 -0
  28. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/settings.py +0 -0
  29. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/templates.py +0 -0
  30. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/types.py +0 -0
  31. {blurry_cli-0.12.0 → blurry_cli-0.13.0}/blurry/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: blurry-cli
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: A Mistune-based static site generator for Python
5
5
  Home-page: https://github.com/blurry-dev/blurry
6
6
  License: MIT
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import concurrent.futures
2
3
  import dataclasses
3
4
  import importlib
4
5
  import json
@@ -7,7 +8,6 @@ import os
7
8
  import pkgutil
8
9
  import shutil
9
10
  import sys
10
- from collections.abc import Coroutine
11
11
  from copy import deepcopy
12
12
  from datetime import datetime
13
13
  from pathlib import Path
@@ -17,6 +17,10 @@ from jinja2 import Environment
17
17
  from livereload import Server
18
18
  from rich import print
19
19
  from rich.console import Console
20
+ from rich.progress import BarColumn
21
+ from rich.progress import Progress
22
+ from rich.progress import TaskProgressColumn
23
+ from rich.progress import TextColumn
20
24
 
21
25
  from blurry.async_typer import AsyncTyper
22
26
  from blurry.cli import print_blurry_name
@@ -54,30 +58,31 @@ warning_console = Console(stderr=True, style="bold yellow")
54
58
  app = AsyncTyper()
55
59
 
56
60
 
57
- async def process_non_markdown_file(filepath: Path, file_data_by_directory):
58
- CONTENT_DIR = get_content_directory()
59
- mimetype, _ = mimetypes.guess_type(filepath, strict=False)
60
- relative_filepath = filepath.relative_to(CONTENT_DIR)
61
- build_filepath = get_build_directory() / relative_filepath
62
- output_file = Path(build_filepath)
63
- output_file.parent.mkdir(exist_ok=True, parents=True)
64
-
61
+ def process_non_markdown_file(
62
+ filepath: Path, file_data_by_directory, jinja_env: Environment
63
+ ):
65
64
  # Process Jinja files
66
65
  if ".jinja" in filepath.suffixes:
67
66
  # Process file
68
- jinja_env = get_jinja_env()
69
67
  process_jinja_file(filepath, jinja_env, file_data_by_directory)
70
68
  return
71
69
 
72
- # Copy file to build directory
73
- shutil.copyfile(filepath, build_filepath)
70
+ CONTENT_DIR = get_content_directory()
71
+ mimetype, _ = mimetypes.guess_type(filepath, strict=False)
72
+ relative_filepath = filepath.relative_to(CONTENT_DIR)
73
+ output_file = get_build_directory() / relative_filepath
74
+
75
+ # Copy file to build directory if it is not already there
76
+ if not output_file.exists():
77
+ output_file.parent.mkdir(exist_ok=True, parents=True)
78
+ shutil.copyfile(filepath, output_file)
74
79
 
75
80
  # Create srcset images
76
81
  if mimetype in [
77
82
  mimetypes.types_map[".jpg"],
78
83
  mimetypes.types_map[".png"],
79
84
  ]:
80
- await generate_images_for_srcset(filepath)
85
+ asyncio.run(generate_images_for_srcset(filepath))
81
86
 
82
87
 
83
88
  def process_jinja_file(filepath: Path, jinja_env: Environment, file_data_by_directory):
@@ -101,32 +106,35 @@ def process_jinja_file(filepath: Path, jinja_env: Environment, file_data_by_dire
101
106
  filepath_in_build.write_text(html)
102
107
 
103
108
 
104
- async def write_html_file(
105
- file_data: MarkdownFileData,
106
- file_data_list: list[MarkdownFileData],
109
+ def write_html_file(
110
+ filepath: Path,
107
111
  file_data_by_directory: dict[Path, list[MarkdownFileData]],
108
112
  release: bool,
109
113
  jinja_env: Environment,
110
114
  ):
111
115
  extra_context: TemplateContext = {}
112
116
  # Gather data from other files in this directory if this is an index file
113
- if file_data.path.name == "index.md":
117
+ file_data_list = file_data_by_directory[filepath.parent]
118
+ if filepath.name == "index.md":
114
119
  sibling_pages = [
115
120
  {
116
121
  "url": content_path_to_url(f.path),
117
122
  **f.front_matter,
118
123
  }
119
124
  for f in file_data_list
120
- if f.path != file_data.path
125
+ if f.path != filepath
121
126
  ]
122
127
  extra_context["sibling_pages"] = sibling_pages
123
- folder_in_build = convert_content_path_to_directory_in_build(file_data.path)
128
+ folder_in_build = convert_content_path_to_directory_in_build(filepath)
124
129
 
130
+ file_data = [
131
+ f for f in file_data_by_directory[filepath.parent] if f.path == filepath
132
+ ][0]
125
133
  schema_type = file_data.front_matter.get("@type")
126
134
  if not schema_type:
127
135
  raise ValueError(
128
136
  f"Required @type value missing in file or TOML front matter invalid: "
129
- f"{file_data.path}"
137
+ f"{filepath}"
130
138
  )
131
139
  template_extension = SETTINGS["MARKDOWN_FILE_JINJA_TEMPLATE_EXTENSION"]
132
140
  template = jinja_env.get_template(f"{schema_type}{template_extension}")
@@ -152,12 +160,13 @@ async def write_html_file(
152
160
  default=json_converter_with_dates,
153
161
  )
154
162
 
155
- validate_front_matter_as_schema(file_data.path, front_matter, warning_console)
163
+ validate_front_matter_as_schema(filepath, front_matter, warning_console)
156
164
 
157
165
  schema_type_tag = f'<script type="application/ld+json">{schema_data}</script>'
158
166
 
159
167
  template_context = {
160
168
  "body": file_data.body,
169
+ "filepath": filepath,
161
170
  "schema_data": schema_data,
162
171
  "schema_type_tag": schema_type_tag,
163
172
  "open_graph_tags": open_graph_meta_tags(file_data.front_matter),
@@ -182,34 +191,15 @@ async def write_html_file(
182
191
  write_index_file_creating_path(folder_in_build, html)
183
192
 
184
193
 
185
- @app.async_command()
186
- async def build(release=True):
187
- """Generates HTML content from Markdown files."""
188
-
189
- print_blurry_name()
190
- print_plugin_table()
191
-
192
- update_settings()
193
- jinja_env = get_jinja_env()
194
- os.environ.setdefault(f"{ENV_VAR_PREFIX}BUILD_MODE", "prod" if release else "dev")
195
- CONTENT_DIR = get_content_directory()
196
- BUILD_DIR = get_build_directory()
197
- start = datetime.now()
198
- path = Path(CONTENT_DIR)
194
+ def gather_file_data_by_directory() -> DirectoryFileData:
195
+ # Sort file data by publishedDate/createdDate, descending, if present
199
196
  file_data_by_directory: DirectoryFileData = {}
200
- if not BUILD_DIR.exists():
201
- BUILD_DIR.mkdir(parents=True)
202
-
203
- markdown_tasks: list[Coroutine] = []
204
- non_markdown_tasks: list[Coroutine] = []
197
+ content_directory = get_content_directory()
205
198
 
206
- # Gather metadata from Markdown files
207
- for filepath in path.glob("**/*.md"):
199
+ for filepath in content_directory.glob("**/*.md"):
208
200
  # Extract filepath for storing context data and writing out
209
- relative_filepath = filepath.relative_to(CONTENT_DIR)
201
+ relative_filepath = filepath.relative_to(content_directory)
210
202
  directory = relative_filepath.parent
211
- if directory not in file_data_by_directory:
212
- file_data_by_directory[directory] = []
213
203
 
214
204
  # Convert Markdown file to HTML
215
205
  body, front_matter = convert_markdown_file_to_html(filepath)
@@ -218,41 +208,79 @@ async def build(release=True):
218
208
  front_matter=front_matter,
219
209
  path=relative_filepath,
220
210
  )
221
- file_data_by_directory[directory].append(file_data)
211
+ try:
212
+ file_data_by_directory[directory].append(file_data)
213
+ except KeyError:
214
+ file_data_by_directory[directory] = [file_data]
222
215
 
223
- # Handle images and other files
224
- for filepath in path.glob("**/*"):
225
- if filepath.suffix == ".md" or filepath.is_dir():
226
- continue
227
- non_markdown_tasks.append(
228
- process_non_markdown_file(filepath, file_data_by_directory)
229
- )
216
+ return sort_directory_file_data_by_date(file_data_by_directory)
230
217
 
231
- # Sort file data by publishedDate/createdDate, descending, if present
232
- file_data_by_directory = sort_directory_file_data_by_date(file_data_by_directory)
233
218
 
234
- non_markdown_tasks.append(write_sitemap_file(file_data_by_directory))
219
+ @app.async_command()
220
+ async def build(release=True):
221
+ """Generates HTML content from Markdown files."""
222
+ start = datetime.now()
223
+
224
+ if release:
225
+ print_blurry_name()
226
+ print_plugin_table()
227
+
228
+ update_settings()
229
+ content_directory = get_content_directory()
230
+ build_directory = get_build_directory()
231
+ build_directory.mkdir(parents=True, exist_ok=True)
232
+ jinja_env = get_jinja_env()
235
233
 
236
- for file_data_list in file_data_by_directory.values():
237
- for file_data in file_data_list:
238
- markdown_tasks.append(
239
- write_html_file(
240
- file_data,
241
- file_data_list,
234
+ os.environ.setdefault(f"{ENV_VAR_PREFIX}BUILD_MODE", "prod" if release else "dev")
235
+
236
+ file_data_by_directory = gather_file_data_by_directory()
237
+
238
+ markdown_task_count = 0
239
+ non_markdown_task_count = 1 # sitemap
240
+
241
+ with Progress(
242
+ TextColumn("[progress.description]{task.description}"),
243
+ BarColumn(),
244
+ TaskProgressColumn(),
245
+ transient=True,
246
+ ) as progress:
247
+ with concurrent.futures.ThreadPoolExecutor() as executor:
248
+ markdown_task = progress.add_task("[blue]Markdown")
249
+ other_task = progress.add_task("[blue]Other")
250
+
251
+ sitemap_future = executor.submit(write_sitemap_file, file_data_by_directory)
252
+ sitemap_future.add_done_callback(lambda _: progress.advance(other_task))
253
+
254
+ for filepath in content_directory.rglob("*"):
255
+ if filepath.is_dir():
256
+ continue
257
+ if filepath.suffix == ".md":
258
+ markdown_future = executor.submit(
259
+ write_html_file,
260
+ filepath.relative_to(content_directory),
261
+ file_data_by_directory,
262
+ release,
263
+ jinja_env,
264
+ )
265
+ markdown_task_count += 1
266
+ progress.update(markdown_task, total=markdown_task_count)
267
+ markdown_future.add_done_callback(
268
+ lambda _: progress.advance(markdown_task)
269
+ )
270
+ continue
271
+ other_future = executor.submit(
272
+ process_non_markdown_file,
273
+ filepath,
242
274
  file_data_by_directory,
243
- release,
244
275
  jinja_env,
245
276
  )
246
- )
247
-
248
- content_dir_relative = CONTENT_DIR.relative_to(Path.cwd())
249
- print(f"Blurring {len(markdown_tasks)} Markdown files from: {content_dir_relative}")
277
+ non_markdown_task_count += 1
278
+ progress.update(other_task, total=non_markdown_task_count)
279
+ other_future.add_done_callback(lambda _: progress.advance(other_task))
250
280
 
251
- await asyncio.gather(*markdown_tasks)
252
- loop = asyncio.new_event_loop()
253
- asyncio.set_event_loop(loop)
254
- for non_markdown_task in non_markdown_tasks:
255
- await asyncio.to_thread(lambda: loop.run_until_complete(non_markdown_task))
281
+ print(
282
+ f"Blurring {markdown_task_count} Markdown files and {non_markdown_task_count} other files"
283
+ )
256
284
 
257
285
  end = datetime.now()
258
286
 
@@ -281,6 +309,9 @@ async def reload_local_plugins_and_build():
281
309
  @app.command()
282
310
  def runserver():
283
311
  """Starts HTTP server with live reloading."""
312
+ print_blurry_name()
313
+ print_plugin_table()
314
+
284
315
  update_settings()
285
316
  os.environ.setdefault(f"{ENV_VAR_PREFIX}BUILD_MODE", "dev")
286
317
 
@@ -289,14 +320,51 @@ def runserver():
289
320
  event_loop = asyncio.get_event_loop()
290
321
  event_loop.create_task(build_development())
291
322
 
323
+ jinja_env = get_jinja_env()
324
+
325
+ def handle_changed_jinja_files(filepaths: list[str]):
326
+ file_data_by_directory = gather_file_data_by_directory()
327
+ for filepath in filepaths:
328
+ process_jinja_file(
329
+ Path.cwd() / filepath,
330
+ jinja_env,
331
+ file_data_by_directory,
332
+ )
333
+
334
+ def handle_changed_markdown_files(filepaths: list[str]):
335
+ file_data_by_directory = gather_file_data_by_directory()
336
+ content_directory = get_content_directory()
337
+ for filepath in filepaths:
338
+ write_html_file(
339
+ filepath=(Path.cwd() / filepath).relative_to(content_directory),
340
+ file_data_by_directory=file_data_by_directory,
341
+ release=False,
342
+ jinja_env=jinja_env,
343
+ )
344
+
292
345
  livereload_server = Server()
346
+ livereload_server.watch(
347
+ f"{SETTINGS['CONTENT_DIRECTORY_NAME']}/**/*.jinja", handle_changed_jinja_files
348
+ )
349
+ livereload_server.watch(
350
+ f"{SETTINGS['CONTENT_DIRECTORY_NAME']}/**/*.md",
351
+ handle_changed_markdown_files,
352
+ )
293
353
  livereload_server.watch(
294
354
  f"{SETTINGS['CONTENT_DIRECTORY_NAME']}/**/*",
295
355
  lambda: event_loop.create_task(build_development()),
356
+ ignore=lambda filepath: any(
357
+ [
358
+ filepath.endswith(".md"),
359
+ filepath.endswith(".jinja"),
360
+ Path(filepath).is_dir(),
361
+ ]
362
+ ),
296
363
  )
297
364
  livereload_server.watch(
298
365
  f"{SETTINGS['TEMPLATES_DIRECTORY_NAME']}/**/*",
299
366
  lambda: event_loop.create_task(build_development()),
367
+ ignore=lambda filepath: Path(filepath).is_dir(),
300
368
  )
301
369
  livereload_server.watch(
302
370
  "./**/*.py",
@@ -1,4 +1,4 @@
1
- import asyncio
1
+ import concurrent.futures
2
2
  from pathlib import Path
3
3
 
4
4
  from wand.image import Image
@@ -30,7 +30,7 @@ def add_image_width_to_path(image_path: Path, width: int) -> Path:
30
30
  return Path(new_filename)
31
31
 
32
32
 
33
- async def convert_image_to_avif(image_path: Path, target_path: Path | None = None):
33
+ def convert_image_to_avif(image_path: Path, target_path: Path | None = None):
34
34
  AVIF_COMPRESSION_QUALITY = SETTINGS["AVIF_COMPRESSION_QUALITY"]
35
35
  image_suffix = image_path.suffix
36
36
  avif_filepath = str(target_path or image_path).replace(image_suffix, ".avif")
@@ -42,31 +42,39 @@ async def convert_image_to_avif(image_path: Path, target_path: Path | None = Non
42
42
  image.save(filename=avif_filepath)
43
43
 
44
44
 
45
+ def clone_and_resize_image(
46
+ image: Image, target_width: int, resized_image_destination: Path
47
+ ):
48
+ if resized_image_destination.exists():
49
+ return
50
+ image.transform(resize=str(target_width))
51
+ image.save(filename=resized_image_destination)
52
+
53
+
45
54
  async def generate_images_for_srcset(image_path: Path):
46
55
  BUILD_DIR = get_build_directory()
47
56
  CONTENT_DIR = get_content_directory()
57
+ filepaths_to_convert_to_avif = []
58
+
59
+ build_path = BUILD_DIR / image_path.resolve().relative_to(CONTENT_DIR)
60
+ with concurrent.futures.ThreadPoolExecutor() as executor:
61
+ with Image(filename=str(image_path)) as img:
62
+ width = img.width
63
+
64
+ for target_width in get_widths_for_image_width(width):
65
+ new_filepath = add_image_width_to_path(image_path, target_width)
66
+ relative_filepath = new_filepath.resolve().relative_to(CONTENT_DIR)
67
+ build_filepath = BUILD_DIR / relative_filepath
68
+ # We convert the resized images to AVIF, so do this synchronously
69
+ clone_and_resize_image(img, target_width, build_filepath)
70
+ filepaths_to_convert_to_avif.append(build_filepath)
48
71
 
49
- with Image(filename=str(image_path)) as img:
50
- width = img.width
51
72
  # Convert original image
52
- build_path = BUILD_DIR / image_path.resolve().relative_to(CONTENT_DIR)
53
- await convert_image_to_avif(image_path=image_path, target_path=build_path)
54
-
55
- avif_files_to_create: list[Path] = []
56
- for target_width in get_widths_for_image_width(width):
57
- new_filepath = add_image_width_to_path(image_path, target_width)
58
- relative_filepath = new_filepath.resolve().relative_to(CONTENT_DIR)
59
- build_filepath = BUILD_DIR / relative_filepath
60
- with img.clone() as resized:
61
- avif_files_to_create.append(build_filepath)
62
- if build_filepath.exists():
63
- continue
64
- resized.transform(resize=str(target_width))
65
- resized.save(filename=build_filepath)
66
-
67
- await asyncio.gather(
68
- *[convert_image_to_avif(avif_file) for avif_file in avif_files_to_create]
73
+ executor.submit(
74
+ convert_image_to_avif, image_path=image_path, target_path=build_path
69
75
  )
76
+ # Generate AVIF files for resized images
77
+ executor.map(convert_image_to_avif, filepaths_to_convert_to_avif)
70
78
 
71
79
 
72
80
  def get_widths_for_image_width(image_width: int) -> list[int]:
@@ -84,7 +84,7 @@ class BlurryRenderer(mistune.HTMLRenderer):
84
84
  attributes_str = " ".join(
85
85
  f'{name}="{value}"' for name, value in attributes.items()
86
86
  )
87
- return f"<img {attributes_str} >"
87
+ return f"<img {attributes_str}>"
88
88
 
89
89
  image_widths = get_widths_for_image_width(image_width)
90
90
 
@@ -103,7 +103,7 @@ class BlurryRenderer(mistune.HTMLRenderer):
103
103
 
104
104
  return (
105
105
  f"<figure>"
106
- f"<picture>{source_tag}<img {attributes_str} /></picture>"
106
+ f"<picture>{source_tag}<img {attributes_str}></picture>"
107
107
  f'<figcaption>{attributes["alt"]}</figcaption>'
108
108
  f"</figure>"
109
109
  )
@@ -73,4 +73,4 @@ class BlurryImage(StandaloneTag):
73
73
  f'{name}="{value}"' for name, value in attributes.items()
74
74
  )
75
75
 
76
- return f"<img {attributes_str}/>"
76
+ return f"<img {attributes_str}>"
@@ -38,9 +38,7 @@ def generate_sitemap_for_file_data_list(file_data_list: list[MarkdownFileData])
38
38
  return SITEMAP_TEMPLATE.format(urls=sitemap_url_content)
39
39
 
40
40
 
41
- async def write_sitemap_file(
42
- file_data_by_directory: dict[Path, list[MarkdownFileData]]
43
- ):
41
+ def write_sitemap_file(file_data_by_directory: dict[Path, list[MarkdownFileData]]):
44
42
  BUILD_DIR = get_build_directory()
45
43
  file_data = []
46
44
  for file_data_list in file_data_by_directory.values():
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "blurry-cli"
3
- version = "0.12.0"
3
+ version = "0.13.0"
4
4
  description = "A Mistune-based static site generator for Python"
5
5
  authors = ["John Franey <franey@duck.com>"]
6
6
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes