Skip to main content

fetch: add some helper functions for calling HTTP methods

ID
644f388
date
2026-03-30 05:34:20+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
6aad8e3
message
fetch: add some helper functions for calling HTTP methods
changed files
16 files, 719 additions, 15 deletions

Changed files

CHANGELOG.md (3706) → CHANGELOG.md (3842)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 336df4a..e8a2d42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # CHANGELOG
 
+## v30 - 2026-03-30
+
+Add a `chives.fetch` package which has some helper functions for making HTTP requests using the standard library.
+
 ## v29 - 2026-03-29
 
 Add a `chives.text` package which exports a `smartify()` function for applying SmartyPants-style formatting to a string -- adding curly quotes and smart dashes.

dev_requirements.in (90) → dev_requirements.in (96)

diff --git a/dev_requirements.in b/dev_requirements.in
index 99e47b4..e755bd4 100644
--- a/dev_requirements.in
+++ b/dev_requirements.in
@@ -1,4 +1,4 @@
--e file:.[media,static_site_tests,text,urls]
+-e file:.[fetch,media,static_site_tests,text,urls]
 
 build
 mypy

dev_requirements.txt (2306) → dev_requirements.txt (2306)

diff --git a/dev_requirements.txt b/dev_requirements.txt
index 274e85e..a377f40 100644
--- a/dev_requirements.txt
+++ b/dev_requirements.txt
@@ -2,7 +2,7 @@
 #    uv pip compile dev_requirements.in --output-file=dev_requirements.txt --exclude-newer=P7D
 -e file:.
     # via -r dev_requirements.in
-build==1.4.0
+build==1.4.2
     # via -r dev_requirements.in
 certifi==2026.2.25
     # via
@@ -48,7 +48,7 @@ mypy==1.19.1
     # via -r dev_requirements.in
 mypy-extensions==1.1.0
     # via mypy
-nh3==0.3.3
+nh3==0.3.4
     # via readme-renderer
 packaging==26.0
     # via
@@ -67,7 +67,7 @@ pluggy==1.6.0
     #   pytest-cov
 pyee==13.0.1
     # via playwright
-pygments==2.19.2
+pygments==2.20.0
     # via
     #   pytest
     #   readme-renderer
@@ -89,7 +89,7 @@ rapidfuzz==3.14.3
     # via alexwlchan-chives
 readme-renderer==44.0
     # via twine
-requests==2.32.5
+requests==2.33.0
     # via
     #   requests-toolbelt
     #   twine
@@ -99,7 +99,7 @@ rfc3986==2.0.0
     # via twine
 rich==14.3.3
     # via twine
-ruff==0.15.7
+ruff==0.15.8
     # via -r dev_requirements.in
 smartypants==2.0.2
     # via alexwlchan-chives

pyproject.toml (1320) → pyproject.toml (1340)

diff --git a/pyproject.toml b/pyproject.toml
index 5f44086..d5c3a39 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,7 @@ dynamic = ["version"]
 license = "MIT"
 
 [project.optional-dependencies]
+fetch = ["certifi"]
 media = ["Pillow"]
 static_site_tests = ["playwright", "pytest", "rapidfuzz"]
 text = ["smartypants"]

src/chives/__init__.py (391) → src/chives/__init__.py (391)

diff --git a/src/chives/__init__.py b/src/chives/__init__.py
index 1401076..d69250d 100644
--- a/src/chives/__init__.py
+++ b/src/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
 
 """
 
-__version__ = "29"
+__version__ = "30"

src/chives/fetch.py (0) → src/chives/fetch.py (2487)

diff --git a/src/chives/fetch.py b/src/chives/fetch.py
new file mode 100644
index 0000000..1091898
--- /dev/null
+++ b/src/chives/fetch.py
@@ -0,0 +1,103 @@
+"""
+Make HTTP requests using the standard library.
+"""
+
+import ssl
+from typing import Literal
+import urllib.parse
+import urllib.request
+
+import certifi
+
+
+__all__ = ["fetch_url", "fetch_image", "ImageFormat"]
+
+
+def _build_request(
+    url: str, params: dict[str, str] | None, headers: dict[str, str] | None
+) -> urllib.request.Request:
+    """
+    Build a request based on the given inputs.
+    """
+    if params:
+        params_str = urllib.parse.urlencode(params)
+        url = url + "?" + params_str
+
+    req = urllib.request.Request(url)
+
+    if headers:
+        for name, value in headers.items():
+            req.add_header(name, value)
+
+    return req
+
+
+def fetch_url(
+    url: str,
+    params: dict[str, str] | None = None,
+    headers: dict[str, str] | None = None,
+) -> bytes:
+    """
+    Fetch the contents of the given URL and return the body of
+    the response.
+    """
+    ssl_context = ssl.create_default_context(cafile=certifi.where())
+
+    req = _build_request(url, params, headers)
+
+    resp = urllib.request.urlopen(req, context=ssl_context)
+
+    data = resp.read()
+    resp.close()
+    assert isinstance(data, bytes), type(data)
+
+    return data
+
+
+ImageFormat = Literal["jpg", "png", "gif", "webp"]
+
+
+def _guess_image_format(content_type: str | None) -> ImageFormat:
+    """
+    Given the Content-Type response header, guess the image format.
+    """
+    if content_type is None:
+        raise RuntimeError(
+            "no Content-Type header in response, cannot guess image format"
+        )
+
+    content_type_mapping: dict[str, ImageFormat] = {
+        "image/jpeg": "jpg",
+        "image/png": "png",
+        "image/gif": "gif",
+        "image/webp": "webp",
+    }
+
+    try:
+        return content_type_mapping[content_type]
+    except KeyError:
+        raise RuntimeError(f"unrecognised image format: {content_type}")
+
+
+def fetch_image(
+    url: str,
+    params: dict[str, str] | None = None,
+    headers: dict[str, str] | None = None,
+) -> tuple[bytes, ImageFormat]:
+    """
+    Fetch an image from the given URL and return the image data and
+    image format.
+    """
+    ssl_context = ssl.create_default_context(cafile=certifi.where())
+
+    req = _build_request(url, params, headers)
+
+    resp = urllib.request.urlopen(req, context=ssl_context)
+
+    img_format = _guess_image_format(content_type=resp.headers["content-type"])
+
+    img_data = resp.read()
+    resp.close()
+    assert isinstance(img_data, bytes), type(img_data)
+
+    return img_data, img_format

tests/fixtures/cassettes/TestFetchImage.test_http_200.yml (0) → tests/fixtures/cassettes/TestFetchImage.test_http_200.yml (16041)

diff --git a/tests/fixtures/cassettes/TestFetchImage.test_http_200.yml b/tests/fixtures/cassettes/TestFetchImage.test_http_200.yml
new file mode 100644
index 0000000..31a0d50
--- /dev/null
+++ b/tests/fixtures/cassettes/TestFetchImage.test_http_200.yml
@@ -0,0 +1,257 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Connection:
+      - close
+      Host:
+      - api.tumblr.com
+      User-Agent:
+      - Python-urllib/3.14
+    method: GET
+    uri: https://api.tumblr.com/v2/blog/thecroissantgirl.tumblr.com/avatar
+  response:
+    body:
+      string: '{"meta":{"status":302,"msg":"Found"},"response":{"avatar_url":"https://64.media.tumblr.com/b9db8c282532e62b4009e9b41e9b0cb0/1773c30d064a7637-b4/s64x64u_c1/f88f4f722bf06397895453a98ac976eaf767de08.png"}}'
+    headers:
+      Alt-Svc:
+      - h3=":443"; ma=86400
+      Connection:
+      - close
+      Content-Length:
+      - '202'
+      Content-Type:
+      - application/json
+      Date:
+      - Mon, 30 Mar 2026 05:04:45 GMT
+      Location:
+      - https://64.media.tumblr.com/b9db8c282532e62b4009e9b41e9b0cb0/1773c30d064a7637-b4/s64x64u_c1/f88f4f722bf06397895453a98ac976eaf767de08.png
+      Server:
+      - nginx
+      Server-Timing:
+      - a8c-cdn, dc;desc=lhr, cache;desc=BYPASS;dur=243.0
+      Strict-Transport-Security:
+      - max-age=31536000; preload
+      X-Cache-Avatar:
+      - 'true'
+      X-Rid:
+      - 35e6c63c1df237d105ecbc7e70c6e78f
+      X-UA-Compatible:
+      - IE=Edge,chrome=1
+    status:
+      code: 302
+      message: Found
+- request:
+    body: null
+    headers:
+      Connection:
+      - close
+      Host:
+      - 64.media.tumblr.com
+      User-Agent:
+      - Python-urllib/3.14
+    method: GET
+    uri: https://64.media.tumblr.com/b9db8c282532e62b4009e9b41e9b0cb0/1773c30d064a7637-b4/s64x64u_c1/f88f4f722bf06397895453a98ac976eaf767de08.png
+  response:
+    body:
+      string: !!binary |
+        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
+        b5UTb+T6FQAAAABJRU5ErkJggg==
+    headers:
+      Access-Control-Allow-Methods:
+      - GET
+      Access-Control-Allow-Origin:
+      - '*'
+      Access-Control-Max-Age:
+      - '86400'
+      Alt-Svc:
+      - h3=":443"; ma=86400
+      Cache-Control:
+      - max-age=315360000
+      Connection:
+      - close
+      Content-Disposition:
+      - inline; filename="tumblr_b9db8c282532e62b4009e9b41e9b0cb0_f88f4f72_64.png"
+      Content-Length:
+      - '9025'
+      Content-Type:
+      - image/png
+      Date:
+      - Mon, 30 Mar 2026 05:04:45 GMT
+      Etag:
+      - '"7c7a5affc81421707c94439f0333734c-1498089600-0de4da0"'
+      Last-Modified:
+      - Fri, 02 Jun 2023 20:33:11 GMT
+      Server:
+      - nginx
+      Server-Timing:
+      - a8c-cdn, dc;desc=lhr, cache;desc=HIT;dur=0.0
+      Strict-Transport-Security:
+      - max-age=31536000; preload
+      Timing-Allow-Origin:
+      - '*'
+      X-nc:
+      - HIT lhr 24
+      x-frames:
+      - '1'
+    status:
+      code: 200
+      message: OK
+version: 1

tests/fixtures/cassettes/TestFetchImage.test_no_content_type_header.yml (0) → tests/fixtures/cassettes/TestFetchImage.test_no_content_type_header.yml (582)

diff --git a/tests/fixtures/cassettes/TestFetchImage.test_no_content_type_header.yml b/tests/fixtures/cassettes/TestFetchImage.test_no_content_type_header.yml
new file mode 100644
index 0000000..2cbcf91
--- /dev/null
+++ b/tests/fixtures/cassettes/TestFetchImage.test_no_content_type_header.yml
@@ -0,0 +1,32 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Connection:
+      - close
+      Host:
+      - httpbin.org
+      User-Agent:
+      - Python-urllib/3.14
+    method: GET
+    uri: http://httpbin.org/status/200
+  response:
+    body:
+      string: ''
+    headers:
+      Access-Control-Allow-Credentials:
+      - 'true'
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - close
+      Content-Length:
+      - '0'
+      Date:
+      - Mon, 30 Mar 2026 05:20:46 GMT
+      Server:
+      - gunicorn/19.9.0
+    status:
+      code: 200
+      message: OK
+version: 1

tests/fixtures/cassettes/TestFetchImage.test_non_image.yml (0) → tests/fixtures/cassettes/TestFetchImage.test_non_image.yml (635)

diff --git a/tests/fixtures/cassettes/TestFetchImage.test_non_image.yml b/tests/fixtures/cassettes/TestFetchImage.test_non_image.yml
new file mode 100644
index 0000000..706342a
--- /dev/null
+++ b/tests/fixtures/cassettes/TestFetchImage.test_non_image.yml
@@ -0,0 +1,34 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Connection:
+      - close
+      Host:
+      - httpbin.org
+      User-Agent:
+      - Python-urllib/3.14
+    method: GET
+    uri: http://httpbin.org/status/200
+  response:
+    body:
+      string: ''
+    headers:
+      Access-Control-Allow-Credentials:
+      - 'true'
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - close
+      Content-Length:
+      - '0'
+      Content-Type:
+      - text/html; charset=utf-8
+      Date:
+      - Mon, 30 Mar 2026 05:21:30 GMT
+      Server:
+      - gunicorn/19.9.0
+    status:
+      code: 200
+      message: OK
+version: 1

tests/fixtures/cassettes/TestFetchUrl.test_headers.yml (0) → tests/fixtures/cassettes/TestFetchUrl.test_headers.yml (1006)

diff --git a/tests/fixtures/cassettes/TestFetchUrl.test_headers.yml b/tests/fixtures/cassettes/TestFetchUrl.test_headers.yml
new file mode 100644
index 0000000..ea52d97
--- /dev/null
+++ b/tests/fixtures/cassettes/TestFetchUrl.test_headers.yml
@@ -0,0 +1,41 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Connection:
+      - close
+      Host:
+      - httpbin.org
+      User-Agent:
+      - Python-urllib/3.14
+      X-Author:
+      - alexwlchan
+      X-Package:
+      - chives
+    method: GET
+    uri: http://httpbin.org/headers
+  response:
+    body:
+      string: "{\n  \"headers\": {\n    \"Accept-Encoding\": \"identity\", \n    \"Host\":
+        \"httpbin.org\", \n    \"User-Agent\": \"Python-urllib/3.14\", \n    \"X-Amzn-Trace-Id\":
+        \"Root=1-69ca0934-55b29bd85f2bff5772b060a7\", \n    \"X-Author\": \"alexwlchan\",
+        \n    \"X-Package\": \"chives\"\n  }\n}\n"
+    headers:
+      Access-Control-Allow-Credentials:
+      - 'true'
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - close
+      Content-Length:
+      - '253'
+      Content-Type:
+      - application/json
+      Date:
+      - Mon, 30 Mar 2026 05:25:08 GMT
+      Server:
+      - gunicorn/19.9.0
+    status:
+      code: 200
+      message: OK
+version: 1

tests/fixtures/cassettes/TestFetchUrl.test_http_200.yml (0) → tests/fixtures/cassettes/TestFetchUrl.test_http_200.yml (670)

diff --git a/tests/fixtures/cassettes/TestFetchUrl.test_http_200.yml b/tests/fixtures/cassettes/TestFetchUrl.test_http_200.yml
new file mode 100644
index 0000000..0f412af
--- /dev/null
+++ b/tests/fixtures/cassettes/TestFetchUrl.test_http_200.yml
@@ -0,0 +1,38 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Connection:
+      - close
+      Host:
+      - httpbin.org
+      User-Agent:
+      - Python-urllib/3.14
+    method: GET
+    uri: http://httpbin.org/robots.txt
+  response:
+    body:
+      string: 'User-agent: *
+
+        Disallow: /deny
+
+        '
+    headers:
+      Access-Control-Allow-Credentials:
+      - 'true'
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - close
+      Content-Length:
+      - '30'
+      Content-Type:
+      - text/plain
+      Date:
+      - Mon, 30 Mar 2026 04:59:39 GMT
+      Server:
+      - gunicorn/19.9.0
+    status:
+      code: 200
+      message: OK
+version: 1

tests/fixtures/cassettes/TestFetchUrl.test_http_404.yml (0) → tests/fixtures/cassettes/TestFetchUrl.test_http_404.yml (642)

diff --git a/tests/fixtures/cassettes/TestFetchUrl.test_http_404.yml b/tests/fixtures/cassettes/TestFetchUrl.test_http_404.yml
new file mode 100644
index 0000000..a2dd9fd
--- /dev/null
+++ b/tests/fixtures/cassettes/TestFetchUrl.test_http_404.yml
@@ -0,0 +1,34 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Connection:
+      - close
+      Host:
+      - httpbin.org
+      User-Agent:
+      - Python-urllib/3.14
+    method: GET
+    uri: http://httpbin.org/status/404
+  response:
+    body:
+      string: ''
+    headers:
+      Access-Control-Allow-Credentials:
+      - 'true'
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - close
+      Content-Length:
+      - '0'
+      Content-Type:
+      - text/html; charset=utf-8
+      Date:
+      - Mon, 30 Mar 2026 05:00:56 GMT
+      Server:
+      - gunicorn/19.9.0
+    status:
+      code: 404
+      message: NOT FOUND
+version: 1

tests/fixtures/cassettes/TestFetchUrl.test_query_params.yml (0) → tests/fixtures/cassettes/TestFetchUrl.test_query_params.yml (1103)

diff --git a/tests/fixtures/cassettes/TestFetchUrl.test_query_params.yml b/tests/fixtures/cassettes/TestFetchUrl.test_query_params.yml
new file mode 100644
index 0000000..8365730
--- /dev/null
+++ b/tests/fixtures/cassettes/TestFetchUrl.test_query_params.yml
@@ -0,0 +1,38 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Connection:
+      - close
+      Host:
+      - httpbin.org
+      User-Agent:
+      - Python-urllib/3.14
+    method: GET
+    uri: http://httpbin.org/get?package=chives&author=alexwlchan
+  response:
+    body:
+      string: "{\n  \"args\": {\n    \"author\": \"alexwlchan\", \n    \"package\":
+        \"chives\"\n  }, \n  \"headers\": {\n    \"Accept-Encoding\": \"identity\",
+        \n    \"Host\": \"httpbin.org\", \n    \"User-Agent\": \"Python-urllib/3.14\",
+        \n    \"X-Amzn-Trace-Id\": \"Root=1-69ca0986-61e735f66a8e8cde268dbe06\"\n
+        \ }, \n  \"origin\": \"89.168.152.222\", \n  \"url\": \"http://httpbin.org/get?package=chives&author=alexwlchan\"\n}\n"
+    headers:
+      Access-Control-Allow-Credentials:
+      - 'true'
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - close
+      Content-Length:
+      - '365'
+      Content-Type:
+      - application/json
+      Date:
+      - Mon, 30 Mar 2026 05:26:30 GMT
+      Server:
+      - gunicorn/19.9.0
+    status:
+      code: 200
+      message: OK
+version: 1

tests/stubs/vcr.pyi (342) → tests/stubs/vcr.pyi (456)

diff --git a/tests/stubs/vcr.pyi b/tests/stubs/vcr.pyi
index 7df326e..48ed9d7 100644
--- a/tests/stubs/vcr.pyi
+++ b/tests/stubs/vcr.pyi
@@ -1,4 +1,5 @@
-import contextlib
+from contextlib import AbstractContextManager
+from typing import Any, Callable
 
 from vcr.cassette import Cassette
 
@@ -8,4 +9,5 @@ def use_cassette(
     decode_compressed_response: bool,
     filter_query_parameters: list[tuple[str, str]] | None = None,
     filter_headers: list[tuple[str, str]] | None = None,
-) -> contextlib.AbstractContextManager[Cassette]: ...
+    before_record_response: Callable[[Any], Any] | None = None,
+) -> AbstractContextManager[Cassette]: ...

tests/test_fetch.py (0) → tests/test_fetch.py (3309)

diff --git a/tests/test_fetch.py b/tests/test_fetch.py
new file mode 100644
index 0000000..aba5278
--- /dev/null
+++ b/tests/test_fetch.py
@@ -0,0 +1,116 @@
+"""
+Tests for `chives.fetch`.
+"""
+
+from io import BytesIO
+import json
+from typing import Any
+from urllib.error import HTTPError
+
+from PIL import Image
+import pytest
+import vcr
+from vcr.cassette import Cassette
+
+from chives.fetch import fetch_image, fetch_url
+
+
+class TestFetchUrl:
+    """
+    Tests for `fetch_url`.
+    """
+
+    def test_http_200(self, vcr_cassette: Cassette) -> None:
+        """
+        Fetch a URL and check we get the expected response body.
+        """
+        resp = fetch_url("http://httpbin.org/robots.txt")
+        assert resp == b"User-agent: *\nDisallow: /deny\n"
+
+    def test_http_404(self, vcr_cassette: Cassette) -> None:
+        """
+        Fetch a URL that returns a 404 Not Found error.
+        """
+        with pytest.raises(HTTPError) as exc:
+            fetch_url("http://httpbin.org/status/404")
+
+        assert exc.value.code == 404
+        exc.value.close()
+
+    def test_query_params(self, vcr_cassette: Cassette) -> None:
+        """
+        Pass some query parameters in the fetch request.
+        """
+        resp = fetch_url(
+            "http://httpbin.org/get",
+            params={"package": "chives", "author": "alexwlchan"},
+        )
+
+        args = json.loads(resp)["args"]
+
+        assert args["package"] == "chives"
+        assert args["author"] == "alexwlchan"
+
+    def test_headers(self, vcr_cassette: Cassette) -> None:
+        """
+        Pass some headers in the fetch request.
+        """
+        resp = fetch_url(
+            "http://httpbin.org/headers",
+            headers={"X-Package": "chives", "X-Author": "alexwlchan"},
+        )
+
+        headers = json.loads(resp)["headers"]
+
+        assert headers["X-Package"] == "chives"
+        assert headers["X-Author"] == "alexwlchan"
+
+
+class TestFetchImage:
+    """
+    Tests for `fetch_image`.
+    """
+
+    def test_http_200(self, vcr_cassette: Cassette) -> None:
+        """
+        Fetch an image and check we get the correct format.
+        """
+        url = "https://api.tumblr.com/v2/blog/thecroissantgirl.tumblr.com/avatar"
+
+        img_data, img_format = fetch_image(url)
+        assert img_format == "png"
+
+        im = Image.open(BytesIO(img_data))
+        assert im.format == "PNG"
+
+    def test_non_image(self, vcr_cassette: Cassette) -> None:
+        """
+        Fetching an "image" which has a non-image Content-Type header
+        throws an error.
+        """
+        url = "http://httpbin.org/status/200"
+
+        with pytest.raises(RuntimeError, match="unrecognised image format"):
+            fetch_image(url)
+
+    def test_no_content_type_header(self, cassette_name: str) -> None:
+        """
+        Fetching a URL which doesn't return a Content-Type header
+        throws an error.
+        """
+        url = "http://httpbin.org/status/200"
+
+        def delete_content_type_header(response: Any) -> Any:
+            response["headers"]["Content-Type"] = []
+            return response
+
+        with vcr.use_cassette(
+            cassette_name,
+            cassette_library_dir="tests/fixtures/cassettes",
+            decode_compressed_response=True,
+            before_record_response=delete_content_type_header,
+        ):
+            with pytest.raises(
+                RuntimeError, match="no Content-Type header in response"
+            ):
+                fetch_image(url)

tests/test_static_site_tests.py (10497) → tests/test_static_site_tests.py (10547)

diff --git a/tests/test_static_site_tests.py b/tests/test_static_site_tests.py
index b92c57a..e84181d 100644
--- a/tests/test_static_site_tests.py
+++ b/tests/test_static_site_tests.py
@@ -2,6 +2,7 @@
 Tests for `chives.static_site_tests`.
 """
 
+import os
 from pathlib import Path
 import shutil
 import subprocess
@@ -52,16 +53,16 @@ def create_pyfile(
         from collections.abc import Iterator
         from pathlib import Path, PosixPath
         from typing import Any
-        
+
         import pytest
-        
+
         from chives.static_site_tests import (
             StaticSiteTestSuite,
             browser,
             pytest_generate_tests,
         )
-        
-        
+
+
         class TestSuite(StaticSiteTestSuite[Any]):
             @classmethod
             def get_site_root(self) -> Path:
@@ -76,9 +77,9 @@ def create_pyfile(
 
             def list_tags_in_metadata(self, metadata: Any) -> Iterator[str]:
                 yield from {repr(tags_in_metadata or set())}
-            
+
             date_formats = {repr(date_formats or default_date_formats)}
-            
+
             known_similar_tags = {repr(known_similar_tags or set())}
         """
     )
@@ -280,6 +281,9 @@ def test_checks_for_similar_tags(pytester: Pytester) -> None:
     pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
 
 
+@pytest.mark.skipif(
+    "SKIP_PLAYWRIGHT" in os.environ, reason="skip slow Playwright tests"
+)
 class TestLoadsPageCorrectly:
     """
     Tests for `test_loads_page_correctly`.