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 deletionsCHANGELOG.mddev_requirements.indev_requirements.txtpyproject.tomlsrc/chives/__init__.pysrc/chives/fetch.pytests/fixtures/cassettes/TestFetchImage.test_http_200.ymltests/fixtures/cassettes/TestFetchImage.test_no_content_type_header.ymltests/fixtures/cassettes/TestFetchImage.test_non_image.ymltests/fixtures/cassettes/TestFetchUrl.test_headers.ymltests/fixtures/cassettes/TestFetchUrl.test_http_200.ymltests/fixtures/cassettes/TestFetchUrl.test_http_404.ymltests/fixtures/cassettes/TestFetchUrl.test_query_params.ymltests/stubs/vcr.pyitests/test_fetch.pytests/test_static_site_tests.py
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`.