diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1e3a698 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release new version +on: + push: + tags: + - v* + +jobs: + pypi-publish: + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/weasyprint + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - name: Install requirements + run: python -m pip install flit + - name: Build packages + run: flit build + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + add-version: + name: Add version to GitHub + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install requirements + run: sudo apt-get install pandoc + - name: Generate content + run: | + pandoc docs/changelog.rst -f rst -t gfm | csplit - /##/ "{1}" -f .part + sed -r "s/^([A-Z].*)\:\$/## \1/" .part01 | sed -r "s/^ *//" | sed -rz "s/([^\n])\n([^\n^-])/\1 \2/g" | tail -n +5 > .body + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + body_path: .body diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 225d162..fa59897 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,12 +9,12 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.13'] + python-version: ['3.14'] include: - os: ubuntu-latest - python-version: '3.9' + python-version: '3.11' - os: ubuntu-latest - python-version: 'pypy-3.9' + python-version: 'pypy-3.11' steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -41,6 +41,4 @@ jobs: env: DYLD_FALLBACK_LIBRARY_PATH: /opt/homebrew/lib - name: Check coding style - run: python -m flake8 - - name: Check imports order - run: python -m isort . --check --diff + run: python -m ruff check diff --git a/README.rst b/README.rst index 3873ff4..6fc6157 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ Flask-WeasyPrint generates PDF files out of your Flask website thanks to WeasyPrint. * Free software: BSD license -* For Python 3.9+, tested on CPython and PyPy +* For Python 3.11+, tested on CPython and PyPy * Documentation: https://doc.courtbouillon.org/flask-weasyprint * Changelog: https://github.com/Kozea/Flask-WeasyPrint/releases * Code, issues, tests: https://github.com/Kozea/Flask-WeasyPrint diff --git a/flask_weasyprint/__init__.py b/flask_weasyprint/__init__.py index 57030ec..d46093a 100644 --- a/flask_weasyprint/__init__.py +++ b/flask_weasyprint/__init__.py @@ -2,9 +2,10 @@ from io import BytesIO from urllib.parse import urljoin, urlsplit +from urllib.request import BaseHandler from flask import current_app, has_request_context, request, send_file -from werkzeug.test import Client, ClientRedirectError, EnvironBuilder +from werkzeug.test import Client, EnvironBuilder from werkzeug.wrappers import Response VERSION = __version__ = '1.1.0' @@ -73,7 +74,7 @@ def dispatch(url_string): def make_url_fetcher(dispatcher=None, next_fetcher=True): - """Return an function suitable as a ``url_fetcher`` in WeasyPrint. + """Return a URL fetcher that handles the Flask app routes internally. You generally don’t need to call this directly. @@ -92,45 +93,44 @@ def make_url_fetcher(dispatcher=None, next_fetcher=True): Typically ``base_url + path`` is equivalent to the passed URL. """ + from weasyprint.urls import URLFetcher, URLFetcherResponse # lazy loading + if next_fetcher is True: - from weasyprint import default_url_fetcher # lazy loading - next_fetcher = default_url_fetcher + next_fetcher = URLFetcher if dispatcher is None: dispatcher = make_flask_url_dispatcher() - def flask_url_fetcher(url): - redirect_chain = set() - while True: - result = dispatcher(url) - if result is None: - return next_fetcher(url) - app, base_url, path = result - client = Client(app, response_wrapper=Response) - if has_request_context() and request.cookies: - server_name = EnvironBuilder( - path, base_url=base_url).server_name - for cookie_key, cookie_value in request.cookies.items(): - client.set_cookie( - cookie_key, cookie_value, domain=server_name) - response = client.get(path, base_url=base_url) - if response.status_code == 200: - return { - 'string': response.data, 'mime_type': response.mimetype, - 'encoding': 'utf-8', 'redirected_url': url} - # The test client can follow redirects, but do it ourselves - # to get access to the redirected URL. - elif response.status_code in (301, 302, 303, 305, 307, 308): - redirect_chain.add(url) - url = urljoin(url, response.location) - if url in redirect_chain: - raise ClientRedirectError('loop detected') - else: - raise ValueError( - 'Flask-WeasyPrint got HTTP status ' - f'{response.status} for {urljoin(base_url, path)}') - - return flask_url_fetcher + class FlaskHandler(BaseHandler): + def default_open(self, req): + url = req.full_url + if result := dispatcher(url): + app, base_url, path = result + client = Client(app, response_wrapper=Response) + if has_request_context() and request.cookies: + server_name = EnvironBuilder(path, base_url=base_url).server_name + for cookie_key, cookie_value in request.cookies.items(): + client.set_cookie(cookie_key, cookie_value, domain=server_name) + response = client.get(path, base_url=base_url) + response = URLFetcherResponse( + url, response.data, response.headers, response.status_code) + response.msg = '' + return response + + class FlaskFetcher(next_fetcher or URLFetcher): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.add_handler(FlaskHandler()) + + def fetch(self, url, headers=None): + if dispatcher(url) is None: + if next_fetcher: + return super().fetch(url, headers) + else: + raise ValueError(f'Unknown Flask app URL: {url}') + return URLFetcher.fetch(self, url, headers) + + return FlaskFetcher() def _wrapper(class_, *args, **kwargs): @@ -148,7 +148,7 @@ def _wrapper(class_, *args, **kwargs): return class_(guess, *args, **kwargs) -def HTML(*args, **kwargs): +def HTML(*args, **kwargs): # noqa: N802 """Like :class:`weasyprint.HTML` but: * :func:`make_url_fetcher` is used to create an ``url_fetcher`` @@ -165,7 +165,7 @@ def HTML(*args, **kwargs): return _wrapper(HTML, *args, **kwargs) -def CSS(*args, **kwargs): +def CSS(*args, **kwargs): # noqa: N802 from weasyprint import CSS # lazy loading return _wrapper(CSS, *args, **kwargs) @@ -200,5 +200,5 @@ def render_pdf(html, stylesheets=None, download_filename=None, pdf = html.write_pdf(stylesheets=stylesheets, **options) as_attachment = automatic_download if download_filename else False return send_file( - BytesIO(pdf), mimetype="application/pdf", as_attachment=as_attachment, + BytesIO(pdf), mimetype='application/pdf', as_attachment=as_attachment, download_name=download_filename) diff --git a/pyproject.toml b/pyproject.toml index 565d16f..954b5a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,12 +8,12 @@ description = 'Make PDF in your Flask app with WeasyPrint' keywords = ['html', 'css', 'pdf', 'converter', 'flask', 'weasyprint'] authors = [{name = 'Simon Sapin', email = 'simon.sapin@exyr.org'}] maintainers = [{name = 'CourtBouillon', email = 'contact@courtbouillon.org'}] -requires-python = '>=3.9' +requires-python = '>=3.11' readme = {file = 'README.rst', content-type = 'text/x-rst'} license = {file = 'LICENSE'} dependencies = [ 'flask >=2.3.0', - 'weasyprint >=53.0', + 'weasyprint >=68.0', ] classifiers = [ 'Development Status :: 5 - Production/Stable', @@ -24,11 +24,10 @@ classifiers = [ 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', @@ -46,7 +45,7 @@ Donation = 'https://opencollective.com/courtbouillon' [project.optional-dependencies] doc = ['sphinx', 'sphinx_rtd_theme'] -test = ['pytest', 'isort', 'flake8'] +test = ['pytest', 'ruff'] [tool.flit.sdist] exclude = ['.*'] @@ -59,6 +58,10 @@ include = ['tests/*', 'flask_weasyprint/*'] exclude_lines = ['pragma: no cover', 'def __repr__', 'raise NotImplementedError'] omit = ['.*'] -[tool.isort] -default_section = 'FIRSTPARTY' -multi_line_output = 4 +[tool.ruff.lint] +select = ['E', 'W', 'F', 'I', 'N', 'RUF', 'T20', 'PIE', 'PT', 'RSE', 'UP', 'Q'] +ignore = ['RUF001', 'RUF002', 'RUF003'] + +[tool.ruff.lint.flake8-quotes] +inline-quotes = 'single' +multiline-quotes = 'single' \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index 2999b96..1ff9b86 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -14,7 +14,7 @@ @app.config.from_object class Config: - GRAPH_COLORS = ['#0C3795', '#752641', '#E47F00'] + GRAPH_COLORS = ('#0C3795', '#752641', '#E47F00') @app.route('/') @@ -115,8 +115,8 @@ def static(filename): abort(404) -@app.route(u'/Unïĉodé/') -@app.route(u'/foo bar/') +@app.route('/Unïĉodé/') +@app.route('/foo bar/') def funky_urls(stuff): return stuff diff --git a/tests/test_flask_weasyprint.py b/tests/test_flask_weasyprint.py index b219768..9a9357c 100644 --- a/tests/test_flask_weasyprint.py +++ b/tests/test_flask_weasyprint.py @@ -1,10 +1,13 @@ """Tests for Flask-WeasyPrint.""" +from urllib.error import HTTPError + import pytest from flask import Flask, json, jsonify, redirect, request -from flask_weasyprint import CSS, HTML, make_url_fetcher, render_pdf from weasyprint import __version__ as weasyprint_version -from werkzeug.test import ClientRedirectError +from weasyprint.urls import URLFetcher, URLFetcherResponse + +from flask_weasyprint import CSS, HTML, make_url_fetcher, render_pdf from . import app, document_html @@ -18,15 +21,15 @@ def test_url_fetcher(): with app.test_request_context(base_url='http://example.org/bar/'): fetcher = make_url_fetcher() - result = fetcher('http://example.org/bar/') - assert result['string'].strip().startswith(b'') - assert result['mime_type'] == 'text/html' - assert result['encoding'] == 'utf-8' - assert result['redirected_url'] == 'http://example.org/bar/foo/' + response = fetcher('http://example.org/bar/') + assert response.read().strip().startswith(b'') + assert response.content_type == 'text/html' + assert response.charset == 'utf-8' + assert response.url == 'http://example.org/bar/foo/' - result = fetcher('http://example.org/bar/foo/graph?data=1&labels=A') - assert result['string'].strip().startswith(b' 68.1, see #2686. + # with pytest.raises(HTTPError, match='infinite loop'): + with pytest.raises((HTTPError, RecursionError)): fetcher('http://localhost/1') - with pytest.raises(ValueError): + with pytest.raises(HTTPError, match='404'): fetcher('http://localhost/nonexistent') @@ -124,21 +129,22 @@ def catchall(subdomain='', path=None): app = [subdomain, request.script_root, request.path, query_string] return jsonify(app=app) - def dummy_fetcher(url): - return {'string': 'dummy ' + url} + class DummyFetcher(URLFetcher): + def fetch(self, url, headers=None): + return URLFetcherResponse(url, f'dummy {url}') def assert_app(url, host, script_root, path, query_string=''): """The URL was dispatched to the app with these parameters.""" - assert json.loads(dispatcher(url)['string']) == { + assert json.loads(dispatcher(url).read()) == { 'app': [host, script_root, path, query_string]} def assert_dummy(url): """The URL was not dispatched, the default fetcher was used.""" - assert dispatcher(url)['string'] == 'dummy ' + url + assert dispatcher(url).read() == f'dummy {url}'.encode() # No SERVER_NAME config, default port with app.test_request_context(base_url='http://a.net/b/'): - dispatcher = make_url_fetcher(next_fetcher=dummy_fetcher) + dispatcher = make_url_fetcher(next_fetcher=DummyFetcher) assert_app('http://a.net/b', '', '/b', '/') assert_app('http://a.net/b/', '', '/b', '/') assert_app('http://a.net/b/', '', '/b', '/') @@ -152,7 +158,7 @@ def assert_dummy(url): # No SERVER_NAME config, explicit default port with app.test_request_context(base_url='http://a.net:80/b/'): - dispatcher = make_url_fetcher(next_fetcher=dummy_fetcher) + dispatcher = make_url_fetcher(next_fetcher=DummyFetcher) assert_app('http://a.net/b', '', '/b', '/') assert_app('http://a.net/b/', '', '/b', '/') assert_app('http://a.net/b/c/d?e', '', '/b', '/c/d', 'e') @@ -165,7 +171,7 @@ def assert_dummy(url): # Change the context’s port number with app.test_request_context(base_url='http://a.net:8888/b/'): - dispatcher = make_url_fetcher(next_fetcher=dummy_fetcher) + dispatcher = make_url_fetcher(next_fetcher=DummyFetcher) assert_app('http://a.net:8888/b', '', '/b', '/') assert_app('http://a.net:8888/b/', '', '/b', '/') assert_app('http://a.net:8888/b/cd?e', '', '/b', '/cd', 'e') @@ -180,7 +186,7 @@ def assert_dummy(url): # Add a SERVER_NAME config app.config['SERVER_NAME'] = 'a.net' with app.test_request_context(): - dispatcher = make_url_fetcher(next_fetcher=dummy_fetcher) + dispatcher = make_url_fetcher(next_fetcher=DummyFetcher) assert_app('http://a.net', '', '', '/') assert_app('http://a.net/', '', '', '/') assert_app('http://a.net/b/c/d?e', '', '', '/b/c/d', 'e') @@ -194,7 +200,7 @@ def assert_dummy(url): # SERVER_NAME with a port number app.config['SERVER_NAME'] = 'a.net:8888' with app.test_request_context(): - dispatcher = make_url_fetcher(next_fetcher=dummy_fetcher) + dispatcher = make_url_fetcher(next_fetcher=DummyFetcher) assert_app('http://a.net:8888', '', '', '/') assert_app('http://a.net:8888/', '', '', '/') assert_app('http://a.net:8888/b/c/d?e', '', '', '/b/c/d', 'e') @@ -205,13 +211,11 @@ def assert_dummy(url): assert_dummy('http://a.net/b/') -@pytest.mark.parametrize('url', ( +@pytest.mark.parametrize('url', [ 'http://example.net/Unïĉodé/pass !', - 'http://example.net/Unïĉodé/pass !'.encode(), 'http://example.net/foo%20bar/p%61ss%C2%A0!', - b'http://example.net/foo%20bar/p%61ss%C2%A0!', -)) +]) def test_funky_urls(url): with app.test_request_context(base_url='http://example.net/'): fetcher = make_url_fetcher() - assert fetcher(url)['string'] == 'pass !'.encode() + assert fetcher(url).read() == 'pass !'.encode()