diff --git a/meta/bindings/python/papermuncher.py b/meta/bindings/python/papermuncher.py deleted file mode 100644 index 07b688f6f..000000000 --- a/meta/bindings/python/papermuncher.py +++ /dev/null @@ -1,141 +0,0 @@ -import dataclasses as dc -from email.message import Message -from pathlib import Path -from email.parser import BytesParser -import subprocess -import tempfile -from typing import IO -import magic - - -class Loader: - def handleRequest( - self, url: str, headers: dict[str, str] - ) -> tuple[int, dict[str, str], bytes]: - return ( - 404, - { - "mime": "text/html", - }, - b"404 Not Found", - ) - - -@dc.dataclass -class StaticDir(Loader): - _path: Path - - def __init__(self, path: Path): - self._path = path - - def handleRequest( - self, url: str, headers: dict[str, str] - ) -> tuple[int, dict[str, str], bytes]: - path = self._path / url - if not path.exists(): - return ( - 404, - { - "mime": "text/html", - }, - b"404 Not Found", - ) - with open(path, "rb") as f: - return ( - 200, - { - "mime": magic.Magic(mime=True).from_file(path), - }, - f.read(), - ) - - -def _run( - args: list[str], - loader=Loader(), -) -> bytes: - def _readRequest(fd: IO) -> Message[str, str] | None: - # Read the request header from the file descriptor - parser = BytesParser() - return parser.parse(fd) - - def _sendResponse(fd: IO, status: int, headers: dict[str, str], body: bytes): - fd.write(f"HTTP/2 {status}\r\n".encode()) - for key, value in headers.items(): - fd.write(f"{key}: {value}\r\n".encode()) - fd.write(b"\r\n") - fd.write(body) - - with subprocess.Popen( - args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) as proc: - stdout = proc.stdout - if stdout is None: - raise ValueError("stdout is None") - - stderr = proc.stderr - if stderr is None: - raise ValueError("stderr is None") - - stdin = proc.stdin - if stdin is None: - raise ValueError("stdin is None") - - while True: - request = _readRequest(stdout) - if request is None: - raise ValueError("request is None") - - if request.preamble is None: - raise ValueError("request.preamble is None") - - preamble = request.preamble.split(" ") - if preamble[0] == b"GET": - _sendResponse(stdin, *loader.handleRequest(preamble[1], dict(request))) - elif preamble[0] == b"POST": - payload = request.get_payload() - if not isinstance(payload, bytes): - raise ValueError("payload is not bytes") - proc.terminate() - return payload - else: - raise ValueError("Invalid request") - - -def find() -> Path: - return Path(__file__).parent / "bin" - - -def print( - document: bytes | str | Path, - mime: str = "text/html", - loader: Loader = StaticDir(Path.cwd()), - bin: Path = find(), - **kwargs: str, -) -> bytes: - extraArgs = [] - for key, value in kwargs.items(): - extraArgs.append(f"--{key}") - extraArgs.append(str(value)) - - if isinstance(document, Path): - return _run( - [str(bin), "print", "-i", str(document), "-o", "out.pdf"] + extraArgs, - loader, - ) - else: - with tempfile.NamedTemporaryFile(delete=False) as f: - if isinstance(document, str): - document = document.encode() - f.write(document) - return _run( - [str(bin), "print", "-i", f.name, "-o", "out.pdf"] + extraArgs, - loader, - ) - return b"" - - -__all__ = ["Loader", "StaticDir", "print"] diff --git a/meta/bindings/python/papermuncher/__init__.py b/meta/bindings/python/papermuncher/__init__.py new file mode 100644 index 000000000..b0aee379b --- /dev/null +++ b/meta/bindings/python/papermuncher/__init__.py @@ -0,0 +1,3 @@ +from .render import render, to_pdf, to_image +from .binding import paper_muncher +from .types import Environment diff --git a/meta/bindings/python/papermuncher/_internal.py b/meta/bindings/python/papermuncher/_internal.py new file mode 100644 index 000000000..3d2329aa1 --- /dev/null +++ b/meta/bindings/python/papermuncher/_internal.py @@ -0,0 +1,134 @@ +"""Module with internal functions for handling streams and URLs.""" + +import asyncio +import logging +import os.path + +from io import BytesIO +from functools import wraps +from contextlib import asynccontextmanager +from urllib.parse import urlparse + +from typing import Iterable, Optional, overload, Union +from .types import Environment, Streamable + +_logger = logging.getLogger(__name__) + + +@overload +def to_stream( + instance : Union[str, bytes, BytesIO], + environment : Optional[Environment] = None, +) -> Iterable[BytesIO]: + ... + +@overload +def to_stream( + instance : Streamable, + environment : Optional[Environment] = None, +) -> Iterable[Streamable]: + ... + +@asynccontextmanager +async def to_stream( + instance : Union[Streamable, str, bytes], + environment : Optional[Environment] = None, +) -> Iterable[Streamable]: + """ + Convert various types of input (str, bytes, Streamable) to a stream. + This function handles different types of input and returns a streamable + object. It can also handle URLs and file paths, converting them to + appropriate streamable objects. + Args: + instance (Union[Streamable, str, bytes]): The input to convert to a + stream. + environment (Optional[Environment]): An optional environment object + for handling URLs (this arg is also used to know if PM is piped). + Yields: + Iterable[Streamable]: A streamable object. + """ + def has_all_attrs(obj, attrs): + return all(hasattr(obj, attr) for attr in attrs) + + try: + if has_all_attrs(instance, ['read', 'readline']): + yield instance + else: + if isinstance(instance, bytes): + future_stream = instance + elif isinstance(instance, str): + if environment is not None and os.path.isfile(instance): + with open(instance, 'rb') as file_stream: + yield file_stream + future_stream = instance.encode('utf-8') + elif hasattr('__bytes__', instance): + future_stream = bytes(instance) + elif hasattr('__str__', instance): + future_stream = str(instance).encode('utf-8') + else: + raise TypeError(f"Unsupported type: {type(instance)}") + + url = urlparse(future_stream) + if environment is not None and url.scheme and url.netloc: + yield await environment.get_asset(future_stream) + else: + stream = BytesIO(future_stream) + stream.seek(0) + yield stream + + except Exception as e: + _logger.error("Error converting to stream: %s", e) + raise + else: + if 'stream' in locals(): + stream.close() + + +def with_pmoptions_init(cls): + """Decorator to override __init__ to support PMOptions passthrough.""" + original_init = cls.__init__ + + @wraps(original_init) + def __init__(self, **kwargs): + # If any value is a PMOptions, use it to populate fields + option_like = next((v for v in kwargs.values() if isinstance(v, cls)), None) + if option_like: + original_init(self, **option_like.__dict__) + else: + original_init(self, **kwargs) + + cls.__init__ = __init__ + return cls + + +class SyncStreamWrapper(Streamable): + """ + A synchronous wrapper for an asynchronous stream. + This class allows synchronous code to interact with an + asynchronous stream by providing synchronous methods for reading + and iterating over the stream. + """ + + def __init__(self, async_stream: asyncio.StreamReader): + self.async_stream = async_stream + + def read(self, size=-1) -> bytes: + return asyncio.run(self._read(size)) + + async def _read(self, size=-1) -> bytes: + return await self.async_stream.read(size) + + def readline(self) -> bytes: + return asyncio.run(self._readline()) + + async def _readline(self) -> bytes: + return await self.async_stream.readline() + + def __iter__(self): + return self + + def __next__(self): + chunk = asyncio.run(self.async_stream.read(4096)) + if not chunk: + raise StopIteration + return chunk diff --git a/meta/bindings/python/papermuncher/binding.py b/meta/bindings/python/papermuncher/binding.py new file mode 100644 index 000000000..5ce0ccd05 --- /dev/null +++ b/meta/bindings/python/papermuncher/binding.py @@ -0,0 +1,128 @@ +import asyncio +import logging +from typing import Any, Union, Tuple, Dict, Optional, overload +from contextlib import asynccontextmanager +from .communication import PaperMuncherRequest +from .default import DefaultEnvironment +from .exceptions import PaperMuncherError, PaperMuncherException +from .utils import locate_executable +from .options import PMOptions +from ._internal import SyncStreamWrapper + + +_logger = logging.getLogger(__name__) + +@overload +def paper_muncher( + doc : Optional[Any] = None, + auto : bool = True, + environment : Optional[Any] = None, + as_stream : bool = True, + **rendering_options : Dict[str, str], +) -> SyncStreamWrapper: + ... +@overload +def paper_muncher( + doc : Optional[Any] = None, + auto : bool = True, + environment : Optional[Any] = None, + as_stream : bool = False, + **rendering_options : Dict[str, str], +) -> bytes: + ... +@overload +def paper_muncher( + doc : Optional[Any] = None, + auto : bool = False, + environment : Optional[Any] = None, + as_stream : bool = True or False, + **rendering_options : Dict[str, str], +) -> Tuple[asyncio.subprocess.PIPE, asyncio.subprocess.PIPE, asyncio.subprocess.PIPE]: + ... + + +@asynccontextmanager +async def paper_muncher( + doc : Optional[Any] = None, + environment : Optional[Any] = None, + auto : bool = True, + as_stream : bool = True, + **rendering_options : Dict[str, str], +) -> Union[ + SyncStreamWrapper, + bytes, + Tuple[asyncio.subprocess.PIPE, asyncio.subprocess.PIPE, asyncio.subprocess.PIPE], +]: + """ + Asynchronous context manager for rendering documents using Paper Muncher. + This function handles the rendering process and provides an interface + for interacting with the rendering options. + Args: + doc (Any): The document to be rendered. + auto (bool): Flag to indicate automatic rendering. + environment (Optional[Any]): An optional environment object for + handling URLs and assets. + as_stream (bool): Flag to indicate if the output should be a stream. + **rendering_options (dict[str, str]): Additional rendering options. + Yields: + Union[ + SyncStreamWrapper, + bytes, + Tuple[asyncio.subprocess.PIPE, asyncio.subprocess.PIPE, asyncio.subprocess.PIPE] + ]: - the set of pipes to interact with the process if auto is False + If the document should be rendered in pipe: + - the rendered document as bytes if auto is True and as_stream is False + - a stream wrapper if auto is True and as_stream is True + If at url or file path: + - The path/url of the file if auto is True and as_stream is False + - a stream wrapper if auto is True and as_stream is True + """ + + pm_options = PMOptions(**rendering_options, auto=auto) + pm_process = await asyncio.subprocess.create_subprocess_exec( + locate_executable(), + *pm_options.args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + stdin, stdout, stderr = pm_process.stdin, pm_process.stdout, pm_process.stderr + if not auto: + yield stdin, stdout, stderr + else: + if pm_options.is_piped and environment is None: + if doc is None: + raise ValueError("Document cannot be None when auto is True") + if environment is None: + _logger.warning("No environment provided, assets will not be loaded") + environment = DefaultEnvironment(from_doc=doc) + while True: + request = PaperMuncherRequest() + await request.read_header(stdout) + if request is None: + break + + response = await environment.handle_request(request) + for chunk in response: + stdin.write(chunk) + await stdin.drain() + + if as_stream: + yield SyncStreamWrapper(stdout) + else: + yield await stdout.read() + + except asyncio.CancelledError: + _logger.warning("Paper Muncher process cancelled") + raise + except PaperMuncherException: + raise + except Exception as e: + raise PaperMuncherError(f"Error in Paper Muncher: {e}") from e + finally: + pm_process.terminate() + await pm_process.wait() + if pm_process.returncode: + _logger.error("Paper Muncher exited with code %s", pm_process.returncode) diff --git a/meta/bindings/python/papermuncher/communication/__init__.py b/meta/bindings/python/papermuncher/communication/__init__.py new file mode 100644 index 000000000..5536693b8 --- /dev/null +++ b/meta/bindings/python/papermuncher/communication/__init__.py @@ -0,0 +1,4 @@ +from .request import PaperMuncherRequest +from .response import BindingResponse + +__all__ = ["PaperMuncherRequest", "BindingResponse"] diff --git a/meta/bindings/python/papermuncher/communication/message.py b/meta/bindings/python/papermuncher/communication/message.py new file mode 100644 index 000000000..0ed6a6f23 --- /dev/null +++ b/meta/bindings/python/papermuncher/communication/message.py @@ -0,0 +1,122 @@ +"""This module defines the base Message class, +which is used to handle Pseudo HTTP messages. +""" + + +import asyncio + +MAX_BUFFER_SIZE = 8192 + + +class Message: + """ + This class is a blueprint to handle Pseudo HTTP messages. + It provides methods to read HTTP-style headers and chunked transfer + encoding from a stream. + """ + __slots__ = ("headers",) + headers: dict[str, str] + + + def __init__(self): + self.headers = {} + + async def _read_header_lines(self, reader: asyncio.StreamReader) -> list[str]: + """ + Asynchronously read HTTP-style header lines from a stream until an + empty line is encountered. + + Args: + reader (asyncio.StreamReader): The stream reader to read from. + + Returns: + list[str]: A list of header lines as decoded UTF-8 strings. + + Raises: + EOFError: If the stream ends unexpectedly. + """ + + lines = [] + while True: + request_line = await reader.readline() + if not request_line: + raise EOFError("Input stream has ended") + request_line = request_line.decode("utf-8") + if request_line == "\r\n": + break + lines.append(request_line) + return lines + + def _add_to_header(self, header_line: str) -> None: + """ + Parse a header line and add the key-value pair to the headers + dictionary. + + Args: + header_line (str): A single HTTP header line, e.g., + "Content-Type: text/plain". + """ + + key, value = header_line.split(":", 1) + self.headers[key.strip()] = value.strip() + + async def _read_single_chunk(self, reader: asyncio.StreamReader) -> bytes: + """ + Asynchronously read a single HTTP chunk from a stream. + + Args: + reader (asyncio.StreamReader): The stream reader to read from. + + Returns: + bytes: The raw bytes of the chunk. + + Raises: + EOFError: If the stream ends unexpectedly while reading the chunk + or CRLF. + ValueError: If the chunk is not properly terminated with CRLF. + """ + + size_line = await reader.readline() + if not size_line: + raise EOFError("Unexpected end of file while reading chunk size.") + size = int(size_line.strip(), 16) + if size == 0: + # consume trailing empty line + await reader.readexactly(2) + return b"" + + rem_size = size + chunk = b"" + while rem_size > 0: + bs = min(MAX_BUFFER_SIZE, rem_size) + data = await reader.read(bs) + if not data: + raise EOFError( + "Unexpected end of stream while reading chunk data.") + chunk += data + rem_size -= len(data) + + crlf = await reader.readexactly(2) + if crlf != b"\r\n": + raise ValueError(f"Expected '\\r\\n' after chunk, got {crlf!r}") + + return chunk + + async def read_chunked_body(self, reader: asyncio.StreamReader) -> bytes: + """ + Asynchronously read an entire HTTP chunked transfer-encoded body. + + Args: + reader (asyncio.StreamReader): The stream reader to read from. + + Returns: + bytes: The complete decoded body. + """ + + encoded_body = b"" + while True: + chunk = await self._read_single_chunk(reader) + if len(chunk) == 0: + break + encoded_body += chunk + return encoded_body diff --git a/meta/bindings/python/papermuncher/communication/request.py b/meta/bindings/python/papermuncher/communication/request.py new file mode 100644 index 000000000..d3cea907e --- /dev/null +++ b/meta/bindings/python/papermuncher/communication/request.py @@ -0,0 +1,46 @@ +import asyncio + +from .message import Message + + +class PaperMuncherRequest(Message): + __slots__ = super().__slots__ + ("method", "path", "version") + + def __init__(self, method=None, path=None, version=None): + """ + Initialize a PaperMuncherRequest object. + + Args: + method (str, optional): HTTP method (e.g., "GET"). + path (str, optional): Request path (e.g., "/index.html"). + version (str, optional): HTTP version (e.g., "1.1"). + """ + super().__init__() + self.method = method + self.path = path + self.version = version + + async def read_header(self, reader: asyncio.StreamReader) -> None: + """ + Asynchronously read the HTTP request header from a stream. + + Parses the request line and headers, storing them in the object's attributes. + + Args: + reader (asyncio.StreamReader): The stream to read the header from. + + Raises: + ValueError: If the request line is malformed. + EOFError: If the stream ends unexpectedly. + """ + header_lines = await self._read_header_lines(reader) + + if not header_lines or len( + splitted := header_lines[0].strip().split(" ") + ) != 3: + raise ValueError("Malformed HTTP request line.") + + self.method, self.path, self.version = splitted + + for line in header_lines[1:]: + self._add_to_header(line) diff --git a/meta/bindings/python/papermuncher/communication/response.py b/meta/bindings/python/papermuncher/communication/response.py new file mode 100644 index 000000000..525e91bca --- /dev/null +++ b/meta/bindings/python/papermuncher/communication/response.py @@ -0,0 +1,80 @@ +from http import HTTPStatus +from io import BytesIO + +from .message import Message, MAX_BUFFER_SIZE + + +class BindingResponse(Message): + __slots__ = super().__slots__ + ("version", "code", "body") + + + def __init__(self, code: int, version="1.1", body=None): + """ + Initialize a BindingResponse object representing an HTTP response. + + Args: + code (int): HTTP status code (e.g., 200, 404). + version (str): HTTP version, default is "1.1". + body (bytes, optional): Optional response body. + + Raises: + TypeError: If body is provided but is not in bytes. + """ + super().__init__() + self.version = version + self.code = code + if body: + self.addBody(body) + else: + self.body = None + + def addHeader(self, key: str, value: str) -> None: + """ + Add a header key-value pair to the response. + + Args: + key (str): The header name. + value (str): The header value. + """ + self.headers[key] = value + + def addBody(self, body: bytes) -> None: + """ + Set the body of the response and automatically add the Content-Length header. + + Args: + body (bytes): The response body. + + Raises: + TypeError: If body is not a bytes object. + """ + if not isinstance(body, bytes) and not isinstance(body, BytesIO): + raise TypeError("Body must be in bytes") + self.body = body + self.addHeader("Content-Length", len(body)) + + def __iter__(self) -> bytes: + """ + Convert the response into raw bytes suitable for sending over a socket. + + Returns: + bytes: The full HTTP response including status line, headers, and body. + """ + def first_line(): + return f"HTTP/{self.version} {self.code} {HTTPStatus(self.code).phrase}".encode() + + def headers(): + return (f"{key}: {value}".encode() for key, value in self.headers.items()) + + if self.body and isinstance(self.body, bytes): + response = b"\r\n".join([first_line(), *headers(), b"", self.body]) + while response: + yield response[:MAX_BUFFER_SIZE] + response = response[MAX_BUFFER_SIZE:] + elif self.body and isinstance(self.body, BytesIO): + self.body.seek(0) + yield b"\r\n".join([first_line(), *headers(), b"", b""]) + while chunk := self.body.read(MAX_BUFFER_SIZE): + yield chunk + else: + yield b"\r\n".join([first_line(), *headers(), b""]) diff --git a/meta/bindings/python/papermuncher/default/__init__.py b/meta/bindings/python/papermuncher/default/__init__.py new file mode 100644 index 000000000..7fdcc310f --- /dev/null +++ b/meta/bindings/python/papermuncher/default/__init__.py @@ -0,0 +1,2 @@ +from .environment import DefaultEnvironment +from .path import REPORT_URI, OUTPUT_URI diff --git a/meta/bindings/python/papermuncher/default/environment.py b/meta/bindings/python/papermuncher/default/environment.py new file mode 100644 index 000000000..59275a08f --- /dev/null +++ b/meta/bindings/python/papermuncher/default/environment.py @@ -0,0 +1,33 @@ +from .path import REPORT_URI +from ..types import Environment +from ..exceptions import PaperMuncherException +from ..communication import BindingResponse +from .._internal import to_stream + + +class DefaultEnvironment(Environment): + + def __init__(self, from_doc=None) -> None: + self.doc = from_doc + + async def get_asset(self, path: str) -> None: + if path in REPORT_URI: + return self.doc + + async def handle_get(self, stdin, path): + asset = to_stream(await self.get_asset(path)) + response = BindingResponse(code=200 if asset else 404, body=asset) + return response + + async def handle_put(self, stdin) -> None: + return BindingResponse(code=200) + + async def handle_request(self, stdin, request) -> BindingResponse: + if request.method == 'GET': + await self.handle_get(stdin, request.path) + elif request.method == 'PUT': + await self.handle_put(stdin) + else: + raise PaperMuncherException( + f"Unsupported method: {request.method}" + ) diff --git a/meta/bindings/python/papermuncher/default/path.py b/meta/bindings/python/papermuncher/default/path.py new file mode 100644 index 000000000..eeba56e70 --- /dev/null +++ b/meta/bindings/python/papermuncher/default/path.py @@ -0,0 +1,2 @@ +REPORT_URI = "http://127.0.0.1:0000/report.html" +OUTPUT_URI = "http://stdout" diff --git a/meta/bindings/python/papermuncher/exceptions.py b/meta/bindings/python/papermuncher/exceptions.py new file mode 100644 index 000000000..2d0ced33f --- /dev/null +++ b/meta/bindings/python/papermuncher/exceptions.py @@ -0,0 +1,8 @@ +class PaperMuncherException(Exception): + """Base class for all exceptions raised by the binding.""" + pass + + +class PaperMuncherError(RuntimeError): + """Exception raised for errors in Paper Muncher.""" + pass diff --git a/meta/bindings/python/papermuncher/options.py b/meta/bindings/python/papermuncher/options.py new file mode 100644 index 000000000..68e708c3d --- /dev/null +++ b/meta/bindings/python/papermuncher/options.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass + +from ._internal import with_pmoptions_init +from .default import REPORT_URI, OUTPUT_URI + +@with_pmoptions_init +@dataclass +class PMOptions: + """ + Class to handle Paper Muncher options and arguments. + This class is used to manage the options passed to Paper Muncher + and provides a way to convert them into command line arguments. + """ + + auto: bool = True + unsecure: bool = False + sandbox: bool = False + pipe: bool = False + + @property + def is_piped: + """Check if the output is piped.""" + return self.auto or self.sandbox + + @property + def args(self): + self_dict = self.__dict__ + args = [] + for key, value in self_dict.items(): + if key in ["auto", "mode", "out"]: + continue + if isinstance(value, bool): + if value: + args.append(f"--{key}") + elif isinstance(value, str): + args.append(f"--{key}={value}") + elif isinstance(value, Iterable): + for item in value: + args.append(f"--{key}={item}") diff --git a/meta/bindings/python/papermuncher/renderer.py b/meta/bindings/python/papermuncher/renderer.py new file mode 100644 index 000000000..63019e778 --- /dev/null +++ b/meta/bindings/python/papermuncher/renderer.py @@ -0,0 +1,55 @@ +import asyncio +import logging +from contextlib import contextmanager, asynccontextmanager +from typing import Any, Optional + +from .binding import paper_muncher # your asynccontextmanager + +_logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def render_async( + xhtml: Any, + environment: Optional[Any] = None, + **rendering_options: dict[str, str], +): + async with paper_muncher(doc=xhtml, environment=environment, **rendering_options) as result: + yield result + +@contextmanager +def render_sync( + xhtml: Any, + environment: Optional[Any] = None, + **rendering_options: dict[str, str], +): + async def runner(): + async with paper_muncher(doc=xhtml, environment=environment, **rendering_options) as result: + return result + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + result = loop.run_until_complete(runner()) + yield result + finally: + loop.close() + + +def render(xhtml, environment=None, **rendering_options): + try: + asyncio.get_running_loop() + except RuntimeError: + _logger.info("No running loop, using sync render") + return render_sync(xhtml, environment, **rendering_options) + else: + _logger.info("Running loop detected, using async render") + return render_async(xhtml, environment, **rendering_options) + + +def to_pdf(xhtml, environment=None, **rendering_options): + return render(xhtml, environment, mode="print", **rendering_options) + + +def to_image(xhtml, environment=None, **rendering_options): + return render(xhtml, environment, mode="render", **rendering_options) diff --git a/meta/bindings/python/papermuncher/testing/__init__.py b/meta/bindings/python/papermuncher/testing/__init__.py new file mode 100644 index 000000000..b35d07c1d --- /dev/null +++ b/meta/bindings/python/papermuncher/testing/__init__.py @@ -0,0 +1 @@ +from .environ import TestEnvironMocked diff --git a/meta/bindings/python/papermuncher/testing/environ.py b/meta/bindings/python/papermuncher/testing/environ.py new file mode 100644 index 000000000..14562f232 --- /dev/null +++ b/meta/bindings/python/papermuncher/testing/environ.py @@ -0,0 +1,47 @@ +import logging +import urllib + +from papermuncher import Environ +from papermuncher.bindings import FAKE_REPORT_URI + +logger = logging.getLogger(__name__) + +class BaseTestEnviron(Environ): + def get_asset(self, path: str) -> bytes: + """Fetches a file from local filesystem if path starts with file:///, + otherwise returns the HTML content. + + Args: + path (str): the URI to get the file + + Returns: + bytes: the file content, or fallback from parent class + """ + if path in FAKE_REPORT_URI: + return self.html.encode() + + +class TestEnvironMocked(BaseTestEnviron): + __slots__ = ('html', 'data_dir') + + html: str + data_dir: dict[str, str] + + def __init__(self, html: str, data_dir: dict[str, str]): + super().__init__(html) + self.data_dir = data_dir + + def get_asset(self, path: str) -> bytes: + """Fetches mocked data from the data_dir if path is in it, + otherwise returns the HTML content. + + Args: + path (str): the URI to get the file + + Returns: + bytes: the file content, or fallback from parent class + """ + + if data := self.data_dir.get(path): + return data.encode() + return super().get_asset(path) diff --git a/meta/bindings/python/papermuncher/tests/__init__.py b/meta/bindings/python/papermuncher/tests/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/meta/bindings/python/papermuncher/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/meta/bindings/python/papermuncher/types.py b/meta/bindings/python/papermuncher/types.py new file mode 100644 index 000000000..1b49e1266 --- /dev/null +++ b/meta/bindings/python/papermuncher/types.py @@ -0,0 +1,10 @@ +from typing import Protocol, Union +from io import BytesIO +from .communication import BindingResponse + +class Streamable(Protocol): + def read(self, size: int = -1) -> bytes: ... + def readline(self) -> bytes: ... + +class Environment(Protocol): + async def handle_request(self, stdin, request) -> BindingResponse: ... diff --git a/meta/bindings/python/papermuncher/utils.py b/meta/bindings/python/papermuncher/utils.py new file mode 100644 index 000000000..97ca5cdc6 --- /dev/null +++ b/meta/bindings/python/papermuncher/utils.py @@ -0,0 +1,49 @@ +import logging +import os +import shutil +import subprocess +import re + + +_logger = logging.getLogger(__name__) + +MIN_PM_VERSION: tuple = (0, 1, 2) +EXECUTABLE_NAME: str = 'paper-muncher' + + +def locate_executable(): + executable_name = EXECUTABLE_NAME + if os.name == 'nt': # Windows + executable_name += ".exe" + path = shutil.which(executable_name) + if path: + return path + else: + raise FileNotFoundError(f"Executable '{executable_name}' not found in PATH.") + +def get_executable_status(self): + try: + path = locate_executable() + except FileNotFoundError: + return 'Not Installed' + + try: + process = subprocess.Popen( + [path, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + except OSError: + return 'Dead' + + out, _ = process.communicate() + version_bare = out.decode('utf-8').strip() + version_match = re.search(r'(\d+\.\d+\.\d+)', version_bare) + + if not version_match: + return 'Unknown Version' + + version = tuple(map(int, version_match.group(1).split('.'))) + + if version < MIN_PM_VERSION: + return 'Depreciated' + + return 'Ready' diff --git a/meta/bindings/python/sample.py b/meta/bindings/python/sample.py deleted file mode 100644 index e1a59c492..000000000 --- a/meta/bindings/python/sample.py +++ /dev/null @@ -1,12 +0,0 @@ -import papermuncher - -with open("out.pdf", "wb") as f: - document = """ -

Hello, world!

- """ - f.write( - papermuncher.print( - document, - paper="a4", - ) - ) diff --git a/meta/site/usage.md b/meta/site/usage.md index 87bc7188b..cd477b70f 100644 --- a/meta/site/usage.md +++ b/meta/site/usage.md @@ -60,4 +60,3 @@ paper-munch render page.html -o out.bmp paper-munch render page.html -o out.png --width 1024px --height 768px --density 192dpi paper-munch render page.html -o out.png --wireframe ``` -