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/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..602a3378 100644 --- a/scripts/check-broken-links-md.py +++ b/scripts/check-broken-links-md.py @@ -1,15 +1,59 @@ -"""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 +# List of exception URLs (known to block crawlers/bots) exception_urls = [ "https://www.linkedin.com/", + "https://twitter.com/", + "https://x.com/", ] +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: """Run the command and capture the output.""" @@ -18,7 +62,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, @@ -26,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.") @@ -35,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 line.endswith("429)"): - # Too Many Requests http error - continue - # Extract the URL using regex - for exception_url in exception_urls: - if exception_url not in line: - flagged_errors.append(line) + if is_error(line): + flagged_errors.append(line.strip()) # Print flagged errors if not flagged_errors: @@ -54,10 +96,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 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( 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 @@ --> -