First version
parent
3db4a35cc5
commit
a4b4a4c72b
@ -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 }}
|
||||||
|
|
@ -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"]
|
@ -0,0 +1,2 @@
|
|||||||
|
aiohttp==3.8.4
|
||||||
|
pydantic==1.10.2
|
@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.0.1"
|
@ -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,
|
||||||
|
)
|
@ -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",
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
class RtexError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class YouNeedToUseAContextManager(RtexError):
|
||||||
|
pass
|
@ -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")
|
Loading…
Reference in New Issue