Skip to content

Commit e292a04

Browse files
committed
add python paper muncher package
1 parent cd0f588 commit e292a04

File tree

18 files changed

+1058
-153
lines changed

18 files changed

+1058
-153
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from paper_muncher.frameworks.flask import register_paper_muncher
2+
from flask import Flask, Response
3+
4+
app = Flask(__name__)
5+
register_paper_muncher(app)
6+
7+
8+
@app.route("/")
9+
def index():
10+
html_content = "<h1>Hello, Paper Muncher with Flask!</h1>"
11+
pdf_bytes = app.run_paper_muncher(html_content, mode="print")
12+
return Response(pdf_bytes, mimetype="application/pdf")
13+
14+
if __name__ == "__main__":
15+
app.run(debug=True)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
2+
3+
import logging
4+
import os
5+
6+
IS_FULLY_SYNC = os.getenv("USE_SYNC", "0") == "1"
7+
8+
if IS_FULLY_SYNC:
9+
...
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from ..runners.wsgi import wsgi_runner_factory
2+
from ..synchronous import render
3+
from flask import request
4+
5+
def register_paper_muncher(flask_application):
6+
def run_paper_muncher(content, mode="print", **options):
7+
runner = wsgi_runner_factory(flask_application, request.environ)
8+
return render(content, mode=mode, runner=runner, **options)
9+
10+
flask_application.run_paper_muncher = run_paper_muncher
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from ..runners.wsgi import wsgi_runner_factory
2+
from ..synchronous import render
3+
4+
5+
def patch(application):
6+
def run_paper_muncher(content, mode="print", **options):
7+
runner = wsgi_runner_factory(application, application.request.environ)
8+
return render(content, mode=mode, runner=runner, **options)
9+
10+
application.run_paper_muncher = run_paper_muncher
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""The :mod:`paper_muncher.runners.wsgi` module
2+
provides utilities to simulate HTTP requests to a WSGI application.
3+
It includes functions to generate WSGI environments and simulate
4+
HTTP responses from a WSGI app.
5+
"""
6+
import logging
7+
import os
8+
from collections.abc import Generator
9+
from datetime import datetime, timezone
10+
from email.utils import format_datetime
11+
from typing import Optional
12+
try:
13+
from wsgiref.types import WSGIEnvironment, WSGIApplication
14+
except ImportError:
15+
from typing import Any, Callable, Iterable, Tuple
16+
WSGIStartResponse = Callable[
17+
[str, list[Tuple[str, str]], Optional[Exception]],
18+
None
19+
]
20+
WSGIEnvironment = dict[str, Any]
21+
WSGIApplication = Callable[
22+
[WSGIEnvironment, WSGIStartResponse],
23+
Iterable[bytes]
24+
]
25+
26+
from werkzeug.test import create_environ, run_wsgi_app
27+
28+
_logger = logging.getLogger(__name__)
29+
SERVER_SOFTWARE = 'Paper Muncher (WSGI Request SIMULATION)'
30+
31+
32+
def generate_environ(
33+
path: str,
34+
current_environ: WSGIEnvironment,
35+
) -> WSGIEnvironment:
36+
"""Generate a WSGI environment for the given path.
37+
This is used to simulate an HTTP request to a WSGI application.
38+
:param str path: The HTTP request path.
39+
:return: The WSGI environment dictionary. (See PEP 3333)
40+
:rtype: WSGIEnvironment
41+
"""
42+
url, _, query_string = path.partition('?')
43+
environ = create_environ(
44+
method='GET',
45+
path=url,
46+
query_string=query_string,
47+
headers={
48+
'Host': current_environ['HTTP_HOST'],
49+
'User-Agent': SERVER_SOFTWARE,
50+
'http_cookie': current_environ['HTTP_COOKIE'],
51+
'remote_addr': current_environ['REMOTE_ADDR'],
52+
}
53+
)
54+
return environ
55+
56+
57+
def generate_http_response(
58+
request_path: str,
59+
application: WSGIApplication,
60+
environ: WSGIEnvironment,
61+
) -> Generator[bytes, None, None]:
62+
"""Simulate an internal HTTP GET request to an WSGI app and yield
63+
the HTTP response headers and body as bytes.
64+
The use of it is mainly permitting to call a wsgi application from an
65+
inline external application, such as a subprocess requesting resources.
66+
67+
Note: This function doesn't preserves the thread-local data.
68+
69+
usage example:
70+
.. code-block:: python
71+
72+
from paper_muncher.runners.wsgi import generate_http_response
73+
74+
for chunk in generate_http_response('/my/request/path'):
75+
print(chunk.decode())
76+
77+
:param str request_path: Path to query within the wsgi app.
78+
:param WSGIApplication application: The WSGI application to query.
79+
:param WSGIEnvironment environ: The current WSGI environment.
80+
:yields: Chunks of the full HTTP response to the simulated request.
81+
:rtype: Generator[bytes, None, None]
82+
"""
83+
84+
response_iterable, http_status, http_response_headers = run_wsgi_app(
85+
application, generate_environ(environ)
86+
)
87+
88+
if "X-Sendfile" in http_response_headers:
89+
with open(http_response_headers["X-Sendfile"], 'rb') as file:
90+
now = datetime.now(timezone.utc)
91+
http_response_status_line_and_headers = (
92+
f"HTTP/1.1 {http_status}\r\n"
93+
f"Date: {format_datetime(now, usegmt=True)}\r\n"
94+
f"Server: {SERVER_SOFTWARE}\r\n"
95+
f"Content-Length: {os.path.getsize(http_response_headers['X-Sendfile'])}\r\n"
96+
f"Content-Type: {http_response_headers['Content-Type']}\r\n"
97+
"\r\n"
98+
).encode()
99+
100+
yield http_response_status_line_and_headers
101+
yield from file
102+
103+
else:
104+
now = datetime.now(timezone.utc)
105+
http_response_status_line_and_headers = (
106+
f"HTTP/1.1 {http_status}\r\n"
107+
f"Date: {format_datetime(now, usegmt=True)}\r\n"
108+
f"Server: {SERVER_SOFTWARE}\r\n"
109+
f"Content-Length: {http_response_headers['Content-Length']}\r\n"
110+
f"Content-Type: {http_response_headers['Content-Type']}\r\n"
111+
"\r\n"
112+
).encode()
113+
114+
yield http_response_status_line_and_headers
115+
yield from response_iterable
116+
117+
118+
def wsgi_runner_factory(
119+
application: WSGIApplication,
120+
environ: WSGIEnvironment,
121+
):
122+
"""Create a runner function that can be used to generate HTTP responses
123+
from a WSGI application.
124+
125+
:param WSGIApplication application: The WSGI application to query.
126+
:param WSGIEnvironment environ: The current WSGI environment.
127+
(See PEP 3333) This environment only needs to provide the
128+
necessary keys to build a new environment for each request.
129+
(Host, http_cookie, remote_addr)
130+
:return: A function that takes a request path and yields the HTTP response.
131+
:rtype: Callable[[str], Generator[bytes, None, None]]
132+
"""
133+
_logger.debug(
134+
"Creating WSGI runner for application %r with environ %r",
135+
application,
136+
{k: environ[k] for k in (
137+
'HTTP_HOST',
138+
'REMOTE_ADDR',
139+
) if k in environ}
140+
)
141+
142+
def runner(request_path: str) -> Generator[bytes, None, None]:
143+
return generate_http_response(
144+
request_path,
145+
application,
146+
environ,
147+
)
148+
return runner
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""The :mod:`paper_muncher.synchronous` module
2+
provides the core functionality for rendering documents
3+
using the Paper Muncher engine.
4+
It includes the main rendering functions and utilities
5+
for managing the rendering process.
6+
"""
7+
8+
from .interface import rendered, render
9+
from .binary import can_use_paper_muncher
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""The :mod:`paper_muncher.utils.binary` module
2+
provides utilities to locate and validate the Paper Muncher binary.
3+
"""
4+
5+
6+
import logging
7+
import os
8+
import subprocess
9+
from shutil import which
10+
11+
from typing import Optional
12+
13+
14+
_logger = logging.getLogger(__name__)
15+
16+
FALLBACK_BINARY = '/opt/paper-muncher/bin/paper-muncher'
17+
18+
19+
def find_in_path(name):
20+
path = os.environ.get('PATH', os.defpath).split(os.pathsep)
21+
return which(name, path=os.pathsep.join(path))
22+
23+
24+
def get_paper_muncher_binary() -> Optional[str]:
25+
"""Find and validate the Paper Muncher binary
26+
27+
:return: Path to the Paper Muncher binary if found and usable,
28+
None otherwise.
29+
:rtype: str or None
30+
"""
31+
try:
32+
binary = find_in_path('paper-muncher')
33+
except OSError:
34+
_logger.debug("Cannot locate in path paper-muncher", exc_info=True)
35+
binary = FALLBACK_BINARY
36+
37+
try:
38+
subprocess.run(
39+
[binary, '--version'],
40+
stdout=subprocess.DEVNULL,
41+
stderr=subprocess.DEVNULL,
42+
check=True,
43+
)
44+
except subprocess.CalledProcessError:
45+
_logger.debug("Cannot use paper-muncher", exc_info=True)
46+
return None
47+
48+
return binary
49+
50+
51+
def can_use_paper_muncher() -> bool:
52+
"""Check if Paper Muncher binary is available and usable.
53+
54+
:return: True if Paper Muncher is in debug session and available,
55+
False otherwise.
56+
:rtype: bool
57+
"""
58+
return bool(get_paper_muncher_binary())

0 commit comments

Comments
 (0)