---
.reuse/dep5 | 2 +-
fixtures/cassettes/test_pages_publish | 157 ++++++++++++++++++++++
fixtures/test_site.tar.gz | Bin 0 -> 449 bytes
fixtures/test_site/index.html | 19 +++
noxfile.py | 9 +-
src/sourcehut/client.py | 4 +-
src/sourcehut/services/pages.py | 179 ++++++++++++++++++++++++++
tests/integration/test_pages.py | 77 +++++++++++
8 files changed, 443 insertions(+), 4 deletions(-)
create mode 100644 fixtures/cassettes/test_pages_publish
create mode 100644 fixtures/test_site.tar.gz
create mode 100644 fixtures/test_site/index.html
create mode 100644 src/sourcehut/services/pages.py
create mode 100644 tests/integration/test_pages.py
diff --git a/.reuse/dep5 b/.reuse/dep5
index 75c6734..dc65ca2 100644
--- a/.reuse/dep5
+++ b/.reuse/dep5
@@ -1,5 +1,5 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
-Files: fixtures/cassettes/*
+Files: fixtures/*
Copyright: None
License: Unlicense
diff --git a/fixtures/cassettes/test_pages_publish b/fixtures/cassettes/test_pages_publish
new file mode 100644
index 0000000..8f0c81c
--- /dev/null
+++ b/fixtures/cassettes/test_pages_publish
@@ -0,0 +1,157 @@
+interactions:
+- request:
+ body: '{"query": "\n query {\n me {\n username\n }\n }\n ",
+ "variables": {}}'
+ headers:
+ accept:
+ - '*/*'
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '119'
+ content-type:
+ - application/json
+ host:
+ - pages.sr.ht
+ user-agent:
+ - python-httpx/0.24.1
+ method: POST
+ uri: https://pages.sr.ht/query
+ response:
+ content: '{"data":{"me":{"username":"gotmax23-test"}}}'
+ headers:
+ Content-Length:
+ - '44'
+ Content-Type:
+ - application/json
+ Date:
+ - Sun, 13 Aug 2023 01:58:42 GMT
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: !!binary |
+ LS0wNjYwMTFkMjkxNjI5OWFiNGQ3OTg3ZjdkZjYzZTg4Yw0KQ29udGVudC1EaXNwb3NpdGlvbjog
+ Zm9ybS1kYXRhOyBuYW1lPSJvcGVyYXRpb25zIg0KDQp7InF1ZXJ5IjogIlxuICAgICAgICBtdXRh
+ dGlvbiBwdWJsaXNoKFxuICAgICAgICAgICAgJGRvbWFpbjogU3RyaW5nIVxuICAgICAgICAgICAg
+ JGZpbGU6IFVwbG9hZCFcbiAgICAgICAgICAgICRwcm90b2NvbDogUHJvdG9jb2whXG4gICAgICAg
+ ICAgICAkc3ViZGlyZWN0b3J5OiBTdHJpbmdcbiAgICAgICAgICAgICRzaXRlQ29uZmlnOiBTaXRl
+ Q29uZmlnXG4gICAgICAgICkge1xuICAgICAgICAgICAgcHVibGlzaChcbiAgICAgICAgICAgICAg
+ ICBkb21haW46ICRkb21haW5cbiAgICAgICAgICAgICAgICBjb250ZW50OiAkZmlsZVxuICAgICAg
+ ICAgICAgICAgIHByb3RvY29sOiAkcHJvdG9jb2xcbiAgICAgICAgICAgICAgICBzdWJkaXJlY3Rv
+ cnk6ICRzdWJkaXJlY3RvcnlcbiAgICAgICAgICAgICAgICBzaXRlQ29uZmlnOiAkc2l0ZUNvbmZp
+ Z1xuICAgICAgICAgICAgKSB7XG4gICAgICAgICAgICAgICAgaWRcbmRvbWFpblxucHJvdG9jb2xc
+ bmNyZWF0ZWRcbnVwZGF0ZWRcbnZlcnNpb25cbm5vdEZvdW5kXG5cbiAgICAgICAgICAgIH1cbiAg
+ ICAgICAgfVxuXG4gICAgICAgICIsICJ2YXJpYWJsZXMiOiB7ImRvbWFpbiI6ICJnb3RtYXgyMy10
+ ZXN0LnNyaHQuc2l0ZSIsICJwcm90b2NvbCI6ICJIVFRQUyIsICJzdWJkaXJlY3RvcnkiOiAicHl0
+ aG9uLXNvdXJjZWh1dC10ZXN0IiwgInNpdGVDb25maWciOiB7Im5vdEZvdW5kIjogbnVsbCwgImZp
+ bGVDb25maWdzIjogbnVsbH19fQ0KLS0wNjYwMTFkMjkxNjI5OWFiNGQ3OTg3ZjdkZjYzZTg4Yw0K
+ Q29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJtYXAiDQoNCnsiMCI6IFsidmFy
+ aWFibGVzLmZpbGUiXX0NCi0tMDY2MDExZDI5MTYyOTlhYjRkNzk4N2Y3ZGY2M2U4OGMNCkNvbnRl
+ bnQtRGlzcG9zaXRpb246IGZvcm0tZGF0YTsgbmFtZT0iMCI7IGZpbGVuYW1lPSJ1cGxvYWQiDQpD
+ b250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL29jdGV0LXN0cmVhbQ0KDQofiwgIDC7YZAL/c2l0ZS50
+ YXIA7dVba9swFABgP/tXqHnZky3bsZ00KKbQCy2MLdAUtkc1PokEvgTphCYv++2Tq972sO1htLD2
+ fBhknSMs+XLkmMf8ZCH3lyBrMMGrSLzftUkyzp/Ph3iaZGkWsH3wBnYWpXHTBx9TNmUt6hbmaXmc
+ TqdFMSnirEzzcnwcBuTd010N+1hh27zeHENRl/l9jaeTInnZ3iuTIkjzvCyTzPUmrv6zfDIOWPKW
+ 9b/psZV/2HH+lv9PiaMoCq8XZ9+iC93Aab89GL1RuIQ9ztiXvgOf/KxX0FmIrmroUK81mBm76Rof
+ DaOoCkNxdPb1dPl9cc6Gr6kKhXK/lCpkjmgBJVspaSzgfHSzvIimo4cUamyguoSm6dldb5pacB8K
+ BfdXELd9fXgYrdJfh7q+T2x9O1gqbZk7JEOwyO7g1mqE+Cl/hUN2Z6Fm6948hYVkysB6PlKIWzvj
+ 3BpXF/yHf/HZmNt+Z1agdshHFSpg1499tjig6ju2arR7Op+s4PJ5NbpD2BiJ2g0Y1mP9QgTfDnfo
+ b422IUIIIYQQQgghhBBCCCGEEEIIIYT8g5//ep60ACgAAA0KLS0wNjYwMTFkMjkxNjI5OWFiNGQ3
+ OTg3ZjdkZjYzZTg4Yy0tDQo=
+ headers:
+ accept:
+ - '*/*'
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '1556'
+ content-type:
+ - multipart/form-data; boundary=066011d2916299ab4d7987f7df63e88c
+ host:
+ - pages.sr.ht
+ user-agent:
+ - python-httpx/0.24.1
+ method: POST
+ uri: https://pages.sr.ht/query
+ response:
+ content: '{"data":{"publish":{"id":171768,"domain":"gotmax23-test.srht.site","protocol":"https","created":"2023-08-13T01:58:15.991942Z","updated":"2023-08-13T01:58:15.991942Z","version":"cd1ecccdff2e4c1497d7996091d76cf1","notFound":null}}}'
+ headers:
+ Content-Length:
+ - '229'
+ Content-Type:
+ - application/json
+ Date:
+ - Sun, 13 Aug 2023 01:58:42 GMT
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: '{"query": "\n query listSites(\n $cursor: Cursor\n )
+ {\n sites(cursor: $cursor) {\n cursor\n results
+ {\n id\ndomain\nprotocol\ncreated\nupdated\nversion\nnotFound\n\n }\n }\n }\n ",
+ "variables": {"cursor": null}}'
+ headers:
+ accept:
+ - '*/*'
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '337'
+ content-type:
+ - application/json
+ host:
+ - pages.sr.ht
+ user-agent:
+ - python-httpx/0.24.1
+ method: POST
+ uri: https://pages.sr.ht/query
+ response:
+ content: '{"data":{"sites":{"cursor":null,"results":[{"id":171768,"domain":"gotmax23-test.srht.site","protocol":"https","created":"2023-08-13T01:58:15.991942Z","updated":"2023-08-13T01:58:15.991942Z","version":"cd1ecccdff2e4c1497d7996091d76cf1","notFound":null}]}}}'
+ headers:
+ Content-Length:
+ - '255'
+ Content-Type:
+ - application/json
+ Date:
+ - Sun, 13 Aug 2023 01:58:42 GMT
+ http_version: HTTP/1.1
+ status_code: 200
+- request:
+ body: '{"query": "\n mutation unpublish(\n $domain: String!\n $protocol:
+ Protocol!\n ) {\n unpublish(\n domain: $domain\n protocol:
+ $protocol\n ) {\n id\ndomain\nprotocol\ncreated\nupdated\nversion\nnotFound\n\n }\n }\n ",
+ "variables": {"domain": "gotmax23-test.srht.site", "protocol": "HTTPS"}}'
+ headers:
+ accept:
+ - '*/*'
+ accept-encoding:
+ - gzip, deflate
+ connection:
+ - keep-alive
+ content-length:
+ - '416'
+ content-type:
+ - application/json
+ host:
+ - pages.sr.ht
+ user-agent:
+ - python-httpx/0.24.1
+ method: POST
+ uri: https://pages.sr.ht/query
+ response:
+ content: '{"data":{"unpublish":{"id":171768,"domain":"gotmax23-test.srht.site","protocol":"","created":"2023-08-13T01:58:15.991942Z","updated":"2023-08-13T01:58:15.991942Z","version":"cd1ecccdff2e4c1497d7996091d76cf1","notFound":null}}}'
+ headers:
+ Content-Length:
+ - '226'
+ Content-Type:
+ - application/json
+ Date:
+ - Sun, 13 Aug 2023 01:58:42 GMT
+ http_version: HTTP/1.1
+ status_code: 200
+version: 1
diff --git a/fixtures/test_site.tar.gz b/fixtures/test_site.tar.gz
new file mode 100644
index 0000000000000000000000000000000000000000..c45da8545c022dce9ac218d07d911748bd1338cb
GIT binary patch
literal 449
zcmV;y0Y3g8iwFn|F4$xO|8r?{WiE7KaschsTWi}e6aZj9`&X!W*^@2Xv7Iz1rqBy5
zj4jX<wnsHSi3Gj`>4YXP`|Xpf-uAHVVYIOJd>CY%BP{uH<e10&WFhyLAZswet4O@x
zwMjD0pU;PQnq+Afuzm0bb{3^^(+3|@Hf1g97UpSroKB}jF)8A#O!IPl90ugw(_Ibw
zSYg||-W)?wm-&5-r;{Ri+1@LYB1rSRER)RjCa(Uod@>GL@|N|#>9Li6*m3@r|4)gd
zC<?C^XWya=-N0$TJs7=FxP(2<*q6QopZu&>&{>GCYUog}HJBN@=^7smqo@i)adh_e
zboqU8&W5k43WajNlqzJ7XaOZ#DQPU=d~~zCh^C{56l!dsx`d|b*<Ej%T5ul<1@~7F
zw|#wh7_HJ@hq}H`Y<)XeDs7oNBtx*s?%>vHg!n0cg+q2{p=RseJcWg1%D{R)QixkS
z<J=lo7yO5R@!6Q$erHyocF0E+Dqz=-eYQBD>N~b-w0k<Atl;u_HM&FC7>U{q7}jI`
rLImFqcj#}8wjn|YA%qY@2qA<JLI@#*5JCtcg#3e_|9YOZ04M+e1i9QJ
literal 0
HcmV?d00001
diff --git a/fixtures/test_site/index.html b/fixtures/test_site/index.html
new file mode 100644
index 0000000..6937791
--- /dev/null
+++ b/fixtures/test_site/index.html
@@ -0,0 +1,19 @@
+<!--
+SPDX-FileCopyrightText: None
+SPDX-License-Identifier: Unlicense
+-->
+
+<!DOCTYPE html>
+<head>
+ <meta charset="UTF-8">
+ <title>Hello world</title>
+</head>
+<body>
+ <h1>Hello world</h1>
+ <p>
+ This is a test website.
+ It is used for
+ <a href="https://sr.ht/~gotmax23/sourcehut/">the Sourcehut Python client's</a>
+ integration tests.
+ </p>
+</body>
diff --git a/noxfile.py b/noxfile.py
index 48c0d6a..825400c 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -45,7 +45,14 @@ def git(session: nox.Session, *args, **kwargs):
@nox.session(python=["3.8", "3.9", "3.10", "3.11"], tags=["test"])
def test(session: nox.Session):
- install(session, ".[test]", editable=True)
+ install(
+ session,
+ ".[test]",
+ # httpx: use surrogateescape to allow for binary data
+ # https://github.com/kevin1024/vcrpy/pull/760
+ "git+https://github.com/gotmax23/vcrpy.git@binary_httpx",
+ editable=True,
+ )
args = list(session.posargs)
tmp = Path(session.create_tmp())
diff --git a/src/sourcehut/client.py b/src/sourcehut/client.py
index 2621c07..c86bdb0 100644
--- a/src/sourcehut/client.py
+++ b/src/sourcehut/client.py
@@ -127,7 +127,7 @@ class SrhtClient:
service: SRHT_SERVICE,
query: str,
variables: dict[str, Any],
- filename: StrPath,
+ filename: StrPath | None,
content: str | bytes | IO[bytes],
*,
extra_params: dict[str, Any] | None = None,
@@ -143,7 +143,7 @@ class SrhtClient:
resp = await self.http_client.post(
endpoint,
data=data,
- files={"0": (str(filename), content)},
+ files={"0": (str(filename), content) if filename is not None else content},
headers=headers,
follow_redirects=True,
**extra_params,
diff --git a/src/sourcehut/services/pages.py b/src/sourcehut/services/pages.py
new file mode 100644
index 0000000..0aa06fa
--- /dev/null
+++ b/src/sourcehut/services/pages.py
@@ -0,0 +1,179 @@
+# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
+# SPDX-License-Identifier: MIT
+# ruff: noqa: ARG002
+
+"""
+pages.sr.ht API
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from dataclasses import dataclass
+from datetime import datetime as DT
+from enum import Enum
+from typing import IO, Any, Dict, List, Optional, Union
+
+from pydantic import Field, validator
+
+from .._utils import check_found as _cf
+from .._utils import get_locals
+from ..client import SRHT_SERVICE
+from ._base import _Resource, _ServiceClient
+
+_SITE_REF_MEMBERS = """\
+id
+domain
+protocol
+"""
+
+_SITE_MEMBERS = (
+ _SITE_REF_MEMBERS
+ + """\
+created
+updated
+version
+notFound
+"""
+)
+
+
+class SITE_PROTOCOL(str, Enum):
+ HTTPS = "https"
+ GEMINI = "gemini"
+
+ def __str__(self) -> str:
+ return self.value
+
+
+@dataclass()
+class FileConfig:
+ glob: str
+ cache_control: Optional[str] = None
+
+ def get_dict(self) -> Dict[str, Any]:
+ return {"glob": self.glob, "options": {"cacheControl": self.cache_control}}
+
+
+@dataclass()
+class _SiteConfig:
+ not_found: Optional[str] = None
+ file_configs: Optional[List[FileConfig]] = None
+
+ def get_dict(self) -> Dict[str, Any]:
+ return {
+ "notFound": self.not_found,
+ "fileConfigs": [config.get_dict() for config in self.file_configs]
+ if self.file_configs is not None
+ else None,
+ }
+
+
+class PagesSrhtClient(_ServiceClient):
+ SERVICE = SRHT_SERVICE.PAGES
+
+ async def publish(
+ self,
+ domain: str,
+ content: Union[bytes, IO[bytes]],
+ protocol: SITE_PROTOCOL = SITE_PROTOCOL.HTTPS,
+ subdirectory: Optional[str] = None,
+ not_found: Optional[str] = None,
+ file_configs: Optional[List[FileConfig]] = None,
+ ) -> Site:
+ inp = get_locals(**locals())
+ inp["siteConfig"] = _SiteConfig(
+ inp.pop("not_found"), inp.pop("file_configs")
+ ).get_dict()
+ inp["protocol"] = str(inp["protocol"]).upper()
+ del inp["content"]
+ query = (
+ """
+ mutation publish(
+ $domain: String!
+ $file: Upload!
+ $protocol: Protocol!
+ $subdirectory: String
+ $siteConfig: SiteConfig
+ ) {
+ publish(
+ domain: $domain
+ content: $file
+ protocol: $protocol
+ subdirectory: $subdirectory
+ siteConfig: $siteConfig
+ ) {
+ %s
+ }
+ }
+
+ """
+ % _SITE_MEMBERS
+ )
+ data = await self.client._query_with_upload(
+ self.SERVICE, query, inp, None, content
+ )
+ return Site(**_cf(data["publish"]), client=self)
+
+ async def unpublish(
+ self, domain: str, protocol: SITE_PROTOCOL = SITE_PROTOCOL.HTTPS
+ ) -> Site:
+ inp = get_locals(**locals())
+ inp["protocol"] = str(inp["protocol"]).upper()
+ query = (
+ """
+ mutation unpublish(
+ $domain: String!
+ $protocol: Protocol!
+ ) {
+ unpublish(
+ domain: $domain
+ protocol: $protocol
+ ) {
+ %s
+ }
+ }
+ """
+ % _SITE_MEMBERS
+ )
+ data = await self.query(query, inp)
+ return Site(**_cf(data["unpublish"]), client=self)
+
+ def list_sites(self, *, max_pages: Optional[int] = 1) -> AsyncIterator[Site]:
+ query = (
+ """
+ query listSites(
+ $cursor: Cursor
+ ) {
+ sites(cursor: $cursor) {
+ cursor
+ results {
+ %s
+ }
+ }
+ }
+ """
+ % _SITE_MEMBERS
+ )
+ return self._cursorit("sites", Site, query, max_pages)
+
+
+class SiteRef(_Resource[PagesSrhtClient]):
+ domain: str
+ protocol: SITE_PROTOCOL
+
+ @validator("protocol", pre=True)
+ def _v_site_ref(cls, value: Any) -> Any:
+ if value == "": # noqa: PLC1901 # Other falsy values should cause an error
+ value = SITE_PROTOCOL.HTTPS
+ return value
+
+
+class Site(SiteRef):
+ created: DT
+ updated: DT
+ version: str
+ not_found: Optional[str] = Field(None, alias="notFound")
+
+ async def unpublish(self) -> Site:
+ return await self._client.unpublish(self.domain, self.protocol)
diff --git a/tests/integration/test_pages.py b/tests/integration/test_pages.py
new file mode 100644
index 0000000..adbcc7a
--- /dev/null
+++ b/tests/integration/test_pages.py
@@ -0,0 +1,77 @@
+# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
+# SPDX-License-Identifier: MIT
+
+"""
+pages.sr.ht API
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from sourcehut.client import SrhtClient
+from sourcehut.services import pages
+
+from .. import ROOT, vcr
+
+
+@pytest.mark.asyncio
+@pytest.mark.xfail(
+ reason="The cassettes contain binary data"
+ " and require a patched vcrpy to handle them"
+)
+@vcr.use_cassette
+async def test_pages_publish(authed_client: SrhtClient, tmp_path: Path):
+ async with authed_client:
+ client = pages.PagesSrhtClient(authed_client)
+ content = (ROOT / "fixtures/test_site.tar.gz").read_bytes()
+ whoami = await client.whoami()
+ domain = f"{whoami}.srht.site"
+ site = await client.publish(
+ f"{whoami}.srht.site",
+ content,
+ subdirectory="python-sourcehut-test",
+ )
+ try:
+ expected = pages.Site(
+ id=site.id,
+ client=client,
+ domain=domain,
+ protocol=pages.SITE_PROTOCOL.HTTPS,
+ created=site.created,
+ updated=site.updated,
+ version=site.version,
+ notFound=None,
+ )
+ assert site == expected
+
+ gotten = [s async for s in client.list_sites()]
+ assert site in gotten
+ finally:
+ await site.unpublish()
+
+
+@pytest.mark.parametrize(
+ "config, expected",
+ [
+ pytest.param(
+ pages._SiteConfig(None, None), {"notFound": None, "fileConfigs": None}
+ ),
+ pytest.param(
+ pages._SiteConfig("abc.html", []),
+ {"notFound": "abc.html", "fileConfigs": []},
+ ),
+ pytest.param(
+ pages._SiteConfig("abc.html", [pages.FileConfig("*", "no-cache")]),
+ {
+ "notFound": "abc.html",
+ "fileConfigs": [{"glob": "*", "options": {"cacheControl": "no-cache"}}],
+ },
+ ),
+ ],
+)
+def test_pages_site_config(config: pages._SiteConfig, expected: dict[str, Any]):
+ assert config.get_dict() == expected
--
2.41.0