From 6e30b4f294f6006a8f11f2a6763ec92a1aeeecc5 Mon Sep 17 00:00:00 2001 From: Jakob Pinterits Date: Thu, 7 Nov 2024 22:39:16 +0100 Subject: [PATCH] remove httpx dependency --- changelog.md | 2 + pyproject.toml | 1 - rio/arequests.py | 171 ++++++++++++++++++++++++++++++ rio/assets.py | 20 ++-- rio/cli/rio_api.py | 11 +- rio/cli/run_project/arbiter.py | 20 ++-- rio/components/devel_component.py | 11 +- rio/utils.py | 23 ++++ 8 files changed, 228 insertions(+), 31 deletions(-) create mode 100644 rio/arequests.py diff --git a/changelog.md b/changelog.md index 436a8829..07154f6c 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,8 @@ - New styles for input boxes: "rounded" and "pill" - Improved mobile support: Dragging is now much smoother +- Improved tables +- `rio run` now also works when using `as_fastapi` ## 0.10 diff --git a/pyproject.toml b/pyproject.toml index d315c508..8deb766d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ dependencies = [ "fastapi~=0.110", "fuzzywuzzy~=0.18", "gitignore-parser==0.1.11", - "httpx~=0.27.2", "introspection~=1.9.2", "isort~=5.13", "keyring~=24.3", diff --git a/rio/arequests.py b/rio/arequests.py new file mode 100644 index 00000000..c8e3b18b --- /dev/null +++ b/rio/arequests.py @@ -0,0 +1,171 @@ +import asyncio +import json as json_module +import typing as t +import urllib.error +import urllib.parse +import urllib.request +import urllib.response + + +class HttpError(Exception): + """ + Raised when an HTTP request fails. + + The status code is `None` if the error wasn't caused by HTTP, but some other + reason like a network error. + """ + + def __init__( + self, + message: str, + status_code: int | None, + ) -> None: + super().__init__(message, status_code) + + @property + def message(self) -> str: + return self.args[0] + + @property + def status_code(self) -> int | None: + return self.args[1] + + +class HttpResponse: + """ + Represents an HTTP response. + """ + + def __init__( + self, + *, + status_code: int, + headers: dict[str, str], + content: bytes, + ) -> None: + self.status_code = status_code + self.headers = headers + self._content = content + + def read(self) -> bytes: + """ + Returns the response body as bytes. + """ + return self._content + + def json(self) -> t.Any: + """ + Returns the response body as a JSON object. Raises a + `json.JSONDecodeError` if the response body is not valid JSON. + """ + + try: + return json_module.loads(self._content) + except UnicodeDecodeError: + raise json_module.JSONDecodeError( + "The response body is not valid UTF-8", + "", + 0, + ) + + +def _request_sync( + method: t.Literal["GET", "POST"], + url: str, + *, + content: str | bytes | None = None, + json: dict[str, t.Any] | None = None, + headers: dict[str, str] | None = None, +) -> HttpResponse: + """ + Makes an HTTP request with the specified parameters. Returns the response + headers and body. + """ + + # Prepare the request + req = urllib.request.Request(url, method=method) + + if headers: + for key, value in headers.items(): + req.add_header(key, value) + + if json: + if content is not None: + raise ValueError("Cannot specify both `content` and `json`") + + content = json_module.dumps(json) + + if content: + if isinstance(content, str): + content = content.encode("utf-8") + + req.data = content + + # Make the request + try: + with urllib.request.urlopen(req) as response: + # Check the status code + if response.status >= 300: + raise HttpError( + response.reason, + response.status, + ) + + # Epic success! + return HttpResponse( + status_code=response.status, + headers={ + key.lower(): value for key, value in response.getheaders() + }, + content=response.read(), + ) + + except urllib.error.HTTPError as e: + raise HttpError( + e.reason, + e.code, + ) from None + + except urllib.error.URLError as e: + raise HttpError( + str(e.reason), + None, + ) from None + + +async def request( + method: t.Literal["GET", "POST"], + url: str, + *, + content: bytes | None = None, + json: dict[str, t.Any] | None = None, + headers: dict[str, str] | None = None, +) -> HttpResponse: + """ + Makes an HTTP request with the specified parameters. Returns the response + headers and body. + """ + + return await asyncio.to_thread( + _request_sync, + method, + url, + content=content, + json=json, + headers=headers, + ) + + +async def main() -> None: + response = await request( + "GET", + "https://postman-echo.com/get?foo=bar", + ) + + print(response.status_code) + print(response.headers) + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/rio/assets.py b/rio/assets.py index b3641f80..7be375da 100644 --- a/rio/assets.py +++ b/rio/assets.py @@ -7,12 +7,12 @@ import os import typing as t from pathlib import Path -import httpx import typing_extensions as te from PIL.Image import Image from yarl import URL import rio +import rio.arequests as arequests from .self_serializing import SelfSerializing from .utils import ImageLike @@ -257,17 +257,17 @@ class UrlAsset(Asset): async def try_fetch_as_blob(self) -> tuple[bytes, str | None]: try: - async with httpx.AsyncClient() as client: - response = await client.get(str(self._url)) + response = await arequests.request("GET", str(self._url)) - content_type = response.headers.get("Content-Type") - if content_type is None: - content_type = "application/octet-stream" - else: - content_type, _, _ = content_type.partition(";") + content_type = response.headers.get("content-type") - return response.read(), content_type - except httpx.HTTPError: + if isinstance(content_type, str): + content_type, _, _ = content_type.partition(";") + else: + content_type = "application/octet-stream" + + return response.read(), content_type + except arequests.HttpError: raise ValueError(f"Could not fetch asset from {self._url}") @property diff --git a/rio/cli/rio_api.py b/rio/cli/rio_api.py index fbd25eb0..703b8a23 100644 --- a/rio/cli/rio_api.py +++ b/rio/cli/rio_api.py @@ -1,7 +1,7 @@ import typing as t from datetime import timedelta -import httpx +import rio.arequests as arequests BASE_URL = "https://rio.dev/api" @@ -35,9 +35,8 @@ class RioApi: def __init__( self, access_token: str | None = None, - ): + ) -> None: self._access_token = access_token - self._http_client = httpx.AsyncClient() @property def is_logged_in(self) -> bool: @@ -51,7 +50,7 @@ class RioApi: async def close(self) -> None: """ - Log out, if logged in and close the HTTP client. + Log out, if currently logged in. """ if self.is_logged_in: try: @@ -59,8 +58,6 @@ class RioApi: except ApiException: pass - await self._http_client.aclose() - async def request( self, endpoint: str, @@ -85,7 +82,7 @@ class RioApi: # Make the request # # TODO: Which exceptions can this throw? - response = await self._http_client.request( + response = await arequests.request( method, f"{BASE_URL}/{endpoint}", headers=headers, diff --git a/rio/cli/run_project/arbiter.py b/rio/cli/run_project/arbiter.py index 6cee68b1..f06affba 100644 --- a/rio/cli/run_project/arbiter.py +++ b/rio/cli/run_project/arbiter.py @@ -10,11 +10,11 @@ import typing as t from datetime import datetime, timedelta, timezone from pathlib import Path -import httpx import revel from revel import print import rio.app_server.fastapi_server +import rio.arequests as arequests import rio.cli import rio.snippets @@ -187,21 +187,17 @@ class Arbiter: """ try: - async with httpx.AsyncClient() as client: - response = await client.get( - "https://pypi.org/pypi/rio-ui/json", - timeout=10, - ) - response.raise_for_status() + response = await arequests.request( + "GET", + "https://pypi.org/pypi/rio-ui/json", + ) - data = response.json() - return data["info"]["version"] + data = response.json() + return data["info"]["version"] # Oh muh gawd soo many errors except ( - httpx.HTTPError, - httpx.StreamError, - httpx.ProtocolError, + arequests.HttpError, json.JSONDecodeError, ) as exc: raise ValueError(f"Failed to fetch the latest Rio version: {exc}") diff --git a/rio/components/devel_component.py b/rio/components/devel_component.py index 1641e5b2..a071abb1 100644 --- a/rio/components/devel_component.py +++ b/rio/components/devel_component.py @@ -121,7 +121,16 @@ class DevelComponent(FundamentalComponent): tasks = [ ("sass", str(scss_path), str(css_path)), - ("tsc", str(ts_path), "--outFile", str(js_path)), + ( + "tsc", + "--lib", + "dom,es6", + str(ts_path), + "--target", + "ES6", + "--outFile", + str(js_path), + ), ] with concurrent.futures.ThreadPoolExecutor() as executor: diff --git a/rio/utils.py b/rio/utils.py index 401e3fbd..4f792631 100644 --- a/rio/utils.py +++ b/rio/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import hashlib import mimetypes import os @@ -8,6 +9,7 @@ import secrets import socket import typing as t from dataclasses import dataclass +from http.client import HTTPSConnection from io import BytesIO, StringIO from pathlib import Path @@ -565,3 +567,24 @@ def soft_sort( for element, _, _ in keyed_elements: elements.append(element) + + +async def async_http_request(url: str) -> Tuple[Dict[str, str], bytes]: + """Performs an async HTTP GET request to the given URL and returns headers and content blob.""" + loop = asyncio.get_running_loop() + host, path = url.split("/", 1) + + def fetch() -> Tuple[Dict[str, str], bytes]: + conn = HTTPSConnection(host) + conn.request("GET", f"/{path}") + response = conn.getresponse() + headers = dict(response.getheaders()) + blob = response.read() + conn.close() + return headers, blob + + return await loop.run_in_executor(None, fetch) + + +# Example usage: +# headers, blob = asyncio.run(async_http_request("example.com/some-path"))