blurry-cli 0.18.0__tar.gz → 0.19.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 (40) hide show
  1. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/PKG-INFO +3 -3
  2. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/__init__.py +8 -3
  3. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/cli.py +0 -9
  4. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/file_processors/__init__.py +2 -0
  5. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/images.py +41 -44
  6. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/markdown/__init__.py +9 -18
  7. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/plugins/jinja_plugins/blurry_image_extension.py +11 -12
  8. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/utils.py +8 -0
  9. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/pyproject.toml +5 -4
  10. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/LICENSE +0 -0
  11. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/README.md +0 -0
  12. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/__main__.py +0 -0
  13. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/async_typer.py +0 -0
  14. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/commands/__init__.py +0 -0
  15. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/commands/clean.py +0 -0
  16. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/commands/init.py +0 -0
  17. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/commands/version.py +0 -0
  18. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/constants.py +0 -0
  19. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/gather_file_data_by_directory.py +0 -0
  20. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/markdown/front_matter.py +0 -0
  21. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/markdown/renderer_functions/__init__.py +0 -0
  22. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/markdown/renderer_functions/render_video.py +0 -0
  23. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/open_graph.py +0 -0
  24. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/plugins/__init__.py +0 -0
  25. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/plugins/html_plugins/__init__.py +0 -0
  26. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/plugins/html_plugins/minify_html_plugin.py +0 -0
  27. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/plugins/jinja_plugins/__init__.py +0 -0
  28. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/plugins/jinja_plugins/filters.py +0 -0
  29. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/plugins/markdown_plugins/__init__.py +0 -0
  30. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/plugins/markdown_plugins/container_plugin.py +0 -0
  31. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/plugins/markdown_plugins/punctuation_plugin.py +0 -0
  32. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/plugins/markdown_plugins/python_code_plugin.py +0 -0
  33. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/py.typed +0 -0
  34. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/runserver_handlers.py +0 -0
  35. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/schema_validation/__init__.py +0 -0
  36. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/schema_validation/models.py +0 -0
  37. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/settings.py +0 -0
  38. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/sitemap.py +0 -0
  39. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/templates.py +0 -0
  40. {blurry_cli-0.18.0 → blurry_cli-0.19.0}/blurry/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blurry-cli
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: A Mistune-based static site generator for Python
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -23,14 +23,14 @@ Classifier: Topic :: Text Processing :: Markup :: HTML
23
23
  Classifier: Topic :: Text Processing :: Markup :: Markdown
24
24
  Requires-Dist: Jinja2 (>=3.1.5,<4.0.0)
25
25
  Requires-Dist: PyLD (>=2.0.4,<3.0.0)
26
- Requires-Dist: Wand (>=0.6.13,<0.7.0)
27
26
  Requires-Dist: dpath (>=2.2.0,<3.0.0)
28
27
  Requires-Dist: ffmpeg-python (>=0.2.0,<0.3.0)
29
28
  Requires-Dist: htmlmin2 (>=0.1.13,<0.2.0)
30
29
  Requires-Dist: jinja2-simple-tags (>=0.6.1,<0.7.0)
31
- Requires-Dist: jinjax (>=0.48,<0.61)
30
+ Requires-Dist: jinjax (>=0.48,<0.62)
32
31
  Requires-Dist: livereload (>=2.7.1,<3.0.0)
33
32
  Requires-Dist: mistune (>=3.1.0,<4.0.0)
33
+ Requires-Dist: pillow (>=12.0.0,<13.0.0)
34
34
  Requires-Dist: pydantic (>=2.10.5,<3.0.0)
35
35
  Requires-Dist: rich (>=13.9.4,<15.0.0)
36
36
  Requires-Dist: selectolax (>=0.3.27,<0.4.0)
@@ -16,7 +16,7 @@ from rich.progress import TaskProgressColumn
16
16
  from rich.progress import TextColumn
17
17
 
18
18
  from blurry.async_typer import AsyncTyper
19
- from blurry.cli import check_avif_support, print_blurry_name
19
+ from blurry.cli import print_blurry_name
20
20
  from blurry.cli import print_plugin_table
21
21
  from blurry.commands.clean import clean_build_directory
22
22
  from blurry.commands.init import initialize_new_project
@@ -34,6 +34,7 @@ from blurry.settings import get_settings
34
34
  from blurry.settings import update_settings
35
35
  from blurry.sitemap import write_sitemap_file
36
36
  from blurry.templates import get_jinja_env
37
+ from blurry.utils import handle_future_result
37
38
 
38
39
 
39
40
  warning_console = Console(stderr=True, style="bold yellow")
@@ -66,7 +67,6 @@ async def build(release=True, clean: bool = False):
66
67
  update_settings()
67
68
  print_blurry_name()
68
69
  print_plugin_table()
69
- check_avif_support(console=warning_console)
70
70
 
71
71
  build_directory = get_build_directory()
72
72
 
@@ -97,6 +97,12 @@ async def build(release=True, clean: bool = False):
97
97
  other_task = progress.add_task("[blue]Other")
98
98
 
99
99
  sitemap_future = executor.submit(write_sitemap_file, file_data_by_directory)
100
+ sitemap_future.add_done_callback(
101
+ lambda future: handle_future_result(
102
+ future,
103
+ "Could not generate sitemap",
104
+ )
105
+ )
100
106
  sitemap_future.add_done_callback(lambda _: progress.advance(other_task))
101
107
 
102
108
  for filepath in content_directory.rglob("*"):
@@ -165,7 +171,6 @@ def runserver():
165
171
  """Starts HTTP server with live reloading."""
166
172
  print_blurry_name()
167
173
  print_plugin_table()
168
- check_avif_support(console=warning_console)
169
174
 
170
175
  SETTINGS = get_settings()
171
176
  os.environ.setdefault(f"{ENV_VAR_PREFIX}BUILD_MODE", "dev")
@@ -1,6 +1,5 @@
1
1
  from rich.console import Console
2
2
  from rich.table import Table
3
- import wand.version
4
3
 
5
4
  from blurry.plugins import discovered_html_plugins
6
5
  from blurry.plugins import discovered_jinja_extensions
@@ -36,11 +35,3 @@ def print_plugin_table():
36
35
  )
37
36
 
38
37
  console.print(plugin_table)
39
-
40
-
41
- def check_avif_support(console: Console):
42
- if "AVIF" in wand.version.formats():
43
- return
44
- console.print(
45
- "AVIF support not found. Falling back to WEBP. See https://trac.ffmpeg.org/wiki/Encode/AV1"
46
- )
@@ -51,6 +51,8 @@ def process_non_markdown_file(
51
51
  if mimetype in [
52
52
  mimetypes.types_map[".jpg"],
53
53
  mimetypes.types_map[".png"],
54
+ mimetypes.types_map[".webp"],
55
+ mimetypes.types_map[".avif"],
54
56
  ]:
55
57
  asyncio.run(generate_images_for_srcset(filepath))
56
58
 
@@ -2,14 +2,12 @@ import concurrent.futures
2
2
  from concurrent.futures import Future
3
3
  from pathlib import Path
4
4
 
5
- from wand.image import Image
6
- import wand.version
5
+ from PIL import Image
7
6
 
8
7
  from blurry.settings import get_build_directory
9
8
  from blurry.settings import get_content_directory
10
9
  from blurry.settings import get_settings
11
-
12
- has_avif_support = "AVIF" in wand.version.formats()
10
+ from blurry.utils import handle_future_result
13
11
 
14
12
 
15
13
  def get_target_image_widths():
@@ -33,30 +31,32 @@ def add_image_width_to_path(image_path: Path, width: int) -> Path:
33
31
  return Path(new_filename)
34
32
 
35
33
 
36
- def convert_image_to_avif_or_webp(image_path: Path, target_path: Path | None = None):
34
+ def convert_image_to_avif(image_path: Path, target_path: Path | None = None):
37
35
  SETTINGS = get_settings()
38
36
  IMAGE_COMPRESSION_QUALITY = SETTINGS["IMAGE_COMPRESSION_QUALITY"]
39
- image_suffix = image_path.suffix
40
- new_image_format = "avif" if has_avif_support else "webp"
41
- new_filepath = str(target_path or image_path).replace(
42
- image_suffix, f".{new_image_format}"
43
- )
37
+ new_filepath = (target_path or image_path).with_suffix(".avif")
44
38
  if Path(new_filepath).exists():
45
39
  return
46
- with Image(filename=str(image_path)) as image:
47
- image.format = new_image_format
48
- image.compression_quality = IMAGE_COMPRESSION_QUALITY
49
- image.save(filename=new_filepath)
40
+ with Image.open(image_path) as image:
41
+ image.save(new_filepath, quality=IMAGE_COMPRESSION_QUALITY)
50
42
 
51
43
 
52
- def clone_and_resize_image(
53
- image_path: Path, target_width: int, resized_image_destination: Path
54
- ):
44
+ def clone_and_resize_image_as_avif_or_webp(image_path: Path, target_width: int):
45
+ resized_image_suffix = ".webp" if image_path.suffix == ".webp" else ".avif"
46
+ resized_image_destination = get_build_directory() / (
47
+ add_image_width_to_path(
48
+ image_path.with_suffix(resized_image_suffix), target_width
49
+ )
50
+ ).relative_to(get_content_directory())
55
51
  if resized_image_destination.exists():
56
52
  return
57
- with Image(filename=str(image_path)) as image:
58
- image.transform(resize=str(target_width))
59
- image.save(filename=resized_image_destination)
53
+ with Image.open(image_path) as image:
54
+ width, height = image.size
55
+ resize_factor = target_width / width
56
+ resized_image = image.resize(
57
+ (target_width, int(height * resize_factor)), Image.Resampling.LANCZOS
58
+ )
59
+ resized_image.save(resized_image_destination)
60
60
 
61
61
 
62
62
  async def generate_images_for_srcset(image_path: Path):
@@ -66,37 +66,34 @@ async def generate_images_for_srcset(image_path: Path):
66
66
 
67
67
  build_path = BUILD_DIR / image_path.resolve().relative_to(CONTENT_DIR)
68
68
  with concurrent.futures.ThreadPoolExecutor() as executor:
69
- # Convert original image
70
- full_sized_image_future = executor.submit(
71
- convert_image_to_avif_or_webp, image_path=image_path, target_path=build_path
72
- )
73
- image_futures.append(full_sized_image_future)
74
-
75
- # Store resized image futures so AVIF/WEBP versions can be created once they're done
76
- resize_future_to_filepath: dict[Future, Path] = {}
77
-
78
- width = Image(filename=str(image_path)).width
79
-
80
- for target_width in get_widths_for_image_width(width):
81
- new_filepath = add_image_width_to_path(image_path, target_width)
82
- new_filepath_in_build = BUILD_DIR / new_filepath.relative_to(CONTENT_DIR)
83
-
84
- resized_original_image_type_future = executor.submit(
85
- clone_and_resize_image, image_path, target_width, new_filepath_in_build
69
+ # Convert original image only if it is in an inefficient format
70
+ if image_path.suffix not in [".avif", ".webp"]:
71
+ full_sized_image_future = executor.submit(
72
+ convert_image_to_avif, image_path=image_path, target_path=build_path
86
73
  )
87
- resize_future_to_filepath[resized_original_image_type_future] = (
88
- new_filepath_in_build
74
+ full_sized_image_future.add_done_callback(
75
+ lambda future: handle_future_result(
76
+ future,
77
+ f"Could not convert image: {image_path.relative_to(CONTENT_DIR)}",
78
+ )
89
79
  )
80
+ image_futures.append(full_sized_image_future)
90
81
 
91
- # Create AVIF/WEBP versions of resized images as they're ready
92
- for future in concurrent.futures.as_completed(resize_future_to_filepath):
93
- resized_image_filepath = resize_future_to_filepath[future]
82
+ width = Image.open(image_path).width
83
+
84
+ for target_width in get_widths_for_image_width(width):
94
85
  resized_image_future = executor.submit(
95
- convert_image_to_avif_or_webp, resized_image_filepath
86
+ clone_and_resize_image_as_avif_or_webp, image_path, target_width
87
+ )
88
+ resized_image_future.add_done_callback(
89
+ lambda future: handle_future_result(
90
+ future,
91
+ f"Could not resize image: {image_path.relative_to(CONTENT_DIR)} to {target_width}",
92
+ )
96
93
  )
97
94
  image_futures.append(resized_image_future)
98
95
 
99
- concurrent.futures.wait(image_futures + list(resize_future_to_filepath.keys()))
96
+ concurrent.futures.wait(image_futures)
100
97
 
101
98
 
102
99
  def get_widths_for_image_width(image_width: int) -> list[int]:
@@ -14,7 +14,7 @@ from mistune.plugins.table import table
14
14
  from mistune.plugins.task_lists import task_lists
15
15
  from mistune.plugins.url import url
16
16
  from mistune.util import escape
17
- from wand.image import Image
17
+ from PIL import Image
18
18
 
19
19
  from .front_matter import parse_front_matter
20
20
  from .renderer_functions.render_video import render_video
@@ -74,14 +74,11 @@ class BlurryRenderer(mistune.HTMLRenderer):
74
74
  return render_video(src, absolute_path, extension, title=title)
75
75
 
76
76
  # Tailor srcset and sizes to image width
77
- with Image(filename=str(absolute_path)) as img:
78
- image_width = img.width
79
- image_height = img.height
80
- # The .animated property doesn't always detect animated .webp images
81
- # and .webp is optimized enough not to benefit much from .avif
82
- image_is_animated = img.animation or extension.lower() == "webp"
83
- attributes["width"] = image_width
84
- attributes["height"] = image_height
77
+ with Image.open(absolute_path) as img:
78
+ image_width, image_height = img.size
79
+ image_is_animated = getattr(img, "n_frames", 0) > 1
80
+ attributes["width"] = str(image_width)
81
+ attributes["height"] = str(image_height)
85
82
 
86
83
  if image_is_animated or extension.lower() == "svg":
87
84
  attributes_str = " ".join(
@@ -91,25 +88,19 @@ class BlurryRenderer(mistune.HTMLRenderer):
91
88
 
92
89
  image_widths = get_widths_for_image_width(image_width)
93
90
 
94
- attributes["sizes"] = generate_sizes_string(image_widths)
95
- attributes["srcset"] = generate_srcset_string(src, image_widths)
91
+ sizes = generate_sizes_string(image_widths)
96
92
  avif_srcset = generate_srcset_string(
97
93
  src.replace(extension, "avif"), image_widths
98
94
  )
99
95
  source_tag = '<source srcset="{}" sizes="{}" type="image/avif" />'.format(
100
- avif_srcset, attributes["sizes"]
96
+ avif_srcset, sizes
101
97
  )
102
98
 
103
99
  attributes_str = " ".join(
104
100
  f'{name}="{value}"' for name, value in attributes.items()
105
101
  )
106
102
 
107
- return (
108
- f"<figure>"
109
- f"<picture>{source_tag}<img {attributes_str}></picture>"
110
- f"<figcaption>{attributes['alt']}</figcaption>"
111
- f"</figure>"
112
- )
103
+ return f"<figure><picture>{source_tag}<img {attributes_str}></picture></figure>"
113
104
 
114
105
  def link(self, text, url, title: str | None = None) -> str:
115
106
  CONTENT_DIR = get_content_directory()
@@ -1,11 +1,9 @@
1
1
  import mimetypes
2
- from pathlib import Path
3
2
  from urllib.parse import urlparse
4
3
 
5
4
  from jinja2_simple_tags import StandaloneTag
6
5
  from rich.console import Console
7
- from wand.exceptions import BlobError
8
- from wand.image import Image
6
+ from PIL import Image
9
7
 
10
8
  from blurry.images import add_image_width_to_path
11
9
  from blurry.settings import get_build_directory
@@ -31,17 +29,16 @@ class BlurryImage(StandaloneTag):
31
29
  image_path = get_build_directory() / image_relative_pathname
32
30
 
33
31
  try:
34
- with Image(filename=str(image_content_path)) as image:
35
- image_width = image.width
36
- image_height = image.height
37
- image_mimetype = image.mimetype
38
- except BlobError:
32
+ with Image.open(image_content_path) as image:
33
+ image_width, image_height = image.size
34
+ image_mimetype = image.get_format_mimetype()
35
+ except Exception:
39
36
  warning_console.print(f"Could not find image: {image_content_path}")
40
37
  return ""
41
38
 
42
39
  attributes = {
43
- "width": image_width,
44
- "height": image_height,
40
+ "width": str(image_width),
41
+ "height": str(image_height),
45
42
  }
46
43
  for attribute_key in kwargs:
47
44
  if attribute_key in ["width", "height"]:
@@ -49,7 +46,9 @@ class BlurryImage(StandaloneTag):
49
46
  f"blurry_image: Received {attribute_key} in template {self.template} but this attribute is dynamic. Skipping."
50
47
  )
51
48
  continue
52
- attributes[attribute_key] = kwargs.get(attribute_key)
49
+ attribute_value = kwargs.get(attribute_key)
50
+ if attribute_value is not None:
51
+ attributes[attribute_key] = attribute_value
53
52
 
54
53
  if width:
55
54
  image_path = add_image_width_to_path(image_path, width)
@@ -58,7 +57,7 @@ class BlurryImage(StandaloneTag):
58
57
  mimetypes.types_map[".jpg"],
59
58
  mimetypes.types_map[".png"],
60
59
  ]:
61
- image_path = Path(str(image_path).replace(image_path.suffix, ".avif"))
60
+ image_path = image_path.with_suffix(".avif")
62
61
 
63
62
  attributes["src"] = build_path_to_url(image_path)
64
63
 
@@ -1,3 +1,4 @@
1
+ from concurrent.futures import Future
1
2
  from pathlib import Path
2
3
 
3
4
  from blurry.settings import get_build_directory
@@ -99,3 +100,10 @@ def format_schema_data(schema_data: dict) -> dict:
99
100
  formatted_schema_data = {"@context": "https://schema.org"}
100
101
  formatted_schema_data.update(schema_data)
101
102
  return formatted_schema_data
103
+
104
+
105
+ def handle_future_result(future: Future, message: str):
106
+ exception = future.exception()
107
+ if not exception:
108
+ return
109
+ print(message, "-", exception)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "blurry-cli"
3
- version = "0.18.0"
3
+ version = "0.19.0"
4
4
  description = "A Mistune-based static site generator for Python"
5
5
  authors = ["John Franey <franey@duck.com>"]
6
6
  license = "MIT"
@@ -13,6 +13,7 @@ classifiers = [
13
13
  "Programming Language :: Python :: 3.11",
14
14
  "Programming Language :: Python :: 3.12",
15
15
  "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3.14",
16
17
  "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System",
17
18
  "Topic :: Internet :: WWW/HTTP :: Site Management",
18
19
  "Topic :: Software Development :: Libraries :: Python Modules",
@@ -25,7 +26,6 @@ packages = [{ include = "blurry" }]
25
26
  [tool.poetry.dependencies]
26
27
  Jinja2 = "^3.1.5"
27
28
  PyLD = "^2.0.4"
28
- Wand = "^0.6.13"
29
29
  ffmpeg-python = "^0.2.0"
30
30
  livereload = "^2.7.1"
31
31
  mistune = "^3.1.0"
@@ -36,8 +36,9 @@ typer = ">=0.15.1,<0.17.0"
36
36
  htmlmin2 = "^0.1.13"
37
37
  dpath = "^2.2.0"
38
38
  jinja2-simple-tags = "^0.6.1"
39
- jinjax = ">=0.48,<0.61"
39
+ jinjax = ">=0.48,<0.62"
40
40
  pydantic = "^2.10.5"
41
+ pillow = "^12.0.0"
41
42
 
42
43
  [tool.poetry.scripts]
43
44
  blurry = 'blurry:main'
@@ -50,7 +51,7 @@ black = ">=24.10,<26.0"
50
51
  nox = ">=2024.10.9,<2026.0.0"
51
52
  pyright = "^1.1.391"
52
53
  pytest = "^8.3.4"
53
- pytest-cov = "^6.0.0"
54
+ pytest-cov = ">=6,<8"
54
55
  ruff = ">=0.8.6,<0.15.0"
55
56
  pytest-watcher = "^0.4.3"
56
57
 
File without changes
File without changes
File without changes
File without changes