diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..650fb79 --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,41 @@ +--- + +name: Publish Python distributions to PyPI and TestPyPI +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + validate: + name: Run static analysis on the code + runs-on: ubuntu-22.04 + steps: + - name: Lint with flake8 + run: | + pip install flake8 + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + build-and-publish: + name: Build and publish Python distribution + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@main + - name: Initialize Python 3.11 + uses: actions/setup-python@v1 + with: + python-version: 3.11 + - name: Install dependencies + run: | + python -m pip install --upgrade pip build + - name: Build binary wheel and a source tarball + run: python -m build + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@v1.8.5 + with: + password: ${{ secrets.PIPY_PASSWORD }} + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2ddbe89 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +# pyproject.toml +# https://packaging.python.org/en/latest/specifications/declaring-project-metadata +# https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml + +[build-system] +requires = ["setuptools", "setuptools-scm", "build"] +build-backend = "setuptools.build_meta" + + +[project] +name = "rtex" +description = "*Unofficial* client for the Rtex API server" +authors = [ + {name = "Estelle Poulin", email = "dev@inspiredby.es"}, +] +readme = "README.md" +requires-python = ">=3.11" +keywords = ["rtex", "latex", "math"] +license = {text = "MIT"} +classifiers = [ + "Programming Language :: Python :: 3", +] +dynamic = ["version", "dependencies"] + + +[project.urls] +homepage = "https://github.com/estheruary/python-rtex" +repository = "https://github.com/estheruary/python-rtex" +changelog = "https://github.com/estheruary/python-rtex/-/blob/main/CHANGELOG.md" + + +[tool.setuptools] +packages = ["rtex"] + + +[tool.setuptools.dynamic] +version = {attr = "rtex.__version__"} +dependencies = {file = ["requirements.txt"]} + + +[tool.black] +line-length = 100 + + +[tool.isort] +profile = "black" + + +[tool.vulture] +ignore_names = ["self", "cls"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..673d161 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp==3.8.4 +pydantic==1.10.2 diff --git a/rtex/__init__.py b/rtex/__init__.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/rtex/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/rtex/client.py b/rtex/client.py new file mode 100644 index 0000000..56bfe27 --- /dev/null +++ b/rtex/client.py @@ -0,0 +1,161 @@ +import io +import os +import string +import textwrap +from typing import Optional + +import aiohttp +from pydantic import validate_arguments + +from rtex.constants import DEFAULT_API_HOST, FORMAT_MIME +from rtex.exceptions import YouNeedToUseAContextManager +from rtex.models import ( + CreateLaTeXDocumentRequest, + CreateLaTeXDocumentResponse, + RenderDensity, + RenderFormat, + RenderQuality, +) + + +class AsyncRtexClient: + def __init__( + self, + api_host=os.environ.get("RTEX_API_HOST", DEFAULT_API_HOST), + ): + self.api_host = api_host + self.latex_template = string.Template( + textwrap.dedent( + r""" + \documentclass{$docclass} + \begin{document} + $doc + \end{document} + """ + ) + ) + + async def __aenter__(self): + self.session = await aiohttp.ClientSession( + base_url=self.api_host, + headers={ + "Content-Type": "application/json", + }, + ).__aenter__() + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return await self.session.__aexit__(exc_type, exc_val, exc_tb) + + def _oops_no_session(self): + if not self.session: + raise YouNeedToUseAContextManager( + textwrap.dedent( + f"""\ + {self.__class__.__name__} keeps a aiohttp.ClientSession under + the hood and needs to be closed when you're done with it. But + since there isn't an async version of __del__ we have to use + __aenter__/__aexit__ instead. Apologies. + + Instead of + + myclient = {self.__class__.__name__}() + myclient.text_to_image(...) + + Do this + + async with {self.__class__.__name__} as myclient: + myclient.text_to_image(...) + + Note that it's `async with` and not `with`. + """ + ) + ) + + @validate_arguments + async def create_render( + self, + code: str, + format: RenderFormat = "png", + documentclass: str = "minimal", + quality: Optional[RenderQuality] = None, + density: Optional[RenderDensity] = None, + ): + self._oops_no_session() + + final_doc = self.latex_template.substitute( + docclass=documentclass, + doc=code, + ) + + request_body = CreateLaTeXDocumentRequest( + code=final_doc, + format=format, + quality=quality, + density=density, + ) + + res = await self.session.post( + "/api/v2", + headers={"Accept": "application/json"}, + data=request_body.json( + exclude_defaults=True, + exclude_none=True, + exclude_unset=True, + ), + ) + + return CreateLaTeXDocumentResponse.parse_obj(await res.json()).__root__ + + @validate_arguments(config={"arbitrary_types_allowed": True}) + async def save_render( + self, + filename: str, + output_fd: io.IOBase, + format: RenderFormat = "png", + ): + self._oops_no_session() + + res = await self.session.get( + f"/api/v2/{filename}", + headers={ + "Accept": FORMAT_MIME[format], + }, + ) + + async for chunk, _ in res.content.iter_chunks(): + output_fd.write(chunk) + + @validate_arguments + async def get_render( + self, + filename: str, + format: RenderFormat = "png", + ): + buf = io.BytesIO() + await self.save_render(filename, buf, format) + buf.seek(0) + + return buf + + @validate_arguments + async def render_math( + self, + code: str, + format: RenderFormat = "png", + ): + final_doc = rf"\({code}\)" + + res = await self.create_render( + code=final_doc, + format=format, + ) + + if res.status == "error": + raise RuntimeError("Failed to render code") + + return await self.get_render( + filename=res.filename, + format=format, + ) diff --git a/rtex/constants.py b/rtex/constants.py new file mode 100644 index 0000000..4805d08 --- /dev/null +++ b/rtex/constants.py @@ -0,0 +1,9 @@ +from typing import Dict, Final + +DEFAULT_API_HOST: Final[str] = "https://rtex.probablyaweb.site" + +FORMAT_MIME: Final[Dict[str, str]] = { + "png": "image/png", + "jpg": "image/jpeg", + "pdf": "application/pdf", +} diff --git a/rtex/exceptions.py b/rtex/exceptions.py new file mode 100644 index 0000000..a83ca3e --- /dev/null +++ b/rtex/exceptions.py @@ -0,0 +1,6 @@ +class RtexError(Exception): + pass + + +class YouNeedToUseAContextManager(RtexError): + pass diff --git a/rtex/models.py b/rtex/models.py new file mode 100644 index 0000000..1aaae4d --- /dev/null +++ b/rtex/models.py @@ -0,0 +1,33 @@ +from typing import Annotated, Literal, Optional, Union + +from pydantic import BaseModel, Field, conint + +RenderFormat = Literal["png", "jpg", "pdf"] +RenderQuality = Annotated[int, conint(ge=0, le=100)] +RenderDensity = int + + +class CreateLaTeXDocumentRequest(BaseModel): + code: str + format: RenderFormat + quality: Optional[RenderQuality] + density: Optional[RenderDensity] = None + + +class CreateLaTeXDocumentSuccessResponse(BaseModel): + status: Literal["success"] + log: str + filename: str + + +class CreateLaTeXDocumentErrorResponse(BaseModel): + status: Literal["error"] + log: str + description: str + + +class CreateLaTeXDocumentResponse(BaseModel): + __root__: Union[ + CreateLaTeXDocumentSuccessResponse, + CreateLaTeXDocumentErrorResponse, + ] = Field(discriminator="status")