Skip to content

Commit 3393f80

Browse files
committed
zr
1 parent 5e66406 commit 3393f80

File tree

17 files changed

+332
-340
lines changed

17 files changed

+332
-340
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .render import render, to_pdf, to_image
2+
from .binding import paper_muncher
3+
from .types import Environment

meta/bindings/python/papermuncher/_internal.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
"""Module with internal functions for handling streams and URLs."""
22

3+
import asyncio
34
import logging
45
import os.path
56

67
from io import BytesIO
8+
from functools import wraps
79
from contextlib import asynccontextmanager
810
from urllib.parse import urlparse
911

1012
from typing import Iterable, Optional, overload, Union
11-
from .type import Environment, Streamable
13+
from .types import Environment, Streamable
1214

1315
_logger = logging.getLogger(__name__)
1416

@@ -41,21 +43,21 @@ async def to_stream(
4143
instance (Union[Streamable, str, bytes]): The input to convert to a
4244
stream.
4345
environment (Optional[Environment]): An optional environment object
44-
for handling URLs.
46+
for handling URLs (this arg is also used to know if PM is piped).
4547
Yields:
4648
Iterable[Streamable]: A streamable object.
4749
"""
4850
def has_all_attrs(obj, attrs):
4951
return all(hasattr(obj, attr) for attr in attrs)
5052

5153
try:
52-
if has_all_attrs(instance, ['read', 'readline', 'seek']):
54+
if has_all_attrs(instance, ['read', 'readline']):
5355
yield instance
5456
else:
5557
if isinstance(instance, bytes):
5658
future_stream = instance
5759
elif isinstance(instance, str):
58-
if os.path.isfile(instance):
60+
if environment is not None and os.path.isfile(instance):
5961
with open(instance, 'rb') as file_stream:
6062
yield file_stream
6163
future_stream = instance.encode('utf-8')
@@ -68,7 +70,7 @@ def has_all_attrs(obj, attrs):
6870

6971
url = urlparse(future_stream)
7072
if environment is not None and url.scheme and url.netloc:
71-
yield await environment.get_document(future_stream)
73+
yield await environment.get_asset(future_stream)
7274
else:
7375
stream = BytesIO(future_stream)
7476
stream.seek(0)
@@ -80,3 +82,53 @@ def has_all_attrs(obj, attrs):
8082
else:
8183
if 'stream' in locals():
8284
stream.close()
85+
86+
87+
def with_pmoptions_init(cls):
88+
"""Decorator to override __init__ to support PMOptions passthrough."""
89+
original_init = cls.__init__
90+
91+
@wraps(original_init)
92+
def __init__(self, **kwargs):
93+
# If any value is a PMOptions, use it to populate fields
94+
option_like = next((v for v in kwargs.values() if isinstance(v, cls)), None)
95+
if option_like:
96+
original_init(self, **option_like.__dict__)
97+
else:
98+
original_init(self, **kwargs)
99+
100+
cls.__init__ = __init__
101+
return cls
102+
103+
104+
class SyncStreamWrapper(Streamable):
105+
"""
106+
A synchronous wrapper for an asynchronous stream.
107+
This class allows synchronous code to interact with an
108+
asynchronous stream by providing synchronous methods for reading
109+
and iterating over the stream.
110+
"""
111+
112+
def __init__(self, async_stream: asyncio.StreamReader):
113+
self.async_stream = async_stream
114+
115+
def read(self, size=-1) -> bytes:
116+
return asyncio.run(self._read(size))
117+
118+
async def _read(self, size=-1) -> bytes:
119+
return await self.async_stream.read(size)
120+
121+
def readline(self) -> bytes:
122+
return asyncio.run(self._readline())
123+
124+
async def _readline(self) -> bytes:
125+
return await self.async_stream.readline()
126+
127+
def __iter__(self):
128+
return self
129+
130+
def __next__(self):
131+
chunk = asyncio.run(self.async_stream.read(4096))
132+
if not chunk:
133+
raise StopIteration
134+
return chunk
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import asyncio
2+
import logging
3+
from typing import Any, Union, Tuple, Dict, Optional, overload
4+
from contextlib import asynccontextmanager
5+
from .communication import PaperMuncherRequest
6+
from .default import DefaultEnvironment
7+
from .exceptions import PaperMuncherError
8+
from .utils import locate_executable
9+
from .options import PMOptions
10+
from .handlers import handle_get_request, handle_put_request
11+
from ._internal import SyncStreamWrapper
12+
13+
14+
_logger = logging.getLogger(__name__)
15+
16+
@overload
17+
def paper_muncher(
18+
doc : Optional[Any] = None,
19+
auto : bool = True,
20+
environment : Optional[Any] = None,
21+
as_stream : bool = True,
22+
**rendering_options : Dict[str, str],
23+
) -> SyncStreamWrapper:
24+
...
25+
@overload
26+
def paper_muncher(
27+
doc : Optional[Any] = None,
28+
auto : bool = True,
29+
environment : Optional[Any] = None,
30+
as_stream : bool = False,
31+
**rendering_options : Dict[str, str],
32+
) -> bytes:
33+
...
34+
@overload
35+
def paper_muncher(
36+
doc : Optional[Any] = None,
37+
auto : bool = False,
38+
environment : Optional[Any] = None,
39+
as_stream : bool = True or False,
40+
**rendering_options : Dict[str, str],
41+
) -> Tuple[asyncio.subprocess.PIPE, asyncio.subprocess.PIPE, asyncio.subprocess.PIPE]:
42+
...
43+
44+
45+
@asynccontextmanager
46+
async def paper_muncher(
47+
doc : Optional[Any] = None,
48+
environment : Optional[Any] = None,
49+
auto : bool = True,
50+
as_stream : bool = True,
51+
**rendering_options : Dict[str, str],
52+
) -> Union[
53+
SyncStreamWrapper,
54+
bytes,
55+
Tuple[asyncio.subprocess.PIPE, asyncio.subprocess.PIPE, asyncio.subprocess.PIPE],
56+
]:
57+
"""
58+
Asynchronous context manager for rendering documents using Paper Muncher.
59+
This function handles the rendering process and provides an interface
60+
for interacting with the rendering options.
61+
Args:
62+
doc (Any): The document to be rendered.
63+
auto (bool): Flag to indicate automatic rendering.
64+
environment (Optional[Any]): An optional environment object for
65+
handling URLs and assets.
66+
as_stream (bool): Flag to indicate if the output should be a stream.
67+
**rendering_options (dict[str, str]): Additional rendering options.
68+
Yields:
69+
Union[
70+
SyncStreamWrapper,
71+
bytes,
72+
Tuple[asyncio.subprocess.PIPE, asyncio.subprocess.PIPE, asyncio.subprocess.PIPE]
73+
]: - the set of pipes to interact with the process if auto is False
74+
If the document should be rendered in pipe:
75+
- the rendered document as bytes if auto is True and as_stream is False
76+
- a stream wrapper if auto is True and as_stream is True
77+
If at url or file path:
78+
- The path/url of the file if auto is True and as_stream is False
79+
- a stream wrapper if auto is True and as_stream is True
80+
"""
81+
82+
pm_options = PMOptions(**rendering_options, auto=auto)
83+
pm_process = await asyncio.subprocess.create_subprocess_exec(
84+
locate_executable(),
85+
*pm_options.args,
86+
stdin=asyncio.subprocess.PIPE,
87+
stdout=asyncio.subprocess.PIPE,
88+
stderr=asyncio.subprocess.PIPE,
89+
)
90+
91+
try:
92+
stdin, stdout, stderr = pm_process.stdin, pm_process.stdout, pm_process.stderr
93+
if not auto:
94+
yield stdin, stdout, stderr
95+
else:
96+
if pm_options.is_piped and environment is None:
97+
if doc is None:
98+
raise ValueError("Document cannot be None when auto is True")
99+
if environment is None:
100+
_logger.warning("No environment provided, assets will not be loaded")
101+
environment = DefaultEnvironment(from_doc=doc)
102+
while True:
103+
request = PaperMuncherRequest()
104+
await request.read_header(stdout)
105+
if request is None:
106+
break
107+
if request.method == "GET":
108+
await handle_get_request(request, stdin, environment)
109+
elif request.method == "PUT":
110+
await handle_put_request(stdin)
111+
if as_stream:
112+
yield SyncStreamWrapper(stdout)
113+
else:
114+
yield await stdout.read()
115+
break
116+
else:
117+
_logger.error("Unsupported method: %s", request.method)
118+
break
119+
except asyncio.CancelledError:
120+
_logger.warning("Paper Muncher process cancelled")
121+
raise
122+
except Exception as e:
123+
raise PaperMuncherError(f"Error in Paper Muncher: {e}") from e
124+
finally:
125+
pm_process.terminate()
126+
await pm_process.wait()
127+
if pm_process.returncode:
128+
_logger.error("Paper Muncher exited with code %s", pm_process.returncode)

meta/bindings/python/papermuncher/communication/response.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from http import HTTPStatus
2+
from io import BytesIO
23

3-
from .message import Message
4+
from .message import Message, MAX_BUFFER_SIZE
45

56

67
class BindingResponse(Message):
@@ -47,12 +48,12 @@ def addBody(self, body: bytes) -> None:
4748
Raises:
4849
TypeError: If body is not a bytes object.
4950
"""
50-
if not isinstance(body, bytes):
51+
if not isinstance(body, bytes) and not isinstance(body, BytesIO):
5152
raise TypeError("Body must be in bytes")
5253
self.body = body
5354
self.addHeader("Content-Length", len(body))
5455

55-
def __bytes__(self) -> bytes:
56+
def __iter__(self) -> bytes:
5657
"""
5758
Convert the response into raw bytes suitable for sending over a socket.
5859
@@ -65,6 +66,15 @@ def first_line():
6566
def headers():
6667
return (f"{key}: {value}".encode() for key, value in self.headers.items())
6768

68-
if self.body:
69-
return b"\r\n".join([first_line(), *headers(), b"", self.body])
70-
return b"\r\n".join([first_line(), *headers(), b""])
69+
if self.body and isinstance(self.body, bytes):
70+
response = b"\r\n".join([first_line(), *headers(), b"", self.body])
71+
while response:
72+
yield response[:MAX_BUFFER_SIZE]
73+
response = response[MAX_BUFFER_SIZE:]
74+
elif self.body and isinstance(self.body, BytesIO):
75+
self.body.seek(0)
76+
yield b"\r\n".join([first_line(), *headers(), b"", b""])
77+
while chunk := self.body.read(MAX_BUFFER_SIZE):
78+
yield chunk
79+
else:
80+
yield b"\r\n".join([first_line(), *headers(), b""])
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .environment import DefaultEnvironment
2+
from .path import REPORT_URI, OUTPUT_URI
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from ..type import Environment, Streamable
2+
from .path import REPORT_URI
3+
4+
5+
class DefaultEnvironment(Environment):
6+
7+
def __init__(self, from_doc=None) -> None:
8+
self.doc = from_doc
9+
10+
async def get_asset(self, path: str) -> None:
11+
if path in REPORT_URI:
12+
return self.doc
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
REPORT_URI = "http://127.0.0.1:0000/report.html"
2+
OUTPUT_URI = "http://stdout"

0 commit comments

Comments
 (0)