diff --git a/base_report_paper_muncher/README.rst b/base_report_paper_muncher/README.rst new file mode 100644 index 00000000000..02532148f95 --- /dev/null +++ b/base_report_paper_muncher/README.rst @@ -0,0 +1,199 @@ +============================ +Report Engine: Paper Muncher +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b1d3faf687c4a3a6d43429969fc2e2e38e46f4fc707ef22f9d88f4cd9b94cf4a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/17.0/base_report_paper_muncher + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-17-0/server-tools-17-0-base_report_paper_muncher + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates `Paper +Muncher `__ as the PDF rendering +engine for QWeb reports. When the ``paper-muncher`` binary is available +on the system, it replaces wkhtmltopdf as the default engine for PDF +generation. + +Main features: + +- Automatic detection of the ``paper-muncher`` binary in ``PATH`` or in + ``/opt/paper-muncher/bin/paper-muncher`` +- Globally configurable PDF engine via the ``report.pdf_engine`` system + parameter +- Multi-page header and footer support and ``report.paperformat`` + integration +- Transparent fallback to wkhtmltopdf when Paper Muncher is not + installed +- HTTP-over-pipe communication between Odoo and the Paper Muncher + subprocess, so report assets are served with Odoo permissions + +This module depends on: + +- Odoo modules: ``base``, ``web`` +- Python package: ``h11`` +- System binary: ``paper-muncher`` from `GitHub + releases `__ + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +Install the ``paper-muncher`` system binary before enabling this module. +Example for Ubuntu 22.04 (Jammy): + +.. code:: bash + + curl -fsSL -o paper-muncher.deb \ + "https://github.com/odoo/paper-muncher/releases/download/v0.3.1-1/paper-muncher_v0.3.1-1_jammy_amd64.deb" + sudo apt install ./paper-muncher.deb + paper-muncher --help + +If the binary is not installed via the ``.deb`` package, add +``/opt/paper-muncher/bin`` to the ``PATH`` of the Odoo process. + +The Python dependency ``h11`` is declared in the module manifest and +listed in ``requirements.txt``. Install it in the Odoo Python +environment if needed: + +.. code:: bash + + pip install h11 + +Then install the module as any other Odoo addon: + +.. code:: bash + + odoo -d -i base_report_paper_muncher --stop-after-init + +Paper Muncher is only supported on Linux and macOS. + +Configuration +============= + +The system parameter ``report.pdf_engine`` controls which PDF engine is +used: + ++--------------------+-------------------------------------------------+ +| Value | Behavior | ++====================+=================================================+ +| ``auto`` (default) | Use Paper Muncher when the binary is available, | +| | otherwise wkhtmltopdf | ++--------------------+-------------------------------------------------+ +| ``paper-muncher`` | Force Paper Muncher; raise an error if the | +| | binary is missing | ++--------------------+-------------------------------------------------+ +| ``wkhtmltopdf`` | Always use wkhtmltopdf | ++--------------------+-------------------------------------------------+ + +Change it from code: + +.. code:: python + + env["ir.config_parameter"].sudo().set_param("report.pdf_engine", "auto") + +Or from **Settings > Technical > Parameters > System Parameters**, key +``report.pdf_engine``. + +Optional environment variables for the Odoo process: + +- ``ODOO_PAPER_MUNCHER_FEATURE=1``: pass ``--feature *=on`` to the + binary +- ``ODOO_PAPER_MUNCHER_DEBUG=1``: pass ``--debug http-client`` to the + binary. This flag is not sent by default in production. It is also + enabled automatically when the module logger is set to ``DEBUG``. + +Usage +===== + +Once installed and configured, QWeb PDF reports use Paper Muncher +automatically when the binary is available and ``report.pdf_engine`` is +set to ``auto`` or ``paper-muncher``. + +Check the engine status: + +.. code:: python + + env["ir.actions.report"].get_wkhtmltopdf_state() + +Returns ``ok`` when Paper Muncher is available with ``auto`` or +``paper-muncher``. Returns ``install`` when ``paper-muncher`` is forced +but the binary is missing. + +When generating PDFs, look for log lines prefixed with ``PDF engine:``: + +- ``PDF engine: Paper-Muncher (...)``: rendering started with Paper + Muncher +- ``PDF engine: Paper-Muncher completed (...)``: PDF generated + successfully +- ``PDF engine: wkhtmltopdf (...)``: fallback to wkhtmltopdf + +Low-level HTTP-over-pipe details are logged at ``DEBUG`` level only. + +Known issues / Roadmap +====================== + +- Paper Muncher is only supported on Linux and macOS (not Windows). +- WebSocket requests are rejected by the HTTP-over-pipe server. +- Rendering timeout: 15 minutes (``SERVE_TIMEOUT``). +- Pipe write timeout: 15 seconds (``WRITE_TIMEOUT``). +- The binary must be installed on the system; this module only + integrates the communication layer. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Contributors +------------ + +- Felix Coca + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_report_paper_muncher/__init__.py b/base_report_paper_muncher/__init__.py new file mode 100644 index 00000000000..d6210b1285d --- /dev/null +++ b/base_report_paper_muncher/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/base_report_paper_muncher/__manifest__.py b/base_report_paper_muncher/__manifest__.py new file mode 100644 index 00000000000..6b046b8c635 --- /dev/null +++ b/base_report_paper_muncher/__manifest__.py @@ -0,0 +1,27 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + "name": "Report Engine: Paper Muncher", + "summary": ( + "Paper Muncher PDF rendering engine for QWeb reports, " + "replacing wkhtmltopdf when the binary is available" + ), + "version": "17.0.1.0.0", + "development_status": "Beta", + "category": "Hidden/Tools", + "website": "https://github.com/OCA/server-tools", + "author": "Odoo Community Association (OCA)", + "depends": [ + "base", + "web", + ], + "data": [ + "data/ir_config_parameter_data.xml", + ], + "external_dependencies": { + "python": ["h11"], + }, + "license": "LGPL-3", + "installable": True, + "application": False, +} diff --git a/base_report_paper_muncher/data/ir_config_parameter_data.xml b/base_report_paper_muncher/data/ir_config_parameter_data.xml new file mode 100644 index 00000000000..a0bd62e7774 --- /dev/null +++ b/base_report_paper_muncher/data/ir_config_parameter_data.xml @@ -0,0 +1,7 @@ + + + + report.pdf_engine + auto + + diff --git a/base_report_paper_muncher/models/__init__.py b/base_report_paper_muncher/models/__init__.py new file mode 100644 index 00000000000..7fc40541f9a --- /dev/null +++ b/base_report_paper_muncher/models/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import ir_actions_report diff --git a/base_report_paper_muncher/models/ir_actions_report.py b/base_report_paper_muncher/models/ir_actions_report.py new file mode 100644 index 00000000000..1bd3dfc5b3b --- /dev/null +++ b/base_report_paper_muncher/models/ir_actions_report.py @@ -0,0 +1,363 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import os +import re +import subprocess +from collections.abc import Sequence +from contextlib import ExitStack +from typing import NamedTuple +from urllib.parse import urlsplit + +import lxml.html + +from odoo import _, api, models +from odoo.exceptions import UserError +from odoo.http import request, root +from odoo.service import security + +from ..paper_muncher import PaperMuncherInfo, PaperMuncherServer, paper_muncher + +_logger = logging.getLogger(__name__) + +_BODY_TAG_RE = re.compile(r"]*>", re.IGNORECASE) + + +class PdfEngineResolution(NamedTuple): + engine: str + use_paper_muncher: bool + pm_info: PaperMuncherInfo + + +def _extract_div_fragments(body_content: str) -> list[str]: + """Extract top-level div fragments from HTML body content.""" + if not body_content: + return [] + wrapper = lxml.html.fromstring(f"
{body_content}
") + return [ + lxml.html.tostring(div, encoding="unicode") for div in wrapper.findall("./div") + ] + + +def partition_on_body(html: str) -> tuple[str, str, str]: + """Split HTML into pre-body, body content, and post-body.""" + match = _BODY_TAG_RE.search(html) + if not match: + return html, "", "" + pre_body = html[: match.end()] + rest = html[match.end() :] + body, sep, post_body = rest.rpartition("") + if not sep: + return html, "", "" + return pre_body, body, sep + post_body + + +def make_multi_docs_html( + bodies: Sequence[str], header: str = "", footer: str = "" +) -> list[str]: + """Inject per-page header/footer fragments into each body HTML document.""" + footer_body = partition_on_body(footer)[1] + footers = _extract_div_fragments(footer_body) + + header_body = partition_on_body(header)[1] + headers = _extract_div_fragments(header_body) + + is_same_length_header = len(headers) == len(bodies) + if headers and not is_same_length_header: + _logger.warning( + "Header fragments count (%d) does not match body count (%d); " + "reusing the first header fragment where needed.", + len(headers), + len(bodies), + ) + + is_same_length_footer = len(footers) == len(bodies) + if footers and not is_same_length_footer: + _logger.warning( + "Footer fragments count (%d) does not match body count (%d); " + "reusing the first footer fragment where needed.", + len(footers), + len(bodies), + ) + + documents = [] + for i, body in enumerate(bodies): + pre_body, body_content, post_body = partition_on_body(body) + header_fragment = ( + headers[i] if is_same_length_header else (headers[0] if headers else "") + ) + footer_fragment = ( + footers[i] if is_same_length_footer else (footers[0] if footers else "") + ) + documents.append( + f"{pre_body}{header_fragment}{body_content}{footer_fragment}{post_body}\n" + ) + + return documents + + +def _paper_muncher_debug_enabled(): + return os.getenv("ODOO_PAPER_MUNCHER_DEBUG") == "1" or _logger.isEnabledFor( + logging.DEBUG + ) + + +class IrActionsReport(models.Model): + _inherit = "ir.actions.report" + + @api.model + def _get_pdf_engine_config(self): + return ( + self.env["ir.config_parameter"] + .sudo() + .get_param("report.pdf_engine", "auto") + ) + + @api.model + def _resolve_pdf_engine(self) -> PdfEngineResolution: + engine = self._get_pdf_engine_config() + pm_info = paper_muncher() + if engine == "wkhtmltopdf": + return PdfEngineResolution(engine, False, pm_info) + if engine == "paper-muncher": + if pm_info.state != "ok": + raise UserError( + _( + "Paper-Muncher is not installed on this system. " + "Install it from " + "https://github.com/odoo/paper-muncher/releases " + "or set report.pdf_engine to 'auto' or 'wkhtmltopdf'." + ) + ) + return PdfEngineResolution(engine, True, pm_info) + return PdfEngineResolution(engine, pm_info.state == "ok", pm_info) + + @api.model + def _should_use_paper_muncher(self): + return self._resolve_pdf_engine().use_paper_muncher + + @api.model + def get_wkhtmltopdf_state(self): + engine = self._get_pdf_engine_config() + pm_info = paper_muncher() + if engine == "wkhtmltopdf": + return super().get_wkhtmltopdf_state() + if pm_info.state == "ok": + return "ok" + if engine == "paper-muncher": + return "install" + return super().get_wkhtmltopdf_state() + + @api.model + def _resolve_report_sudo(self, report_ref): + if not report_ref: + return None + try: + return self._get_report(report_ref) + except ValueError: + return None + + @api.model + def _get_report_log_label(self, report_ref=False, report_sudo=None): + if report_sudo: + return report_sudo.report_name or report_sudo.display_name + if not report_ref: + return "unknown" + try: + report = self._get_report(report_ref) + return report.report_name or report.display_name + except ValueError: + return str(report_ref) + + @api.model + def _run_wkhtmltopdf( + self, + bodies, + report_ref=False, + header=None, + footer=None, + landscape=False, + specific_paperformat_args=None, + set_viewport_size=False, + ): + resolution = self._resolve_pdf_engine() + report_sudo = self._resolve_report_sudo(report_ref) + report_label = self._get_report_log_label(report_ref, report_sudo=report_sudo) + + if resolution.use_paper_muncher: + _logger.info( + ( + "PDF engine: Paper-Muncher " + "(report=%s, config=%s, binary=%s, version=%s)" + ), + report_label, + resolution.engine, + resolution.pm_info.bin, + resolution.pm_info.version, + ) + return self._run_paper_muncher( + bodies, + report_ref=report_ref, + header=header, + footer=footer, + landscape=landscape, + specific_paperformat_args=specific_paperformat_args, + report_sudo=report_sudo, + report_label=report_label, + pm_info=resolution.pm_info, + ) + if resolution.engine == "auto" and resolution.pm_info.state != "ok": + _logger.info( + ( + "PDF engine: wkhtmltopdf " + "(report=%s, config=auto, Paper-Muncher not available)" + ), + report_label, + ) + elif resolution.engine == "wkhtmltopdf": + _logger.info( + "PDF engine: wkhtmltopdf (report=%s, config=wkhtmltopdf)", + report_label, + ) + return super()._run_wkhtmltopdf( + bodies, + report_ref=report_ref, + header=header, + footer=footer, + landscape=landscape, + specific_paperformat_args=specific_paperformat_args, + set_viewport_size=set_viewport_size, + ) + + @api.model + def _run_paper_muncher( + self, + bodies, + report_ref=False, + header=None, + footer=None, + landscape=False, + specific_paperformat_args=None, + scale=72, + report_sudo=None, + report_label=None, + pm_info=None, + ): + """Render a PDF from HTML content using Paper Muncher subprocess.""" + pm_info = pm_info or paper_muncher() + report_label = report_label or self._get_report_log_label( + report_ref, report_sudo=report_sudo + ) + + paperformat = ( + report_sudo.get_paperformat() if report_sudo else self.get_paperformat() + ) + header = header or "" + footer = footer or "" + + if not isinstance(bodies, list | tuple): + bodies = list(bodies) + + documents = make_multi_docs_html(bodies, header, footer) + + names = [f"pipe:/paper-muncher/{i}.html" for i in range(len(documents))] + extra_args = self._build_paper_muncher_extra_args( + landscape=landscape, + paperformat=paperformat, + scale=scale, + specific_paperformat_args=specific_paperformat_args, + ) + + os_env = os.environ.copy() + os_env["NO_COLOR"] = "1" + + try: + with ExitStack() as stack: + wsgi_environ = {} + if request and request.db: + temp_session = root.session_store.new() + temp_session.update( + { + **request.session, + "debug": "", + "_trace_disable": True, + } + ) + if temp_session.uid: + temp_session.session_token = security.compute_session_token( + temp_session, self.env + ) + root.session_store.save(temp_session) + stack.callback(root.session_store.delete, temp_session) + url = urlsplit(self._get_report_url()) + wsgi_environ["HTTP_HOST"] = url.netloc + wsgi_environ["HTTP_COOKIE"] = ( + f"session_id={temp_session.sid}; HttpOnly; " + f"domain={url.hostname}; path=/;" + ) + else: + wsgi_environ["HTTP_X_ODOO_DATABASE"] = self.env.cr.dbname + + with PaperMuncherServer( + args=[ + pm_info.bin, + *names, + "-o", + "pipe:/paper-muncher/output.pdf", + *extra_args, + ], + os_env=os_env, + wsgi_environ=wsgi_environ, + ) as server: + pdf = server.serve(documents) + _logger.info( + ( + "PDF engine: Paper-Muncher completed " + "(report=%s, pages=%d, size=%d bytes)" + ), + report_label, + len(documents), + len(pdf), + ) + return pdf + except (subprocess.CalledProcessError, TimeoutError, RuntimeError) as exc: + message = _("Paper-Muncher failed. Message: %s", str(exc)[-1000:]) + _logger.warning(message) + raise UserError(message) from exc + + @api.model + def _build_paper_muncher_extra_args( + self, + landscape=False, + paperformat=None, + scale=72, + specific_paperformat_args=None, + ): + """Build paper-muncher CLI arguments (exposed for testing).""" + if specific_paperformat_args: + if not landscape and specific_paperformat_args.get("data-report-landscape"): + landscape = specific_paperformat_args["data-report-landscape"] + if specific_paperformat_args.get("data-report-dpi"): + scale = int(specific_paperformat_args["data-report-dpi"]) + + extra_args = [ + "--scale", + f"{scale}dpi", + "--margins", + "none", + ] + if landscape: + extra_args += ["--orientation", "landscape"] + elif paperformat and paperformat.orientation: + extra_args += ["--orientation", paperformat.orientation.lower()] + if os.getenv("ODOO_PAPER_MUNCHER_FEATURE") == "1": + extra_args += ["--feature", "*=on"] + if paperformat and paperformat.format: + if paperformat.format != "custom": + extra_args += ["--paper", paperformat.format] + elif paperformat.page_height and paperformat.page_width: + extra_args += ["--width", f"{paperformat.page_width}mm"] + extra_args += ["--height", f"{paperformat.page_height}mm"] + if _paper_muncher_debug_enabled(): + extra_args += ["--debug", "http-client"] + return extra_args diff --git a/base_report_paper_muncher/paper_muncher.py b/base_report_paper_muncher/paper_muncher.py new file mode 100644 index 00000000000..ac57129b2c6 --- /dev/null +++ b/base_report_paper_muncher/paper_muncher.py @@ -0,0 +1,385 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import datetime as dt +import logging +import os +import os.path +import re +import selectors +import subprocess as sp +import sys +import threading +import time +from collections.abc import Sequence +from email.utils import format_datetime +from functools import cache +from io import DEFAULT_BUFFER_SIZE, BytesIO +from typing import Literal, NamedTuple +from urllib.parse import unquote + +import h11 + +from odoo.http import root +from odoo.tools.misc import find_in_path + +__all__ = ["PaperMuncherInfo", "PaperMuncherServer", "paper_muncher"] + +_logger = logging.getLogger(__name__) +_logger_pipe = _logger.getChild("pipe") +_logger_process = _logger.getChild("process") + +SERVER_SOFTWARE = "Odoo" +SERVER_AGENT = "Odoo" + +FALLBACK_BIN_PATH = "/opt/paper-muncher/bin/paper-muncher" +WRITE_TIMEOUT = 15 +SERVE_TIMEOUT = 15 * 60 +CHUNK_SIZE = 8192 +MAX_INCOMPLETE_EVENT_SIZE = 8192 +GET_DOCUMENT_RE = re.compile(rb"^/paper-muncher/(\.|[0-9]+)\.(?:html|xhtml|xml)$") + + +class PaperMuncherServer: + __slots__ = ( + "_args", + "_conn", + "_deadline", + "_documents", + "_os_env", + "_pdf", + "_process", + "_request", + "_request_body", + "_selector", + "_wsgi_environ", + ) + + def __init__(self, args, os_env=None, wsgi_environ=None): + self._args = args + self._os_env = os_env + self._wsgi_environ = wsgi_environ or {} + self._process = None + + def __enter__(self): + if self._process: + raise RuntimeError("process started already") + + self._process = sp.Popen( + self._args, + stdin=sp.PIPE, + stdout=sp.PIPE, + stderr=( + sys.stderr + if logging.NOTSET < _logger_process.level <= logging.DEBUG + else sp.DEVNULL + ), + env=self._os_env, + ) + + self._conn = h11.Connection( + h11.SERVER, + max_incomplete_event_size=MAX_INCOMPLETE_EVENT_SIZE, + ) + return self + + def __exit__(self, *_): + if self._process and not self._process.poll(): + try: + self._process.terminate() + self._process.wait(1) + except sp.TimeoutExpired: + self._process.kill() + self._process = None + + def serve(self, documents: Sequence[str], *, timeout: int = SERVE_TIMEOUT): + """Serve Paper Muncher requests until the rendered PDF is returned.""" + if not self._process: + raise RuntimeError( + "this function cannot be called outside of the context manager" + ) + + if not hasattr(threading.current_thread(), "query_count"): + threading.current_thread().query_count = 0 + threading.current_thread().query_time = 0 + + _logger.debug("Starting request loop, %d documents available", len(documents)) + self._deadline = time.monotonic() + timeout + self._documents = [ + doc.encode() if isinstance(doc, str) else doc for doc in documents + ] + self._selector = selectors.DefaultSelector() + with self._selector: + self._selector.register( + self._process.stdout, selectors.EVENT_READ, data="stdout" + ) + + while self._process.poll() is None and self._selector.get_map(): + events = self._selector.select(timeout=_remaining_time(self._deadline)) + if events: + chunk = os.read(self._process.stdout.fileno(), CHUNK_SIZE) + if logging.NOTSET < _logger_pipe.level <= logging.DEBUG: + _logger_pipe.debug("read %d bytes:\n%s", len(chunk), chunk) + else: + _logger.debug("read %d bytes", len(chunk)) + self._conn.receive_data(chunk) + self._process_data() + + if exit_code := self._process.poll(): + raise sp.CalledProcessError(exit_code, self._args) + + return self._pdf + + def _process_data(self): + while True: + event = self._conn.next_event() + _logger.debug( + "h11 current-state=%s event=%s", self._conn.states, type(event).__name__ + ) + if event is h11.NEED_DATA: + break + if isinstance(event, h11.Request): + _logger.debug( + "[REQ] %s %s", event.method.decode(), event.target.decode() + ) + self._request = event + self._request_body = bytearray() + elif isinstance(event, h11.Data): + self._request_body += event.data + elif isinstance(event, h11.EndOfMessage): + try: + self._process_request() + except Exception as exc: + exc.add_note("upon processing %s" % self._request) + raise + if self._conn.our_state is h11.MUST_CLOSE: + self._selector.unregister(self._process.stdout) + break + self._conn.start_next_cycle() + elif isinstance(event, h11.ConnectionClosed): + self._selector.unregister(self._process.stdout) + break + else: + raise TypeError(f"unexpected {event=} in states={self._conn.states}") + + def _process_request(self): + if self._request.method == b"GET" and ( + match := GET_DOCUMENT_RE.match(self._request.target) + ): + self._handle_get_document(match[1]) + elif ( + self._request.method == b"PUT" + and self._request.target == b"/paper-muncher/output.pdf" + ): + self._handle_put(self._request_body) + _logger.debug("Got a PDF of %s bytes", len(self._request_body)) + else: + self._handle_fallback(self._request, self._request_body) + + def _handle_get_document(self, document_index): + """Serve one GET document request from the worker.""" + index = int(document_index) if document_index != b"." else 0 + content = self._documents[index] + + response = h11.Response( + status_code=200, + headers=[ + ( + b"Date", + format_datetime(dt.datetime.now(dt.timezone.utc), usegmt=True), + ), + (b"Content-Length", str(len(content))), + (b"Content-Type", "text/html; charset=utf-8"), + (b"Server", SERVER_SOFTWARE), + ], + ) + self._send(response) + self._send(h11.Data(data=content)) + self._send(h11.EndOfMessage()) + + def _handle_put(self, body: bytes): + assert body.startswith(b"%PDF-"), body + self._pdf = body + response = h11.Response( + status_code=200, + headers=[ + ( + b"Date", + format_datetime(dt.datetime.now(dt.timezone.utc), usegmt=True), + ), + (b"Server", SERVER_SOFTWARE), + (b"Content-Length", "0"), + (b"Connection", "close"), + ], + ) + self._send(response) + self._send(h11.EndOfMessage()) + self._process.stdin.close() + + def _handle_fallback(self, request: h11.Request, body: bytes): + assert request.target.startswith(b"/"), request.target + request_uri = request.target.decode("ascii") + path_quoted, _, query = request_uri.partition("?") + environ = { + "REQUEST_METHOD": request.method.decode("ascii"), + "SCRIPT_NAME": "", + "PATH_INFO": unquote(path_quoted, "latin-1"), + "QUERY_STRING": query, + "REQUEST_URI": request_uri, + "RAW_URI": request_uri, + "SERVER_PROTOCOL": "HTTP/1.0", + "SERVER_SOFTWARE": SERVER_SOFTWARE, + "wsgi.version": (1, 0), + "wsgi.url_scheme": "http", + "wsgi.input": BytesIO(body), + "wsgi.errors": sys.stderr, + "wsgi.multithread": False, + "wsgi.multiprocess": False, + "wsgi.run_once": False, + } + headers = { + "HTTP_" + header.upper().replace(b"-", b"_").decode("ascii"): value.decode( + "latin-1" + ) + for header, value in request.headers + } + if content_type := headers.pop("HTTP_CONTENT_TYPE", ""): + environ["CONTENT_TYPE"] = content_type + if content_length := headers.pop("HTTP_CONTENT_LENGTH", ""): + environ["CONTENT_LENGTH"] = content_length + environ.update(headers) + environ.update(self._wsgi_environ) + + response = None + x_sendfile = None + + def start_response(status, res_headers, exc_info=None): + nonlocal response, x_sendfile + status_code = int(status.partition(" ")[0]) + res_headers = [(_normalize_header(h), v) for h, v in res_headers] + + def find_header(header): + return next((v for h, v in res_headers if h == header), None) + + if find_header(b"Connection"): + raise ValueError("the WSGI app cannot set the Connection header") + if find_header(b"Upgrade"): + raise ValueError("paper-muncher does not support websocket") + if not find_header(b"Date"): + res_headers.insert( + 0, + ( + b"Date", + format_datetime(dt.datetime.now(dt.timezone.utc), usegmt=True), + ), + ) + if not find_header(b"Server"): + res_headers.append((b"Server", SERVER_AGENT)) + x_sendfile = find_header(b"X-Sendfile") + if x_sendfile: + index = next( + ( + i + for i, (h, v) in enumerate(res_headers) + if h == b"Content-Length" + ) + ) + res_headers[index] = ( + b"Content-Length", + str(os.path.getsize(x_sendfile)), + ) + + response = h11.Response(status_code=status_code, headers=res_headers) + _logger.debug( + "[RES] %s %s", request.method.decode(), request.target.decode() + ) + + response_body = root(environ, start_response) + deadline = time.monotonic() + WRITE_TIMEOUT + self._send(response, deadline=deadline) + + try: + if x_sendfile: + response_chunks = list(response_body) + assert not any(response_chunks), response_chunks + with open(x_sendfile, "rb") as f: + while chunk := f.read(DEFAULT_BUFFER_SIZE): + self._send(h11.Data(data=chunk), deadline=deadline) + else: + for chunk in response_body: + self._send(h11.Data(data=chunk), deadline=deadline) + if hasattr(response_body, "close"): + response_body.close() + self._send(h11.EndOfMessage(), deadline=deadline) + except Exception: + self._conn.send_failed() + raise + + def _send(self, event, *, deadline=None) -> None: + data = self._conn.send(event) + memview = memoryview(data) + bytes_written = 0 + + if deadline is None: + deadline = time.monotonic() + WRITE_TIMEOUT + + with selectors.DefaultSelector() as selector: + selector.register(self._process.stdin.fileno(), selectors.EVENT_WRITE) + while bytes_written < len(data): + events = selector.select(timeout=_remaining_time(deadline)) + if not events: + raise TimeoutError("Timeout exceeded while writing to subprocess") + bytes_written += os.write( + self._process.stdin.fileno(), memview[bytes_written:] + ) + self._process.stdin.flush() + + if logging.NOTSET < _logger_pipe.level <= logging.DEBUG: + _logger_pipe.debug("wrote %d bytes:\n%s", bytes_written, data) + else: + _logger.debug("wrote %d bytes", bytes_written) + + +def _remaining_time(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError + return max(1, remaining) + + +def _normalize_header(header: str | bytes) -> bytes: + if isinstance(header, bytes): + header = header.decode("ascii") + return header.replace("-", " ").title().replace(" ", "-").encode("ascii") + + +class PaperMuncherInfo(NamedTuple): + state: Literal["ok", "install"] + bin: str + version: str + + +@cache +def paper_muncher() -> PaperMuncherInfo: + bin_path = "" + version = "" + try: + try: + bin_path = find_in_path("paper-muncher") + except OSError as exc: + if not os.path.isfile(FALLBACK_BIN_PATH): + raise RuntimeError("paper-muncher binary not found in PATH") from exc + bin_path = FALLBACK_BIN_PATH + + result = sp.run( + [bin_path, "--version"], stdout=sp.PIPE, stderr=sp.DEVNULL, check=True + ) + version = result.stdout.decode("utf-8", errors="replace").strip() + except (RuntimeError, OSError, sp.SubprocessError): + _logger.info( + "You need paper-muncher to print a pdf version of the reports.", + exc_info=_logger.isEnabledFor(logging.DEBUG), + ) + return PaperMuncherInfo(state="install", bin=bin_path, version=version) + + _logger.info("Will use the paper-muncher binary at %s", bin_path) + return PaperMuncherInfo(state="ok", bin=bin_path, version=version) diff --git a/base_report_paper_muncher/pyproject.toml b/base_report_paper_muncher/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/base_report_paper_muncher/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_report_paper_muncher/readme/CONFIGURE.md b/base_report_paper_muncher/readme/CONFIGURE.md new file mode 100644 index 00000000000..dfe7ae69601 --- /dev/null +++ b/base_report_paper_muncher/readme/CONFIGURE.md @@ -0,0 +1,23 @@ +The system parameter `report.pdf_engine` controls which PDF engine is used: + +| Value | Behavior | +| ----- | -------- | +| `auto` (default) | Use Paper Muncher when the binary is available, otherwise wkhtmltopdf | +| `paper-muncher` | Force Paper Muncher; raise an error if the binary is missing | +| `wkhtmltopdf` | Always use wkhtmltopdf | + +Change it from code: + +```python +env["ir.config_parameter"].sudo().set_param("report.pdf_engine", "auto") +``` + +Or from **Settings > Technical > Parameters > System Parameters**, key +`report.pdf_engine`. + +Optional environment variables for the Odoo process: + +- `ODOO_PAPER_MUNCHER_FEATURE=1`: pass `--feature *=on` to the binary +- `ODOO_PAPER_MUNCHER_DEBUG=1`: pass `--debug http-client` to the binary. This + flag is not sent by default in production. It is also enabled automatically + when the module logger is set to `DEBUG`. diff --git a/base_report_paper_muncher/readme/CONTRIBUTORS.md b/base_report_paper_muncher/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..957ad99ee32 --- /dev/null +++ b/base_report_paper_muncher/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Felix Coca \<\> \ No newline at end of file diff --git a/base_report_paper_muncher/readme/DESCRIPTION.md b/base_report_paper_muncher/readme/DESCRIPTION.md new file mode 100644 index 00000000000..697553c1243 --- /dev/null +++ b/base_report_paper_muncher/readme/DESCRIPTION.md @@ -0,0 +1,21 @@ +This module integrates [Paper Muncher](https://odoo.github.io/paper-muncher/) as +the PDF rendering engine for QWeb reports. When the `paper-muncher` binary is +available on the system, it replaces wkhtmltopdf as the default engine for PDF +generation. + +Main features: + +- Automatic detection of the `paper-muncher` binary in `PATH` or in + `/opt/paper-muncher/bin/paper-muncher` +- Globally configurable PDF engine via the `report.pdf_engine` system parameter +- Multi-page header and footer support and `report.paperformat` integration +- Transparent fallback to wkhtmltopdf when Paper Muncher is not installed +- HTTP-over-pipe communication between Odoo and the Paper Muncher subprocess, + so report assets are served with Odoo permissions + +This module depends on: + +- Odoo modules: `base`, `web` +- Python package: `h11` +- System binary: `paper-muncher` from + [GitHub releases](https://github.com/odoo/paper-muncher/releases) diff --git a/base_report_paper_muncher/readme/INSTALL.md b/base_report_paper_muncher/readme/INSTALL.md new file mode 100644 index 00000000000..4a19188ce16 --- /dev/null +++ b/base_report_paper_muncher/readme/INSTALL.md @@ -0,0 +1,27 @@ +Install the `paper-muncher` system binary before enabling this module. Example +for Ubuntu 22.04 (Jammy): + +```bash +curl -fsSL -o paper-muncher.deb \ + "https://github.com/odoo/paper-muncher/releases/download/v0.3.1-1/paper-muncher_v0.3.1-1_jammy_amd64.deb" +sudo apt install ./paper-muncher.deb +paper-muncher --help +``` + +If the binary is not installed via the `.deb` package, add +`/opt/paper-muncher/bin` to the `PATH` of the Odoo process. + +The Python dependency `h11` is declared in the module manifest and listed in +`requirements.txt`. Install it in the Odoo Python environment if needed: + +```bash +pip install h11 +``` + +Then install the module as any other Odoo addon: + +```bash +odoo -d -i base_report_paper_muncher --stop-after-init +``` + +Paper Muncher is only supported on Linux and macOS. diff --git a/base_report_paper_muncher/readme/ROADMAP.md b/base_report_paper_muncher/readme/ROADMAP.md new file mode 100644 index 00000000000..e2df9e6693a --- /dev/null +++ b/base_report_paper_muncher/readme/ROADMAP.md @@ -0,0 +1,6 @@ +- Paper Muncher is only supported on Linux and macOS (not Windows). +- WebSocket requests are rejected by the HTTP-over-pipe server. +- Rendering timeout: 15 minutes (`SERVE_TIMEOUT`). +- Pipe write timeout: 15 seconds (`WRITE_TIMEOUT`). +- The binary must be installed on the system; this module only integrates the + communication layer. diff --git a/base_report_paper_muncher/readme/USAGE.md b/base_report_paper_muncher/readme/USAGE.md new file mode 100644 index 00000000000..d0d326a9de8 --- /dev/null +++ b/base_report_paper_muncher/readme/USAGE.md @@ -0,0 +1,20 @@ +Once installed and configured, QWeb PDF reports use Paper Muncher automatically +when the binary is available and `report.pdf_engine` is set to `auto` or +`paper-muncher`. + +Check the engine status: + +```python +env["ir.actions.report"].get_wkhtmltopdf_state() +``` + +Returns `ok` when Paper Muncher is available with `auto` or `paper-muncher`. +Returns `install` when `paper-muncher` is forced but the binary is missing. + +When generating PDFs, look for log lines prefixed with `PDF engine:`: + +- `PDF engine: Paper-Muncher (...)`: rendering started with Paper Muncher +- `PDF engine: Paper-Muncher completed (...)`: PDF generated successfully +- `PDF engine: wkhtmltopdf (...)`: fallback to wkhtmltopdf + +Low-level HTTP-over-pipe details are logged at `DEBUG` level only. diff --git a/base_report_paper_muncher/requirements.txt b/base_report_paper_muncher/requirements.txt new file mode 100644 index 00000000000..0d24def7113 --- /dev/null +++ b/base_report_paper_muncher/requirements.txt @@ -0,0 +1 @@ +h11 diff --git a/base_report_paper_muncher/static/description/index.html b/base_report_paper_muncher/static/description/index.html new file mode 100644 index 00000000000..cb8a077d5ef --- /dev/null +++ b/base_report_paper_muncher/static/description/index.html @@ -0,0 +1,544 @@ + + + + + +Report Engine: Paper Muncher + + + +
+

Report Engine: Paper Muncher

+ + +

Beta License: LGPL-3 OCA/server-tools Translate me on Weblate Try me on Runboat

+

This module integrates Paper +Muncher as the PDF rendering +engine for QWeb reports. When the paper-muncher binary is available +on the system, it replaces wkhtmltopdf as the default engine for PDF +generation.

+

Main features:

+
    +
  • Automatic detection of the paper-muncher binary in PATH or in +/opt/paper-muncher/bin/paper-muncher
  • +
  • Globally configurable PDF engine via the report.pdf_engine system +parameter
  • +
  • Multi-page header and footer support and report.paperformat +integration
  • +
  • Transparent fallback to wkhtmltopdf when Paper Muncher is not +installed
  • +
  • HTTP-over-pipe communication between Odoo and the Paper Muncher +subprocess, so report assets are served with Odoo permissions
  • +
+

This module depends on:

+
    +
  • Odoo modules: base, web
  • +
  • Python package: h11
  • +
  • System binary: paper-muncher from GitHub +releases
  • +
+

Table of contents

+ +
+

Installation

+

Install the paper-muncher system binary before enabling this module. +Example for Ubuntu 22.04 (Jammy):

+
+curl -fsSL -o paper-muncher.deb \
+  "https://github.com/odoo/paper-muncher/releases/download/v0.3.1-1/paper-muncher_v0.3.1-1_jammy_amd64.deb"
+sudo apt install ./paper-muncher.deb
+paper-muncher --help
+
+

If the binary is not installed via the .deb package, add +/opt/paper-muncher/bin to the PATH of the Odoo process.

+

The Python dependency h11 is declared in the module manifest and +listed in requirements.txt. Install it in the Odoo Python +environment if needed:

+
+pip install h11
+
+

Then install the module as any other Odoo addon:

+
+odoo -d <database> -i base_report_paper_muncher --stop-after-init
+
+

Paper Muncher is only supported on Linux and macOS.

+
+
+

Configuration

+

The system parameter report.pdf_engine controls which PDF engine is +used:

+ ++++ + + + + + + + + + + + + + + + + +
ValueBehavior
auto (default)Use Paper Muncher when the binary is available, +otherwise wkhtmltopdf
paper-muncherForce Paper Muncher; raise an error if the +binary is missing
wkhtmltopdfAlways use wkhtmltopdf
+

Change it from code:

+
+env["ir.config_parameter"].sudo().set_param("report.pdf_engine", "auto")
+
+

Or from Settings > Technical > Parameters > System Parameters, key +report.pdf_engine.

+

Optional environment variables for the Odoo process:

+
    +
  • ODOO_PAPER_MUNCHER_FEATURE=1: pass --feature *=on to the +binary
  • +
  • ODOO_PAPER_MUNCHER_DEBUG=1: pass --debug http-client to the +binary. This flag is not sent by default in production. It is also +enabled automatically when the module logger is set to DEBUG.
  • +
+
+
+

Usage

+

Once installed and configured, QWeb PDF reports use Paper Muncher +automatically when the binary is available and report.pdf_engine is +set to auto or paper-muncher.

+

Check the engine status:

+
+env["ir.actions.report"].get_wkhtmltopdf_state()
+
+

Returns ok when Paper Muncher is available with auto or +paper-muncher. Returns install when paper-muncher is forced +but the binary is missing.

+

When generating PDFs, look for log lines prefixed with PDF engine::

+
    +
  • PDF engine: Paper-Muncher (...): rendering started with Paper +Muncher
  • +
  • PDF engine: Paper-Muncher completed (...): PDF generated +successfully
  • +
  • PDF engine: wkhtmltopdf (...): fallback to wkhtmltopdf
  • +
+

Low-level HTTP-over-pipe details are logged at DEBUG level only.

+
+
+

Known issues / Roadmap

+
    +
  • Paper Muncher is only supported on Linux and macOS (not Windows).
  • +
  • WebSocket requests are rejected by the HTTP-over-pipe server.
  • +
  • Rendering timeout: 15 minutes (SERVE_TIMEOUT).
  • +
  • Pipe write timeout: 15 seconds (WRITE_TIMEOUT).
  • +
  • The binary must be installed on the system; this module only +integrates the communication layer.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_report_paper_muncher/tests/__init__.py b/base_report_paper_muncher/tests/__init__.py new file mode 100644 index 00000000000..87d081416f6 --- /dev/null +++ b/base_report_paper_muncher/tests/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_paper_muncher_unit +from . import test_report diff --git a/base_report_paper_muncher/tests/test_paper_muncher_unit.py b/base_report_paper_muncher/tests/test_paper_muncher_unit.py new file mode 100644 index 00000000000..074a04524e3 --- /dev/null +++ b/base_report_paper_muncher/tests/test_paper_muncher_unit.py @@ -0,0 +1,697 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import os +import selectors +import subprocess +from unittest.mock import MagicMock, patch + +import h11 + +import odoo.tests +from odoo.exceptions import UserError + +from odoo.addons.base_report_paper_muncher.models.ir_actions_report import ( + _extract_div_fragments, + make_multi_docs_html, + partition_on_body, +) +from odoo.addons.base_report_paper_muncher.paper_muncher import ( + FALLBACK_BIN_PATH, + PaperMuncherInfo, + PaperMuncherServer, + _remaining_time, + paper_muncher, +) + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPaperMuncherHelpers(odoo.tests.TransactionCase): + def test_partition_on_body_without_body_tag(self): + html = "" + self.assertEqual(partition_on_body(html), (html, "", "")) + + def test_partition_on_body_without_closing_tag(self): + html = "

content

" + self.assertEqual(partition_on_body(html), (html, "", "")) + + def test_partition_on_body_with_body(self): + html = "

content

" + pre, body, post = partition_on_body(html) + self.assertTrue(pre.endswith("")) + self.assertEqual(body, "

content

") + self.assertEqual(post, "") + + def test_make_multi_docs_html_with_header_footer(self): + bodies = [ + "

Page 1

", + "

Page 2

", + ] + header = "
H1
H2
" + footer = "
F1
F2
" + documents = make_multi_docs_html(bodies, header, footer) + self.assertEqual(len(documents), 2) + self.assertIn("H1", documents[0]) + self.assertIn("F1", documents[0]) + self.assertIn("Page 1", documents[0]) + self.assertIn("H2", documents[1]) + self.assertIn("F2", documents[1]) + self.assertIn("Page 2", documents[1]) + + def test_make_multi_docs_html_reuses_first_header_when_counts_differ(self): + bodies = [ + "

Page 1

", + "

Page 2

", + ] + header = "
H1
" + documents = make_multi_docs_html(bodies, header=header) + self.assertIn("H1", documents[0]) + self.assertIn("H1", documents[1]) + + def test_extract_div_fragments_empty(self): + self.assertEqual(_extract_div_fragments(""), []) + + def test_make_multi_docs_html_reuses_first_footer_when_counts_differ(self): + bodies = [ + "

Page 1

", + "

Page 2

", + ] + footer = "
F1
" + documents = make_multi_docs_html(bodies, footer=footer) + self.assertIn("F1", documents[0]) + self.assertIn("F1", documents[1]) + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPaperMuncherServerHandlers(odoo.tests.TransactionCase): + def _make_server(self): + server = PaperMuncherServer(args=["paper-muncher"], os_env={}) + server._process = MagicMock() + server._process.stdin = MagicMock() + server._process.stdin.fileno.return_value = 1 + server._conn = h11.Connection(h11.SERVER, max_incomplete_event_size=8192) + server._documents = [b"doc"] + server._pdf = None + return server + + def test_handle_get_document(self): + server = self._make_server() + + def fake_write(fd, data): + return len(data) + + with ( + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.os.write", + side_effect=fake_write, + ) as mock_write, + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.selectors.DefaultSelector" + ) as mock_selector_cls, + ): + selector = mock_selector_cls.return_value.__enter__.return_value + selector.select.return_value = [(None, selectors.EVENT_WRITE)] + server._handle_get_document(b"0") + self.assertTrue(mock_write.called) + + def test_handle_get_document_default_index(self): + server = self._make_server() + with patch.object(PaperMuncherServer, "_send") as mock_send: + server._handle_get_document(b".") + self.assertEqual(mock_send.call_count, 3) + + def test_handle_put(self): + server = self._make_server() + pdf_body = b"%PDF-1.4 test" + with patch.object(PaperMuncherServer, "_send") as mock_send: + server._handle_put(pdf_body) + self.assertEqual(server._pdf, pdf_body) + server._process.stdin.close.assert_called_once() + self.assertEqual(mock_send.call_count, 2) + + def test_process_request_get_document(self): + server = self._make_server() + server._request = h11.Request( + method=b"GET", + target=b"/paper-muncher/0.html", + headers=[(b"Host", b"localhost")], + ) + server._request_body = bytearray() + with patch.object(PaperMuncherServer, "_handle_get_document") as mock_get: + server._process_request() + mock_get.assert_called_once_with(b"0") + + def test_process_request_put_pdf(self): + server = self._make_server() + server._request = h11.Request( + method=b"PUT", + target=b"/paper-muncher/output.pdf", + headers=[(b"Host", b"localhost")], + ) + server._request_body = bytearray(b"%PDF-1.4") + with patch.object(PaperMuncherServer, "_handle_put") as mock_put: + server._process_request() + mock_put.assert_called_once_with(b"%PDF-1.4") + + def test_handle_fallback_websocket_rejected(self): + server = self._make_server() + request = h11.Request( + method=b"GET", + target=b"/websocket", + headers=[(b"Host", b"localhost"), (b"Upgrade", b"websocket")], + ) + + def fake_wsgi(environ, start_response): + start_response("200 OK", [(b"Upgrade", b"websocket")]) + return [b""] + + with patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.root", + fake_wsgi, + ): + with self.assertRaises(ValueError): + server._handle_fallback(request, b"") + + def test_handle_fallback_serves_wsgi_response(self): + server = self._make_server() + request = h11.Request( + method=b"GET", + target=b"/web/assets/test.css", + headers=[(b"Host", b"localhost")], + ) + sent_events = [] + + def capture_send(self_server, event, *, deadline=None): + sent_events.append(event) + + def fake_wsgi(environ, start_response): + start_response("200 OK", [(b"Content-Type", b"text/css")]) + return [b"body"] + + with patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.root", + fake_wsgi, + ): + with patch.object( + PaperMuncherServer, "_send", autospec=True, side_effect=capture_send + ): + server._handle_fallback(request, b"") + self.assertTrue(any(isinstance(e, h11.Response) for e in sent_events)) + self.assertTrue(any(isinstance(e, h11.Data) for e in sent_events)) + self.assertTrue(any(isinstance(e, h11.EndOfMessage) for e in sent_events)) + + def test_handle_fallback_x_sendfile(self): + server = self._make_server() + request = h11.Request( + method=b"GET", + target=b"/web/content/1", + headers=[(b"Host", b"localhost")], + ) + sent_events = [] + + def capture_send(self_server, event, *, deadline=None): + sent_events.append(event) + + def fake_wsgi(environ, start_response): + start_response( + "200 OK", + [ + (b"Content-Type", b"application/octet-stream"), + (b"X-Sendfile", b"/tmp/test.bin"), + (b"Content-Length", b"0"), + ], + ) + return [] + + with patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.root", + fake_wsgi, + ): + with patch("builtins.open", create=True) as mock_open: + mock_open.return_value.__enter__.return_value.read.side_effect = [ + b"chunk1", + b"", + ] + with patch.object( + PaperMuncherServer, + "_send", + autospec=True, + side_effect=capture_send, + ): + with patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.os.path.getsize", + return_value=6, + ): + server._handle_fallback(request, b"") + self.assertTrue(any(isinstance(e, h11.Data) for e in sent_events)) + + def test_send_writes_to_subprocess_stdin(self): + server = self._make_server() + + def fake_write(fd, data): + return len(data) + + with ( + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.os.write", + side_effect=fake_write, + ) as mock_write, + patch.object(server._conn, "send", return_value=b"x" * 10), + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.selectors.DefaultSelector" + ) as mock_selector_cls, + ): + selector = mock_selector_cls.return_value.__enter__.return_value + selector.select.return_value = [(None, selectors.EVENT_WRITE)] + server._send(MagicMock()) + mock_write.assert_called() + server._process.stdin.flush.assert_called() + + def test_remaining_time_raises_on_expired_deadline(self): + with self.assertRaises(TimeoutError): + _remaining_time(0) + + def test_serve_raises_without_context_manager(self): + server = PaperMuncherServer(args=["paper-muncher"]) + with self.assertRaises(RuntimeError): + server.serve([""]) + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPaperMuncherServerLifecycle(odoo.tests.TransactionCase): + def test_context_manager_starts_and_terminates_process(self): + mock_process = MagicMock() + mock_process.poll.return_value = 0 + with patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.sp.Popen", + return_value=mock_process, + ): + with PaperMuncherServer(args=["paper-muncher"]) as server: + self.assertIs(server._process, mock_process) + self.assertIsNotNone(server._conn) + mock_process.terminate.assert_called_once() + mock_process.wait.assert_called_once_with(1) + + def test_context_manager_kills_on_wait_timeout(self): + mock_process = MagicMock() + mock_process.poll.return_value = None + mock_process.wait.side_effect = subprocess.TimeoutExpired("paper-muncher", 1) + with patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.sp.Popen", + return_value=mock_process, + ): + with PaperMuncherServer(args=["paper-muncher"]) as server: + self.assertIs(server._process, mock_process) + mock_process.kill.assert_called_once() + + def test_context_manager_raises_if_process_already_started(self): + server = PaperMuncherServer(args=["paper-muncher"]) + server._process = MagicMock() + with self.assertRaises(RuntimeError): + server.__enter__() + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPaperMuncherDetection(odoo.tests.TransactionCase): + def setUp(self): + super().setUp() + paper_muncher.cache_clear() + + def tearDown(self): + paper_muncher.cache_clear() + super().tearDown() + + def test_paper_muncher_ok_from_path(self): + with ( + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.find_in_path", + return_value="/usr/bin/paper-muncher", + ), + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.sp.run", + return_value=MagicMock(stdout=b"0.3.1"), + ), + ): + info = paper_muncher() + self.assertEqual(info.state, "ok") + self.assertEqual(info.bin, "/usr/bin/paper-muncher") + self.assertEqual(info.version, "0.3.1") + + def test_paper_muncher_ok_from_fallback_path(self): + with ( + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.find_in_path", + side_effect=OSError("not found"), + ), + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.os.path.isfile", + return_value=True, + ), + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.sp.run", + return_value=MagicMock(stdout=b"0.3.1"), + ), + ): + info = paper_muncher() + self.assertEqual(info.state, "ok") + self.assertEqual(info.bin, FALLBACK_BIN_PATH) + + def test_paper_muncher_install_when_missing(self): + with ( + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.find_in_path", + side_effect=OSError("not found"), + ), + patch( + "odoo.addons.base_report_paper_muncher.paper_muncher.os.path.isfile", + return_value=False, + ), + ): + info = paper_muncher() + self.assertEqual(info.state, "install") + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPaperMuncherEngineResolution(odoo.tests.TransactionCase): + def test_resolve_pdf_engine_auto_without_binary(self): + pm_info = PaperMuncherInfo("install", "", "") + self.env["ir.config_parameter"].sudo().set_param("report.pdf_engine", "auto") + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.paper_muncher", + return_value=pm_info, + ): + resolution = self.env["ir.actions.report"]._resolve_pdf_engine() + self.assertEqual(resolution.engine, "auto") + self.assertFalse(resolution.use_paper_muncher) + + def test_resolve_pdf_engine_paper_muncher_forced_missing_binary(self): + pm_info = PaperMuncherInfo("install", "", "") + self.env["ir.config_parameter"].sudo().set_param( + "report.pdf_engine", "paper-muncher" + ) + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.paper_muncher", + return_value=pm_info, + ): + with self.assertRaises(UserError): + self.env["ir.actions.report"]._resolve_pdf_engine() + + def test_should_use_paper_muncher(self): + pm_info = PaperMuncherInfo("ok", "/usr/bin/paper-muncher", "0.3.1") + self.env["ir.config_parameter"].sudo().set_param( + "report.pdf_engine", "paper-muncher" + ) + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.paper_muncher", + return_value=pm_info, + ): + self.assertTrue(self.env["ir.actions.report"]._should_use_paper_muncher()) + + def test_get_wkhtmltopdf_state_wkhtmltopdf_forced(self): + self.env["ir.config_parameter"].sudo().set_param( + "report.pdf_engine", "wkhtmltopdf" + ) + with patch( + "odoo.addons.base.models.ir_actions_report.IrActionsReport.get_wkhtmltopdf_state", + return_value="ok", + ) as mock_super: + state = self.env["ir.actions.report"].get_wkhtmltopdf_state() + mock_super.assert_called_once() + self.assertEqual(state, "ok") + + def test_get_wkhtmltopdf_state_paper_muncher_forced_missing(self): + pm_info = PaperMuncherInfo("install", "", "") + self.env["ir.config_parameter"].sudo().set_param( + "report.pdf_engine", "paper-muncher" + ) + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.paper_muncher", + return_value=pm_info, + ): + self.assertEqual( + self.env["ir.actions.report"].get_wkhtmltopdf_state(), "install" + ) + + def test_get_report_log_label_unknown(self): + label = self.env["ir.actions.report"]._get_report_log_label() + self.assertEqual(label, "unknown") + + def test_get_report_log_label_invalid_ref(self): + label = self.env["ir.actions.report"]._get_report_log_label( + "invalid.report.ref" + ) + self.assertEqual(label, "invalid.report.ref") + + def test_get_wkhtmltopdf_state_auto_without_binary_falls_back(self): + pm_info = PaperMuncherInfo("install", "", "") + self.env["ir.config_parameter"].sudo().set_param("report.pdf_engine", "auto") + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.paper_muncher", + return_value=pm_info, + ): + with patch( + "odoo.addons.base.models.ir_actions_report.IrActionsReport.get_wkhtmltopdf_state", + return_value="broken", + ) as mock_super: + state = self.env["ir.actions.report"].get_wkhtmltopdf_state() + mock_super.assert_called_once() + self.assertEqual(state, "broken") + + def test_get_report_log_label_from_report_sudo(self): + report = self.env["ir.actions.report"].create( + { + "name": "PM Label Report", + "model": "res.partner", + "report_type": "qweb-pdf", + "report_name": "base_report_paper_muncher.test_label_report", + } + ) + label = self.env["ir.actions.report"]._get_report_log_label(report_sudo=report) + self.assertEqual(label, "base_report_paper_muncher.test_label_report") + + def test_resolve_report_sudo_valid_ref(self): + report = self.env["ir.actions.report"].create( + { + "name": "PM Resolve Report", + "model": "res.partner", + "report_type": "qweb-pdf", + "report_name": "base_report_paper_muncher.test_resolve_report", + } + ) + resolved = self.env["ir.actions.report"]._resolve_report_sudo(report) + self.assertEqual(resolved, report) + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPaperMuncherRun(odoo.tests.TransactionCase): + def test_run_wkhtmltopdf_delegates_to_paper_muncher(self): + pm_info = PaperMuncherInfo("ok", "/usr/bin/paper-muncher", "0.3.1") + report = self.env["ir.actions.report"] + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.paper_muncher", + return_value=pm_info, + ): + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.IrActionsReport._run_paper_muncher", + return_value=b"%PDF-mock", + ) as mock_run: + with patch( + "odoo.addons.base.models.ir_actions_report.IrActionsReport._run_wkhtmltopdf", + ) as mock_super: + result = report._run_wkhtmltopdf([""]) + mock_run.assert_called_once() + mock_super.assert_not_called() + self.assertEqual(result, b"%PDF-mock") + + def test_run_wkhtmltopdf_auto_fallback_logs(self): + pm_info = PaperMuncherInfo("install", "", "") + report = self.env["ir.actions.report"] + self.env["ir.config_parameter"].sudo().set_param("report.pdf_engine", "auto") + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.paper_muncher", + return_value=pm_info, + ): + with patch( + "odoo.addons.base.models.ir_actions_report.IrActionsReport._run_wkhtmltopdf", + return_value=b"%PDF-fallback", + ) as mock_super: + result = report._run_wkhtmltopdf([""]) + mock_super.assert_called_once() + self.assertEqual(result, b"%PDF-fallback") + + def test_run_wkhtmltopdf_wkhtmltopdf_forced_logs(self): + self.env["ir.config_parameter"].sudo().set_param( + "report.pdf_engine", "wkhtmltopdf" + ) + report = self.env["ir.actions.report"] + with patch( + "odoo.addons.base.models.ir_actions_report.IrActionsReport._run_wkhtmltopdf", + return_value=b"%PDF-wk", + ) as mock_super: + result = report._run_wkhtmltopdf([""]) + mock_super.assert_called_once() + self.assertEqual(result, b"%PDF-wk") + + def test_run_paper_muncher_with_request_session(self): + pm_info = PaperMuncherInfo("ok", "/usr/bin/paper-muncher", "0.3.1") + report = self.env["ir.actions.report"] + mock_server = MagicMock() + mock_server.serve.return_value = b"%PDF-1.4\n" + mock_server.__enter__ = MagicMock(return_value=mock_server) + mock_server.__exit__ = MagicMock(return_value=False) + mock_request = MagicMock() + mock_request.db = self.env.cr.dbname + mock_request.session = {"uid": self.env.uid} + mock_session = MagicMock() + mock_session.sid = "test-session-id" + mock_session.uid = self.env.uid + mock_session_store = MagicMock() + mock_session_store.new.return_value = mock_session + captured = {} + + def capture_server_init(*args, **kwargs): + captured["wsgi_environ"] = kwargs.get("wsgi_environ", {}) + return mock_server + + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.request", + mock_request, + ): + with patch( + "odoo.addons.base.models.ir_actions_report.IrActionsReport._get_report_url", + return_value="http://localhost:8069/report/pdf", + ): + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.root.session_store", + mock_session_store, + ): + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.security.compute_session_token", + return_value="token", + ): + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.PaperMuncherServer", + side_effect=capture_server_init, + ): + pdf = report._run_paper_muncher( + [""], + pm_info=pm_info, + ) + self.assertEqual(pdf, b"%PDF-1.4\n") + self.assertIn("HTTP_COOKIE", captured["wsgi_environ"]) + self.assertIn("HTTP_HOST", captured["wsgi_environ"]) + mock_session_store.save.assert_called_once() + mock_session_store.delete.assert_called_once() + + def test_run_paper_muncher_success(self): + pm_info = PaperMuncherInfo("ok", "/usr/bin/paper-muncher", "0.3.1") + report = self.env["ir.actions.report"] + mock_server = MagicMock() + mock_server.serve.return_value = b"%PDF-1.4\n" + mock_server.__enter__ = MagicMock(return_value=mock_server) + mock_server.__exit__ = MagicMock(return_value=False) + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.PaperMuncherServer", + return_value=mock_server, + ): + pdf = report._run_paper_muncher( + ["

Test

"], + pm_info=pm_info, + ) + self.assertEqual(pdf, b"%PDF-1.4\n") + mock_server.serve.assert_called_once() + + def test_run_paper_muncher_non_list_bodies(self): + pm_info = PaperMuncherInfo("ok", "/usr/bin/paper-muncher", "0.3.1") + report = self.env["ir.actions.report"] + mock_server = MagicMock() + mock_server.serve.return_value = b"%PDF-1.4\n" + mock_server.__enter__ = MagicMock(return_value=mock_server) + mock_server.__exit__ = MagicMock(return_value=False) + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.PaperMuncherServer", + return_value=mock_server, + ): + pdf = report._run_paper_muncher( + ("",), + pm_info=pm_info, + ) + self.assertEqual(pdf, b"%PDF-1.4\n") + mock_server.serve.assert_called_once() + + def test_run_paper_muncher_failure_raises_user_error(self): + pm_info = PaperMuncherInfo("ok", "/usr/bin/paper-muncher", "0.3.1") + report = self.env["ir.actions.report"] + mock_server = MagicMock() + mock_server.serve.side_effect = subprocess.CalledProcessError( + 1, "paper-muncher" + ) + mock_server.__enter__ = MagicMock(return_value=mock_server) + mock_server.__exit__ = MagicMock(return_value=False) + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.PaperMuncherServer", + return_value=mock_server, + ): + with self.assertRaises(UserError): + report._run_paper_muncher( + [""], + pm_info=pm_info, + ) + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPaperMuncherExtraArgs(odoo.tests.TransactionCase): + def test_build_extra_args_landscape(self): + report = self.env["ir.actions.report"] + extra_args = report._build_paper_muncher_extra_args(landscape=True) + self.assertIn("--orientation", extra_args) + self.assertIn("landscape", extra_args) + + def test_build_extra_args_custom_paperformat(self): + paperformat = self.env["report.paperformat"].create( + { + "name": "PM Custom Format", + "format": "custom", + "orientation": "Portrait", + "page_width": 200, + "page_height": 300, + } + ) + report = self.env["ir.actions.report"] + extra_args = report._build_paper_muncher_extra_args(paperformat=paperformat) + self.assertIn("--width", extra_args) + self.assertIn("200mm", extra_args) + self.assertIn("--height", extra_args) + self.assertIn("300mm", extra_args) + + def test_build_extra_args_standard_paperformat(self): + paperformat = self.env.ref("base.paperformat_euro") + report = self.env["ir.actions.report"] + extra_args = report._build_paper_muncher_extra_args(paperformat=paperformat) + self.assertIn("--paper", extra_args) + self.assertIn(paperformat.format, extra_args) + + def test_build_extra_args_specific_paperformat_args(self): + report = self.env["ir.actions.report"] + extra_args = report._build_paper_muncher_extra_args( + specific_paperformat_args={ + "data-report-landscape": True, + "data-report-dpi": 96, + } + ) + self.assertIn("--orientation", extra_args) + self.assertIn("landscape", extra_args) + self.assertIn("96dpi", extra_args) + + def test_build_extra_args_feature_flag(self): + report = self.env["ir.actions.report"] + with patch.dict(os.environ, {"ODOO_PAPER_MUNCHER_FEATURE": "1"}): + extra_args = report._build_paper_muncher_extra_args() + self.assertIn("--feature", extra_args) + self.assertIn("*=on", extra_args) + + def test_build_extra_args_portrait_orientation(self): + paperformat = self.env.ref("base.paperformat_euro") + report = self.env["ir.actions.report"] + extra_args = report._build_paper_muncher_extra_args(paperformat=paperformat) + self.assertIn("--orientation", extra_args) + self.assertIn("portrait", extra_args) diff --git a/base_report_paper_muncher/tests/test_report.py b/base_report_paper_muncher/tests/test_report.py new file mode 100644 index 00000000000..e91a2d15cee --- /dev/null +++ b/base_report_paper_muncher/tests/test_report.py @@ -0,0 +1,195 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import os +from contextlib import contextmanager +from unittest.mock import patch + +import odoo.tests +from odoo.tests.common import TEST_CURSOR_COOKIE_NAME + +from odoo.addons.base_report_paper_muncher.paper_muncher import ( + SERVE_TIMEOUT, + PaperMuncherInfo, + PaperMuncherServer, + paper_muncher, +) + + +@contextmanager +def release_test_lock(registry): + """Release the registry test lock while Paper Muncher serves HTTP requests.""" + test_lock = registry.test_lock + if not test_lock: + yield + return + test_lock.release() + try: + yield + finally: + test_lock.acquire() + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPaperMuncherReport(odoo.tests.HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.report = cls.env["ir.actions.report"].create( + { + "name": "Test Paper Muncher Report", + "model": "res.partner", + "report_name": "base_report_paper_muncher.test_report_partner", + "report_type": "qweb-pdf", + "paperformat_id": cls.env.ref("base.paperformat_euro").id, + } + ) + cls.env["ir.ui.view"].create( + { + "type": "qweb", + "name": "base_report_paper_muncher.test_report_partner", + "key": "base_report_paper_muncher.test_report_partner", + "arch": """ + + + +
+

Name:

+
+
+
+
+ """, + } + ) + cls.partners = cls.env["res.partner"].create( + [ + {"name": "PM Test Partner 1"}, + {"name": "PM Test Partner 2"}, + ] + ) + + def setUp(self): + super().setUp() + if paper_muncher().state != "ok": + return + + self_setup = self + old_serve = PaperMuncherServer.serve + + def patched_serve_paper_muncher( + self_server, documents, *, timeout=SERVE_TIMEOUT + ): + test_cookie = f"{TEST_CURSOR_COOKIE_NAME}=paper-muncher" + if "HTTP_COOKIE" in self_server._wsgi_environ: + self_server._wsgi_environ["HTTP_COOKIE"] += f", {test_cookie}" + else: + self_server._wsgi_environ["HTTP_COOKIE"] = test_cookie + + with ( + patch.object(self_setup, "http_request_key", "paper-muncher"), + release_test_lock(self_setup.registry), + ): + return old_serve(self_server, documents, timeout=timeout) + + self.startPatcher( + patch.object( + PaperMuncherServer, + "serve", + patched_serve_paper_muncher, + ) + ) + + def _require_paper_muncher_binary(self): + if paper_muncher().state != "ok": + self.skipTest("paper-muncher binary not found") + + def _render_pdf(self, partner_ids): + return ( + self.env["ir.actions.report"] + .with_context( + force_report_rendering=True, + ) + ._render_qweb_pdf(self.report, partner_ids)[0] + ) + + def test_render_single_document(self): + self._require_paper_muncher_binary() + pdf = self._render_pdf([self.partners[0].id]) + self.assertTrue( + pdf.startswith(b"%PDF-"), f"Expected a valid PDF got:\n{pdf[:200]}" + ) + + def test_render_multiple_documents(self): + self._require_paper_muncher_binary() + pdf = self._render_pdf(self.partners.ids) + self.assertTrue( + pdf.startswith(b"%PDF-"), f"Expected a valid PDF got:\n{pdf[:200]}" + ) + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPaperMuncherEngine(odoo.tests.TransactionCase): + def test_resolve_pdf_engine_wkhtmltopdf_forced(self): + self.env["ir.config_parameter"].sudo().set_param( + "report.pdf_engine", "wkhtmltopdf" + ) + resolution = self.env["ir.actions.report"]._resolve_pdf_engine() + self.assertEqual(resolution.engine, "wkhtmltopdf") + self.assertFalse(resolution.use_paper_muncher) + + def test_resolve_pdf_engine_auto_with_paper_muncher(self): + pm_info = PaperMuncherInfo("ok", "/usr/bin/paper-muncher", "0.3.1") + self.env["ir.config_parameter"].sudo().set_param("report.pdf_engine", "auto") + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.paper_muncher", + return_value=pm_info, + ): + resolution = self.env["ir.actions.report"]._resolve_pdf_engine() + self.assertEqual(resolution.engine, "auto") + self.assertTrue(resolution.use_paper_muncher) + + def test_get_wkhtmltopdf_state_with_paper_muncher(self): + pm_info = PaperMuncherInfo("ok", "/usr/bin/paper-muncher", "0.3.1") + self.env["ir.config_parameter"].sudo().set_param("report.pdf_engine", "auto") + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.paper_muncher", + return_value=pm_info, + ): + self.assertEqual( + self.env["ir.actions.report"].get_wkhtmltopdf_state(), "ok" + ) + + def test_fallback_wkhtmltopdf_when_forced(self): + self.env["ir.config_parameter"].sudo().set_param( + "report.pdf_engine", "wkhtmltopdf" + ) + report = self.env["ir.actions.report"] + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report.IrActionsReport._run_paper_muncher", + ) as mock_paper_muncher: + with patch( + "odoo.addons.base.models.ir_actions_report.IrActionsReport._run_wkhtmltopdf", + return_value=b"%PDF-fallback", + ) as mock_super: + result = report._run_wkhtmltopdf([""]) + mock_paper_muncher.assert_not_called() + mock_super.assert_called_once() + self.assertEqual(result, b"%PDF-fallback") + + def test_debug_flag_not_enabled_by_default(self): + report = self.env["ir.actions.report"] + with patch( + "odoo.addons.base_report_paper_muncher.models.ir_actions_report._paper_muncher_debug_enabled", + return_value=False, + ): + extra_args = report._build_paper_muncher_extra_args() + self.assertNotIn("--debug", extra_args) + + def test_debug_flag_enabled_with_env(self): + report = self.env["ir.actions.report"] + with patch.dict(os.environ, {"ODOO_PAPER_MUNCHER_DEBUG": "1"}): + extra_args = report._build_paper_muncher_extra_args() + self.assertIn("--debug", extra_args) + self.assertIn("http-client", extra_args) diff --git a/requirements.txt b/requirements.txt index 627736d364e..80ee458bf0f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # generated from manifests external_dependencies cryptography dataclasses +h11 mako odoo_test_helper odoorpc