Skip to content

Commit 94187b7

Browse files
authored
fix(integration): imports from Material for Mkdocs blog plugin for type hint was breaking the build when using another theme (#409)
#408
2 parents ad842c0 + dcdeaf3 commit 94187b7

File tree

2 files changed

+263
-5
lines changed

2 files changed

+263
-5
lines changed

mkdocs_rss_plugin/integrations/theme_material_blog_plugin.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# standard library
88
from functools import lru_cache
99
from pathlib import Path
10+
from typing import Union
1011

1112
# 3rd party
1213
from mkdocs.config.defaults import MkDocsConfig
@@ -26,7 +27,7 @@
2627
from material.plugins.blog.structure import Post
2728

2829
except ImportError:
29-
material_version = None
30+
material_version = BlogPlugin = Post = None
3031

3132

3233
# ############################################################################
@@ -132,16 +133,16 @@ def author_name_from_id(self, author_id: str) -> str:
132133
)
133134
return author_id
134135

135-
def is_page_a_blog_post(self, mkdocs_page: Post | MkdocsPageSubset) -> bool:
136+
def is_page_a_blog_post(self, mkdocs_page: Union["Post", MkdocsPageSubset]) -> bool:
136137
"""Identifies if the given page is part of Material Blog.
137138
138139
Args:
139-
mkdocs_page (Page): page to identify
140+
mkdocs_page: page to identify
140141
141142
Returns:
142-
bool: True if the given page is a Material Blog post.
143+
True if the given page is a Material Blog post.
143144
"""
144-
if self.IS_ENABLED and isinstance(mkdocs_page, Post):
145+
if self.IS_ENABLED and Post is not None and isinstance(mkdocs_page, Post):
145146
logger.debug(
146147
f"page '{mkdocs_page.file.src_uri}' identified as Material Blog post."
147148
)

tests/test_no_material.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
#! python3 # noqa E265
2+
3+
"""Test build without Material theme installed.
4+
5+
Usage from the repo root folder:
6+
7+
.. code-block:: python
8+
9+
# for whole test
10+
python -m unittest tests.test_build_without_material
11+
# for specific test
12+
python -m unittest tests.test_build_without_material.TestBuildWithoutMaterial.test_build_without_material_theme
13+
"""
14+
15+
# #############################################################################
16+
# ########## Libraries #############
17+
# ##################################
18+
19+
# Standard library
20+
import sys
21+
import tempfile
22+
import unittest
23+
from pathlib import Path
24+
from unittest.mock import patch
25+
26+
# 3rd party
27+
import feedparser
28+
29+
# test suite
30+
from tests.base import BaseTest
31+
32+
# #############################################################################
33+
# ########## Globals ###############
34+
# ##################################
35+
36+
OUTPUT_RSS_FEED_CREATED = "feed_rss_created.xml"
37+
OUTPUT_RSS_FEED_UPDATED = "feed_rss_updated.xml"
38+
OUTPUT_JSON_FEED_CREATED = "feed_json_created.json"
39+
OUTPUT_JSON_FEED_UPDATED = "feed_json_updated.json"
40+
41+
42+
# #############################################################################
43+
# ########## Classes ###############
44+
# ##################################
45+
46+
47+
class TestBuildWithoutMaterial(BaseTest):
48+
"""Test MkDocs build with RSS plugin when Material theme is not available."""
49+
50+
# -- Standard methods --------------------------------------------------------
51+
@classmethod
52+
def setUpClass(cls):
53+
"""Executed when module is loaded before any test."""
54+
cls.feed_image = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png"
55+
56+
def setUp(self):
57+
"""Executed before each test."""
58+
pass
59+
60+
def tearDown(self):
61+
"""Executed after each test."""
62+
pass
63+
64+
@classmethod
65+
def tearDownClass(cls):
66+
"""Executed after the last test."""
67+
pass
68+
69+
# -- TESTS ---------------------------------------------------------
70+
def test_build_without_material_theme(self):
71+
"""Test that the plugin works correctly when Material theme is not installed.
72+
73+
This test simulates the absence of the Material theme by temporarily
74+
blocking its import, ensuring the RSS plugin gracefully handles the missing
75+
dependency.
76+
"""
77+
# Mock the material module to simulate it not being installed
78+
with patch.dict(sys.modules, {"material": None}):
79+
# Also need to mock the submodules
80+
with patch.dict(
81+
sys.modules,
82+
{
83+
"material.plugins": None,
84+
"material.plugins.blog": None,
85+
"material.plugins.blog.plugin": None,
86+
"material.plugins.blog.structure": None,
87+
},
88+
):
89+
with tempfile.TemporaryDirectory() as tmpdirname:
90+
cli_result = self.build_docs_setup(
91+
testproject_path="docs",
92+
mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_minimal.yml"),
93+
output_path=tmpdirname,
94+
strict=True,
95+
)
96+
97+
if cli_result.exception is not None:
98+
e = cli_result.exception
99+
return e
100+
101+
self.assertEqual(cli_result.exit_code, 0)
102+
self.assertIsNone(cli_result.exception)
103+
104+
# Verify RSS feeds were created
105+
self.assertTrue(
106+
Path(tmpdirname).joinpath(OUTPUT_RSS_FEED_CREATED).exists()
107+
)
108+
self.assertTrue(
109+
Path(tmpdirname).joinpath(OUTPUT_RSS_FEED_UPDATED).exists()
110+
)
111+
112+
# Verify feeds are valid
113+
feed_created = feedparser.parse(
114+
Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED
115+
)
116+
self.assertEqual(feed_created.bozo, 0)
117+
118+
feed_updated = feedparser.parse(
119+
Path(tmpdirname) / OUTPUT_RSS_FEED_UPDATED
120+
)
121+
self.assertEqual(feed_updated.bozo, 0)
122+
123+
def test_build_with_material_config_but_theme_not_installed(self):
124+
"""Test build with Material-specific config when the theme is not installed.
125+
126+
This test uses a configuration file that references Material theme features
127+
(like blog plugin) but simulates the theme not being installed. The plugin
128+
should handle this gracefully without crashing.
129+
"""
130+
with patch.dict(sys.modules, {"material": None}):
131+
with patch.dict(
132+
sys.modules,
133+
{
134+
"material.plugins": None,
135+
"material.plugins.blog": None,
136+
"material.plugins.blog.plugin": None,
137+
"material.plugins.blog.structure": None,
138+
},
139+
):
140+
with tempfile.TemporaryDirectory() as tmpdirname:
141+
# Use a config that would normally use Material features
142+
cli_result = self.build_docs_setup(
143+
testproject_path="docs",
144+
mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_complete.yml"),
145+
output_path=tmpdirname,
146+
strict=False, # Don't fail on warnings
147+
)
148+
149+
if cli_result.exception is not None:
150+
e = cli_result.exception
151+
return e
152+
153+
# Build should succeed even without Material
154+
self.assertEqual(cli_result.exit_code, 0)
155+
self.assertIsNone(cli_result.exception)
156+
157+
# Verify feeds were created and are valid
158+
feed_created = feedparser.parse(
159+
Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED
160+
)
161+
self.assertEqual(feed_created.bozo, 0)
162+
self.assertGreater(len(feed_created.entries), 0)
163+
164+
def test_page_processing_without_material(self):
165+
"""Test that page processing works correctly without Material theme.
166+
167+
Ensures that pages are processed correctly and their metadata is extracted
168+
even when Material-specific features are not available.
169+
"""
170+
with patch.dict(sys.modules, {"material": None}):
171+
with patch.dict(
172+
sys.modules,
173+
{
174+
"material.plugins": None,
175+
"material.plugins.blog": None,
176+
"material.plugins.blog.plugin": None,
177+
"material.plugins.blog.structure": None,
178+
},
179+
):
180+
with tempfile.TemporaryDirectory() as tmpdirname:
181+
# Use complete config which includes pages with authors
182+
cli_result = self.build_docs_setup(
183+
testproject_path="docs",
184+
mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_complete.yml"),
185+
output_path=tmpdirname,
186+
strict=False, # Material theme not available
187+
)
188+
189+
self.assertEqual(cli_result.exit_code, 0)
190+
self.assertIsNone(cli_result.exception)
191+
192+
# Parse the created feed
193+
feed_parsed = feedparser.parse(
194+
Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED
195+
)
196+
197+
# Verify feed has entries
198+
self.assertGreater(
199+
len(feed_parsed.entries),
200+
0,
201+
"Feed should contain at least one entry",
202+
)
203+
204+
# Verify entries have required fields
205+
for entry in feed_parsed.entries:
206+
self.assertIn(
207+
"title",
208+
entry,
209+
f"Entry '{entry.get('title', 'UNKNOWN')}' missing title",
210+
)
211+
self.assertIn(
212+
"link", entry, f"Entry '{entry.title}' missing link"
213+
)
214+
self.assertIn(
215+
"description",
216+
entry,
217+
f"Entry '{entry.title}' missing description",
218+
)
219+
self.assertIn(
220+
"published",
221+
entry,
222+
f"Entry '{entry.title}' missing published date",
223+
)
224+
225+
# Look for the specific page with complete metadata
226+
page_with_complete_meta = next(
227+
(
228+
e
229+
for e in feed_parsed.entries
230+
if e.title == "Page with complete meta"
231+
),
232+
None,
233+
)
234+
235+
# This page should exist and have author metadata
236+
self.assertIsNotNone(
237+
page_with_complete_meta,
238+
"Page 'Page with complete meta' should be in the feed. "
239+
f"Available pages: {', '.join([e.title for e in feed_parsed.entries])}",
240+
)
241+
242+
self.assertIn(
243+
"author",
244+
page_with_complete_meta,
245+
"Page 'Page with complete meta' should have author metadata",
246+
)
247+
self.assertIsNotNone(
248+
page_with_complete_meta.description,
249+
"Page 'Page with complete meta' should have a description",
250+
)
251+
252+
253+
# ##############################################################################
254+
# ##### Stand alone program ########
255+
# ##################################
256+
if __name__ == "__main__":
257+
unittest.main()

0 commit comments

Comments
 (0)