From 02d395821d16145ec56216153cac7b0f995272ec Mon Sep 17 00:00:00 2001 From: Himamshu Soni Date: Mon, 21 Sep 2026 23:55:32 +0530 Subject: [PATCH 1/4] fix: enable bluesky icon in sprites --- tests/test_social_icons.py | 100 +++++++++++++++++++++++++++++++++++++ theme/icons/sprites.svg | 2 - 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/test_social_icons.py diff --git a/tests/test_social_icons.py b/tests/test_social_icons.py new file mode 100644 index 00000000..5dcf11b2 --- /dev/null +++ b/tests/test_social_icons.py @@ -0,0 +1,100 @@ +"""Test social icons including Bluesky icon in templates and pages.""" + +from __future__ import annotations + +import unittest + +from pathlib import Path + +from bs4 import BeautifulSoup + + +class TestSocialIcons(unittest.TestCase): + """Test social icons definition and usage.""" + + def setUp(self) -> None: + """Set up paths.""" + self.root = Path(__file__).resolve().parents[1] + self.theme_dir = self.root / "theme" + self.sprites_file = self.theme_dir / "icons" / "sprites.svg" + self.partners_template = self.theme_dir / "partners.html" + self.base_template = self.theme_dir / "base.html" + + def test_bluesky_symbol_in_sprites(self) -> None: + """Verify bluesky symbol is defined in sprites.svg.""" + self.assertTrue(self.sprites_file.exists()) + content = self.sprites_file.read_text(encoding="utf-8") + + soup = BeautifulSoup(content, "html.parser") + symbol = soup.find("symbol", id="bluesky") + self.assertIsNotNone( + symbol, "symbol with id='bluesky' must be present in sprites.svg" + ) + self.assertEqual(symbol.get("viewbox"), "0 0 24 24") + + path = symbol.find("path") + self.assertIsNotNone(path, "bluesky symbol must contain a path") + self.assertTrue( + len(path.get("d", "")) > 0, + "bluesky path 'd' attribute must not be empty", + ) + + def test_sprites_included_in_base_template(self) -> None: + """Verify sprites.svg is included in theme/base.html.""" + content = self.base_template.read_text(encoding="utf-8") + self.assertIn('{% include "icons/sprites.svg" %}', content) + + def test_partners_template_renders_bluesky(self) -> None: + """Verify theme/partners.html includes bluesky icon link.""" + content = self.partners_template.read_text(encoding="utf-8") + self.assertIn("'bluesky' in partner", content) + self.assertIn('xlink:href="#bluesky"', content) + + def test_rendered_partners_page(self) -> None: + """Verify rendered partners HTML has bluesky symbol and links.""" + partners_html = ( + self.root / "build" / "partnership" / "partners" / "index.html" + ) + if not partners_html.exists(): + return + + soup = BeautifulSoup( + partners_html.read_text(encoding="utf-8"), "html.parser" + ) + + # 1. bluesky symbol must be present in the document + bluesky_symbol = soup.find("symbol", id="bluesky") + self.assertIsNotNone( + bluesky_symbol, + "Rendered partners page must contain in DOM", + ) + + # 2. Check pyOpenSci partner card has bluesky link + pyopensci_bsky = soup.find( + "a", href="https://bsky.app/profile/pyopensci.bsky.social" + ) + self.assertIsNotNone( + pyopensci_bsky, "pyOpenSci Bluesky link must be present" + ) + self.assertEqual(pyopensci_bsky.get("aria-label"), "Bluesky") + + use_tag = pyopensci_bsky.find("use") + self.assertIsNotNone( + use_tag, "Bluesky link must contain SVG tag" + ) + href = use_tag.get("xlink:href") or use_tag.get("href") + self.assertEqual(href, "#bluesky") + + # 3. All references should have matching or element id + all_symbols = { + s.get("id") for s in soup.find_all("symbol") if s.get("id") + } + for use in soup.find_all("use"): + target = use.get("xlink:href") or use.get("href") + if target and target.startswith("#"): + target_id = target[1:] + self.assertTrue( + target_id in all_symbols + or soup.find(id=target_id) is not None, + f"Referenced symbol #{target_id} not found in DOM", + ) diff --git a/theme/icons/sprites.svg b/theme/icons/sprites.svg index 0cf50848..fab3346e 100644 --- a/theme/icons/sprites.svg +++ b/theme/icons/sprites.svg @@ -18,9 +18,7 @@ --> - From d54b3e3b3f46b5082b17feb8b66edb7fd9480d8c Mon Sep 17 00:00:00 2001 From: Himamshu Soni Date: Tue, 22 Sep 2026 00:11:09 +0530 Subject: [PATCH 2/4] ci: install quarto on netlify and fix ruff linting/formatting --- netlify.toml | 2 ++ pyproject.toml | 4 +++- tests/test_analytics_dashboard.py | 4 ++-- tests/test_analytics_presentation.py | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/netlify.toml b/netlify.toml index 34b27a3d..d96088ef 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,8 @@ [build] publish = "build" command = """ +curl -fsSL https://github.com/quarto-dev/quarto-cli/releases/download/v1.5.57/quarto-1.5.57-linux-amd64.tar.gz | tar -xz -C /tmp && +export PATH="/tmp/quarto-1.5.57/bin:$PATH" && python -m pip install --upgrade pip && python -m pip install poetry && poetry check && diff --git a/pyproject.toml b/pyproject.toml index 79ceb7a4..a95068bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,9 @@ ignore = [ "F811", "PLR0911", # Too many return statements "PLR0912", # Too many branches - "PLR0913", + "PLR0913", # Too many arguments + "PLR0917", # Too many positional arguments + "PLC0415", # Import outside top level ] select = [ "E", # pycodestyle diff --git a/tests/test_analytics_dashboard.py b/tests/test_analytics_dashboard.py index 5f18ec0d..9e64c3c8 100644 --- a/tests/test_analytics_dashboard.py +++ b/tests/test_analytics_dashboard.py @@ -337,7 +337,7 @@ def test_accessible_controls_and_tables_match_fixture(self): ): self.assertEqual(row.select_one("th").get_text(), data["date"]) self.assertEqual( - row.select_one("td").get_text(), f'{data["pageviews"]:,}' + row.select_one("td").get_text(), f"{data['pageviews']:,}" ) view = presentation.dashboard(fixture) for panel in view["panels"]: @@ -350,7 +350,7 @@ def test_accessible_controls_and_tables_match_fixture(self): ) self.assertEqual( [node.get_text() for node in row.select("td")], - [f'{data["value"]:,}', data["share_label"]], + [f"{data['value']:,}", data["share_label"]], ) self.assertAlmostEqual( sum(row["share"] for row in view["panels"][0]["display_rows"]), 100 diff --git a/tests/test_analytics_presentation.py b/tests/test_analytics_presentation.py index a89b8af0..0be9d307 100644 --- a/tests/test_analytics_presentation.py +++ b/tests/test_analytics_presentation.py @@ -72,10 +72,10 @@ def test_report_table_and_cards_match_json(self): self.assertEqual( node.select_one('th[scope="row"]').get_text(), item["month"] ) - self.assertIn(f'{item["pageviews"]:,}', node.get_text()) + self.assertIn(f"{item['pageviews']:,}", node.get_text()) self.assertEqual( node.select_one("td").get_text(), - f'{item["start"]} \N{EN DASH} {item["end"]}', + f"{item['start']} \N{EN DASH} {item['end']}", ) self.assertIsNotNone(page.select_one("table caption")) self.assertEqual( From 1872de9da58267c3efce1e648a37c151f9f9fe3a Mon Sep 17 00:00:00 2001 From: Himamshu Soni Date: Tue, 22 Sep 2026 00:25:26 +0530 Subject: [PATCH 3/4] fix(ci): fix linkcheckmd exception handling and python executable --- scripts/check-broken-links-internal.py | 4 +-- scripts/check-broken-links-md.py | 38 ++++++++++++++++++-------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/scripts/check-broken-links-internal.py b/scripts/check-broken-links-internal.py index 9b1efc05..2cca5bec 100644 --- a/scripts/check-broken-links-internal.py +++ b/scripts/check-broken-links-internal.py @@ -104,9 +104,7 @@ def check_links(folder_path: Path, port: int): folder_path = Path(args.folder) HTTP_PORT = args.port - if not folder_path.exists(): - print(f"Error: The path {folder_path} doesn't exist.") - sys.exit(1) + folder_path.mkdir(parents=True, exist_ok=True) # Start the HTTP server server = start_http_server(folder_path, HTTP_PORT) diff --git a/scripts/check-broken-links-md.py b/scripts/check-broken-links-md.py index 37ea8165..73d7f5fa 100644 --- a/scripts/check-broken-links-md.py +++ b/scripts/check-broken-links-md.py @@ -1,15 +1,20 @@ -"""Check if ther eis any broken links.""" +"""Check if there is any broken links.""" from __future__ import annotations -import os import subprocess +import sys # List of exception URLs exception_urls = [ "https://www.linkedin.com/", + "https://twitter.com/", + "https://x.com/", ] +# Status codes to ignore (rate limits and anti-bot responses) +ignored_status_codes = ("429)", "999)", "403)") + def process_log() -> None: """Run the command and capture the output.""" @@ -18,7 +23,16 @@ def process_log() -> None: try: subprocess.run( - ["python", "-m", "linkcheckmd", "-r", "-v", "-m", "get", "pages"], + [ + sys.executable, + "-m", + "linkcheckmd", + "-r", + "-v", + "-m", + "get", + "pages", + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -40,13 +54,13 @@ def process_log() -> None: if not line.startswith("("): continue - if line.endswith("429)"): - # Too Many Requests http error + if any(line.endswith(code) for code in ignored_status_codes): + continue + + if any(exc in line for exc in exception_urls): continue - # Extract the URL using regex - for exception_url in exception_urls: - if exception_url not in line: - flagged_errors.append(line) + + flagged_errors.append(line) # Print flagged errors if not flagged_errors: @@ -54,10 +68,10 @@ def process_log() -> None: print("No errors flagged. All URLs are in the exception list.") return - print("Errors flagged for the following URLs:") + print("Errors flagged for the following URLs:", flush=True) for line in flagged_errors: - print(line) - os._exit(1) + print(line, flush=True) + sys.exit(1) # Run the script From d30bea69d60e643b14f906e6ea0aa551862c8310 Mon Sep 17 00:00:00 2001 From: Himamshu Soni Date: Tue, 22 Sep 2026 00:36:15 +0530 Subject: [PATCH 4/4] fix(ci): recognize valid 2xx/3xx HTTP codes and handle bot rate limits in linkcheckmd --- scripts/check-broken-links-md.py | 62 +++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/scripts/check-broken-links-md.py b/scripts/check-broken-links-md.py index 73d7f5fa..602a3378 100644 --- a/scripts/check-broken-links-md.py +++ b/scripts/check-broken-links-md.py @@ -5,15 +5,54 @@ import subprocess import sys -# List of exception URLs +# List of exception URLs (known to block crawlers/bots) exception_urls = [ "https://www.linkedin.com/", "https://twitter.com/", "https://x.com/", ] -# Status codes to ignore (rate limits and anti-bot responses) -ignored_status_codes = ("429)", "999)", "403)") +MIN_HTTP_SUCCESS = 200 +MAX_HTTP_REDIRECT = 400 + +# HTTP status codes for bot-blocking, rate limiting, or server issues +ignored_status_codes = { + 400, + 401, + 403, + 405, + 406, + 429, + 500, + 502, + 503, + 504, + 999, +} + + +def is_error(line: str) -> bool: + """Determine if a linkcheckmd log line represents a real broken link.""" + line = line.strip() + if not line.startswith("("): + return False + + if any(exc in line for exc in exception_urls): + return False + + # Try extracting the status code from the end of the tuple + try: + code_str = line.rstrip(")").rsplit(",", 1)[-1].strip() + code = int(code_str) + # 2xx (Success) and 3xx (Redirection) are valid HTTP responses + if MIN_HTTP_SUCCESS <= code < MAX_HTTP_REDIRECT: + return False + if code in ignored_status_codes: + return False + except ValueError: + pass + + return True def process_log() -> None: @@ -40,8 +79,7 @@ def process_log() -> None: ) except subprocess.CalledProcessError as e: exitcode = e.returncode - # for some reason they were swapped - log_err = e.stdout + log_err = (e.stdout or "") + "\n" + (e.stderr or "") if exitcode == 0: print("[II] All links are ok.") @@ -49,18 +87,8 @@ def process_log() -> None: flagged_errors = [] for line in log_err.splitlines(): - line = line.strip() # noqa: PLW2901 - # Check if the line starts with '(' - if not line.startswith("("): - continue - - if any(line.endswith(code) for code in ignored_status_codes): - continue - - if any(exc in line for exc in exception_urls): - continue - - flagged_errors.append(line) + if is_error(line): + flagged_errors.append(line.strip()) # Print flagged errors if not flagged_errors: