From 35f61f1289c1649c25ac58354739a19001d8b0fa Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 14 Nov 2025 21:18:12 +0100 Subject: [PATCH 1/2] update(quality): test build without Material for Mkdocs, patching sys modules #408 --- tests/test_no_material.py | 257 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 tests/test_no_material.py diff --git a/tests/test_no_material.py b/tests/test_no_material.py new file mode 100644 index 0000000..371e736 --- /dev/null +++ b/tests/test_no_material.py @@ -0,0 +1,257 @@ +#! python3 # noqa E265 + +"""Test build without Material theme installed. + +Usage from the repo root folder: + +.. code-block:: python + + # for whole test + python -m unittest tests.test_build_without_material + # for specific test + python -m unittest tests.test_build_without_material.TestBuildWithoutMaterial.test_build_without_material_theme +""" + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +# 3rd party +import feedparser + +# test suite +from tests.base import BaseTest + +# ############################################################################# +# ########## Globals ############### +# ################################## + +OUTPUT_RSS_FEED_CREATED = "feed_rss_created.xml" +OUTPUT_RSS_FEED_UPDATED = "feed_rss_updated.xml" +OUTPUT_JSON_FEED_CREATED = "feed_json_created.json" +OUTPUT_JSON_FEED_UPDATED = "feed_json_updated.json" + + +# ############################################################################# +# ########## Classes ############### +# ################################## + + +class TestBuildWithoutMaterial(BaseTest): + """Test MkDocs build with RSS plugin when Material theme is not available.""" + + # -- Standard methods -------------------------------------------------------- + @classmethod + def setUpClass(cls): + """Executed when module is loaded before any test.""" + cls.feed_image = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png" + + def setUp(self): + """Executed before each test.""" + pass + + def tearDown(self): + """Executed after each test.""" + pass + + @classmethod + def tearDownClass(cls): + """Executed after the last test.""" + pass + + # -- TESTS --------------------------------------------------------- + def test_build_without_material_theme(self): + """Test that the plugin works correctly when Material theme is not installed. + + This test simulates the absence of the Material theme by temporarily + blocking its import, ensuring the RSS plugin gracefully handles the missing + dependency. + """ + # Mock the material module to simulate it not being installed + with patch.dict(sys.modules, {"material": None}): + # Also need to mock the submodules + with patch.dict( + sys.modules, + { + "material.plugins": None, + "material.plugins.blog": None, + "material.plugins.blog.plugin": None, + "material.plugins.blog.structure": None, + }, + ): + with tempfile.TemporaryDirectory() as tmpdirname: + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_minimal.yml"), + output_path=tmpdirname, + strict=True, + ) + + if cli_result.exception is not None: + e = cli_result.exception + return e + + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + # Verify RSS feeds were created + self.assertTrue( + Path(tmpdirname).joinpath(OUTPUT_RSS_FEED_CREATED).exists() + ) + self.assertTrue( + Path(tmpdirname).joinpath(OUTPUT_RSS_FEED_UPDATED).exists() + ) + + # Verify feeds are valid + feed_created = feedparser.parse( + Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED + ) + self.assertEqual(feed_created.bozo, 0) + + feed_updated = feedparser.parse( + Path(tmpdirname) / OUTPUT_RSS_FEED_UPDATED + ) + self.assertEqual(feed_updated.bozo, 0) + + def test_build_with_material_config_but_theme_not_installed(self): + """Test build with Material-specific config when the theme is not installed. + + This test uses a configuration file that references Material theme features + (like blog plugin) but simulates the theme not being installed. The plugin + should handle this gracefully without crashing. + """ + with patch.dict(sys.modules, {"material": None}): + with patch.dict( + sys.modules, + { + "material.plugins": None, + "material.plugins.blog": None, + "material.plugins.blog.plugin": None, + "material.plugins.blog.structure": None, + }, + ): + with tempfile.TemporaryDirectory() as tmpdirname: + # Use a config that would normally use Material features + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_complete.yml"), + output_path=tmpdirname, + strict=False, # Don't fail on warnings + ) + + if cli_result.exception is not None: + e = cli_result.exception + return e + + # Build should succeed even without Material + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + # Verify feeds were created and are valid + feed_created = feedparser.parse( + Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED + ) + self.assertEqual(feed_created.bozo, 0) + self.assertGreater(len(feed_created.entries), 0) + + def test_page_processing_without_material(self): + """Test that page processing works correctly without Material theme. + + Ensures that pages are processed correctly and their metadata is extracted + even when Material-specific features are not available. + """ + with patch.dict(sys.modules, {"material": None}): + with patch.dict( + sys.modules, + { + "material.plugins": None, + "material.plugins.blog": None, + "material.plugins.blog.plugin": None, + "material.plugins.blog.structure": None, + }, + ): + with tempfile.TemporaryDirectory() as tmpdirname: + # Use complete config which includes pages with authors + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_complete.yml"), + output_path=tmpdirname, + strict=False, # Material theme not available + ) + + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + # Parse the created feed + feed_parsed = feedparser.parse( + Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED + ) + + # Verify feed has entries + self.assertGreater( + len(feed_parsed.entries), + 0, + "Feed should contain at least one entry", + ) + + # Verify entries have required fields + for entry in feed_parsed.entries: + self.assertIn( + "title", + entry, + f"Entry '{entry.get('title', 'UNKNOWN')}' missing title", + ) + self.assertIn( + "link", entry, f"Entry '{entry.title}' missing link" + ) + self.assertIn( + "description", + entry, + f"Entry '{entry.title}' missing description", + ) + self.assertIn( + "published", + entry, + f"Entry '{entry.title}' missing published date", + ) + + # Look for the specific page with complete metadata + page_with_complete_meta = next( + ( + e + for e in feed_parsed.entries + if e.title == "Page with complete meta" + ), + None, + ) + + # This page should exist and have author metadata + self.assertIsNotNone( + page_with_complete_meta, + "Page 'Page with complete meta' should be in the feed. " + f"Available pages: {', '.join([e.title for e in feed_parsed.entries])}", + ) + + self.assertIn( + "author", + page_with_complete_meta, + "Page 'Page with complete meta' should have author metadata", + ) + self.assertIsNotNone( + page_with_complete_meta.description, + "Page 'Page with complete meta' should have a description", + ) + + +# ############################################################################## +# ##### Stand alone program ######## +# ################################## +if __name__ == "__main__": + unittest.main() From dcdeaf3eb8c7c4d3ce294ec9a3a5cb814b285d16 Mon Sep 17 00:00:00 2001 From: GeoJulien Date: Fri, 14 Nov 2025 21:23:40 +0100 Subject: [PATCH 2/2] fix(integration): imports from Material for Mkdocs blog plugin for type hint was breaking the build when using another theme closes #408 --- .../integrations/theme_material_blog_plugin.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/mkdocs_rss_plugin/integrations/theme_material_blog_plugin.py b/mkdocs_rss_plugin/integrations/theme_material_blog_plugin.py index 6ee3da5..8c0abca 100644 --- a/mkdocs_rss_plugin/integrations/theme_material_blog_plugin.py +++ b/mkdocs_rss_plugin/integrations/theme_material_blog_plugin.py @@ -7,6 +7,7 @@ # standard library from functools import lru_cache from pathlib import Path +from typing import Union # 3rd party from mkdocs.config.defaults import MkDocsConfig @@ -26,7 +27,7 @@ from material.plugins.blog.structure import Post except ImportError: - material_version = None + material_version = BlogPlugin = Post = None # ############################################################################ @@ -132,16 +133,16 @@ def author_name_from_id(self, author_id: str) -> str: ) return author_id - def is_page_a_blog_post(self, mkdocs_page: Post | MkdocsPageSubset) -> bool: + def is_page_a_blog_post(self, mkdocs_page: Union["Post", MkdocsPageSubset]) -> bool: """Identifies if the given page is part of Material Blog. Args: - mkdocs_page (Page): page to identify + mkdocs_page: page to identify Returns: - bool: True if the given page is a Material Blog post. + True if the given page is a Material Blog post. """ - if self.IS_ENABLED and isinstance(mkdocs_page, Post): + if self.IS_ENABLED and Post is not None and isinstance(mkdocs_page, Post): logger.debug( f"page '{mkdocs_page.file.src_uri}' identified as Material Blog post." )