~gotmax23/sourcehutx

sourcehut: add pages service v1 APPLIED

: 1
 add pages service

 8 files changed, 443 insertions(+), 4 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~gotmax23/sourcehutx/patches/44010/mbox | git am -3
Learn more about email & git

[PATCH sourcehut] add pages service Export this patch

---
 .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
sourcehut/patches: FAILED in 12m19s

[add pages service][0] from [Maxwell G][1]

[0]: https://lists.sr.ht/~gotmax23/sourcehut/patches/43547
[1]: mailto:maxwell@gtmx.me

✗ #1040546 FAILED  sourcehut/patches/mockbuild-epel9.yml.disabled https://builds.sr.ht/~gotmax23/job/1040546
✓ #1040545 SUCCESS sourcehut/patches/main.yml                     https://builds.sr.ht/~gotmax23/job/1040545
✓ #1040547 SUCCESS sourcehut/patches/mockbuild.yml                https://builds.sr.ht/~gotmax23/job/1040547
Applied.