diff --git a/misc/webservice.py b/misc/webservice.py index 2675a670..0e764f00 100644 --- a/misc/webservice.py +++ b/misc/webservice.py @@ -14,94 +14,227 @@ Ghostscript's license as well as OCRmyPDF's. from __future__ import annotations import os -import shlex -from subprocess import run -from tempfile import TemporaryDirectory +import subprocess +import sys +import time +from functools import partial +from operator import getitem +from pathlib import Path +from shutil import which +from tempfile import NamedTemporaryFile -from flask import Flask, Response, request, send_from_directory -from werkzeug.utils import secure_filename +import pikepdf +import streamlit as st +from port_for import get_port +from streamlit.components.v1 import iframe -app = Flask(__name__) -app.secret_key = "secret" -app.config['MAX_CONTENT_LENGTH'] = 50_000_000 -app.config.from_envvar("OCRMYPDF_WEBSERVICE_SETTINGS", silent=True) +from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD -ALLOWED_EXTENSIONS = {"pdf"} +st.title("OCRmyPDF Web Service") + +if not which("ttyd"): + st.error("Missing dependency: ttyd. Please install ttyd to the local environment.") + sys.exit(1) + +uploaded = st.file_uploader("Upload input PDF or image", type=["pdf"], key="file") + +mode = st.selectbox("Mode", options=["normal", "skip-text", "force-ocr", "redo-ocr"]) + +with st.expander("Input options"): + invalidate_digital_signatures = st.checkbox( + "Invalidate digital signatures", value=False + ) + language = st.selectbox("Language", options=["eng", "deu", "fra", "spa"]) + + image_dpi = st.slider( + "Image DPI", value=300, key="image_dpi", min_value=1, max_value=5000, step=50 + ) +with st.expander("Preprocessing"): + skip_big = st.checkbox("Skip OCR on big pages", value=False, key="skip_big") + oversample = st.slider("Oversample", min_value=0, max_value=5000, value=0, step=50) + rotate_pages = st.checkbox("Rotate pages", value=False, key="rotate") + deskew = st.checkbox("Deskew pages", value=False, key="deskew") + clean = st.checkbox("Clean pages before OCR", value=False, key="clean") + clean_final = st.checkbox("Clean final", value=False, key="clean_final") + remove_vectors = st.checkbox("Remove vectors", value=False, key="remove_vectors") -def allowed_file(filename): - return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS +with st.expander("Output options"): + output_type = st.selectbox( + "Output type", options=["pdfa", "pdfa", "pdfa-1", "pdfa-2", "pdfa-3", "none"] + ) + + pdf_renderer = st.selectbox( + "PDF rendereer", options=["auto", "hocr", "hocrdebug", "sandwich"] + ) + + optimize = st.selectbox("Optimize", options=["0", "1", "2", "3"]) + + st.selectbox("PDF/A compression", options=["auto", "jpeg", "lossless"]) + +with st.expander("Metadata"): + title = author = keywords = subject = None + if uploaded: + with pikepdf.open(uploaded) as pdf, pdf.open_metadata() as meta: + st.code(str(meta), language="xml") + title = st.text_input("Title", value=meta.get('dc:title', '')) + author = st.text_input("Author", value=meta.get('dc:creator', '')) + keywords = st.text_input("Keywords", value=meta.get('dc:subject', '')) + subject = st.text_input("Subject", value=meta.get('dc:description', '')) -def do_ocrmypdf(file): - uploaddir = TemporaryDirectory(prefix="ocrmypdf-upload") - downloaddir = TemporaryDirectory(prefix="ocrmypdf-download") +with st.expander("Optimization after OCR"): + jpeg_quality = st.slider( + "JPEG quality", min_value=0, max_value=100, value=75, key="jpeg_quality" + ) + png_quality = st.slider( + "PNG quality", min_value=0, max_value=100, value=75, key="png_quality" + ) + jbig2_lossy = st.checkbox("JBIG2 lossy (dangerous)", value=False, key="jbig2_lossy") + jbig2_threshold = st.number_input("JBIG2 threshold", value=0, key="jbig2_threshold") - filename = secure_filename(file.filename) - up_file = os.path.join(uploaddir.name, filename) - file.save(up_file) +with st.expander("Advanced options"): + jobs = st.slider( + "Threads", + min_value=1, + max_value=os.cpu_count(), + value=os.cpu_count(), + key="threads", + ) + pages = st.text_input( + "Pages", value="", help="Comma-separated list of pages to process" + ) + max_image_mpixels = st.number_input( + "Max image size", + value=250.0, + min_value=0.0, + help="Maximum image size in megapixels", + ) + rotate_pages_threshold = st.number_input( + "Rotate pages threshold", + value=DEFAULT_ROTATE_PAGES_THRESHOLD, + min_value=0.0, + max_value=1000.0, + help="Threshold for automatic page rotation", + ) + fast_web_view = st.number_input( + "Fast web view", + value=1.0, + min_value=0.0, + help="Linearize files above this size in MB", + ) + continue_on_soft_render_error = st.checkbox( + "Continue on soft render error", value=True + ) + verbose_labels = ["quiet", "default", "debug", "debug_all"] + verbose = st.selectbox( + "Verbosity level", + options=[-1, 0, 1, 2], + index=1, + format_func=partial(getitem, verbose_labels), + ) - down_file = os.path.join(downloaddir.name, filename) +if uploaded: + args = [] + if mode and mode != 'normal': + args.append(f"--{mode}") + if language: + args.append(f"--language={language}") + if not uploaded.name.lower().endswith(".pdf") and image_dpi: + args.append(f"--image-dpi={image_dpi}") + if skip_big: + args.append("--skip-big") + if oversample: + args.append(f"--oversample={oversample}") + if rotate_pages: + args.append("--rotate-pages") + if deskew: + args.append("--deskew") + if clean: + args.append("--clean") + if clean_final: + args.append("--clean-final") + if remove_vectors: + args.append("--remove-vectors") + if output_type: + args.append(f"--output-type={output_type}") + if pdf_renderer: + args.append(f"--pdf-renderer={pdf_renderer}") + if optimize: + args.append(f"--optimize={optimize}") + if title: + args.append(f"--title={title}") + if author: + args.append(f"--author={author}") + if keywords: + args.append(f"--keywords={keywords}") + if subject: + args.append(f"--subject={subject}") + if pages: + args.append(f"--pages={pages}") + if max_image_mpixels: + args.append(f"--max-image-mpixels={max_image_mpixels}") + if rotate_pages_threshold: + args.append(f"--rotate-pages-threshold={rotate_pages_threshold}") + if fast_web_view: + args.append(f"--fast-web-view={fast_web_view}") + if continue_on_soft_render_error: + args.append("--continue-on-soft-render-error") + if verbose: + args.append(f"--verbose={verbose}") + if optimize > '0' and jpeg_quality: + args.append(f"--jpeg-quality={jpeg_quality}") + if optimize > '0' and png_quality: + args.append(f"--png-quality={png_quality}") + if jbig2_lossy: + args.append("--jbig2-lossy") + if jbig2_threshold: + args.append(f"--jbig2-threshold={jbig2_threshold}") + if jobs: + args.append(f"--jobs={jobs}") + input_file = NamedTemporaryFile(delete=True, suffix=f"_{uploaded.name}") + input_file.write(uploaded.getvalue()) + input_file.flush() + input_file.seek(0) + args.append(str(input_file.name)) + output_file = NamedTemporaryFile(delete=True, suffix=".pdf") + args.append(str(output_file.name)) - cmd_args = [arg for arg in shlex.split(request.form["params"])] - if "--sidecar" in cmd_args: - return Response("--sidecar not supported", 501, mimetype='text/plain') + st.session_state['running'] = ( + 'run_button' in st.session_state and st.session_state.run_button + ) + if st.button( + "Run OCRmyPDF", + disabled=st.session_state.get("running", False), + key='run_button', + ): + st.session_state['running'] = True + args = [sys.executable, '-m', "ocrmypdf"] + args + cmdline = " ".join(args) + st.code(cmdline, language="bash") - ocrmypdf_args = ["ocrmypdf", *cmd_args, up_file, down_file] - proc = run(ocrmypdf_args, capture_output=True, encoding="utf-8", check=False) - if proc.returncode != 0: - stderr = proc.stderr - return Response(stderr, 400, mimetype='text/plain') + port = get_port((5000, 7000)) + ttyd_args = ['ttyd', '--port', str(port), '--once', '--readonly'] - return send_from_directory(downloaddir.name, filename) + ttyd_proc = subprocess.Popen( + ttyd_args + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + iframe(src=f"http://localhost:{port}", height=400) + while ttyd_proc.poll() is None: + ttyd_proc.poll() + time.sleep(1) -@app.route("/", methods=["GET", "POST"]) -def upload_file(): - if request.method == "POST": - if "file" not in request.files: - return Response("No file in POST", 400, mimetype='text/plain') - file = request.files["file"] - if file.filename == "": - return Response("Empty filename", 400, mimetype='text/plain') - if not allowed_file(file.filename): - return Response("Invalid filename", 400, mimetype='text/plain') - if file and allowed_file(file.filename): - return do_ocrmypdf(file) - return Response("Some other problem", 400, mimetype='text/plain') - - return """ - - OCRmyPDF webservice -

Upload a PDF (debug UI)

-
- - - - - -
-

Notice

-
-

This is a webservice wrapper for OCRmyPDF.

-

Copyright 2019 James R. Barlow

-

This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. -

-

This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. -

-

- You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -

-
- """ - - -if __name__ == "__main__": - app.run(host='0.0.0.0', port=5000) + if ttyd_proc.returncode == 0: + if Path(output_file.name).stat().st_size == 0: + st.error("No output PDF file was generated") + else: + st.download_button( + label="Download input PDF", + data=input_file.read(), + file_name=uploaded.name, + mime="application/pdf", + ) + else: + st.error(f"ttyd failed with exit code {ttyd_proc.returncode}") + st.session_state['running'] = False diff --git a/pyproject.toml b/pyproject.toml index d3d3baca..1c530bdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,10 @@ test = [ "types-humanfriendly", ] watcher = ["watchdog>=1.0.2", "typer-slim[standard]", "python-dotenv"] -webservice = ["Flask>=2.0.1"] +webservice = [ + "port-for>=0.7.4", + "streamlit>=1.41.0", +] [project.scripts] ocrmypdf = "ocrmypdf.__main__:run" diff --git a/uv.lock b/uv.lock index 846b34d5..89f69961 100644 --- a/uv.lock +++ b/uv.lock @@ -311,7 +311,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 }, { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 }, { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 }, - { url = "https://files.pythonhosted.org/packages/4e/d5/9cc182bf24c86f542129565976c21301d4ac397e74bf5a16e48241aab8a6/cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385", size = 4164756 }, { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 }, { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 }, { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 }, @@ -322,7 +321,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 }, { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 }, { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 }, - { url = "https://files.pythonhosted.org/packages/31/d9/90409720277f88eb3ab72f9a32bfa54acdd97e94225df699e7713e850bd4/cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba", size = 4165207 }, { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 }, { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 }, { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 }, @@ -386,22 +384,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, ] -[[package]] -name = "flask" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 }, -] - [[package]] name = "gitdb" version = "4.0.11" @@ -477,15 +459,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] -[[package]] -name = "itsdangerous" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, -] - [[package]] name = "jinja2" version = "3.1.4" @@ -802,7 +775,7 @@ wheels = [ [[package]] name = "ocrmypdf" -version = "16.6.3.dev16+g15df9c37.d20241208" +version = "16.7.1.dev2+gf71a5ffd.d20250101" source = { editable = "." } dependencies = [ { name = "deprecation" }, @@ -842,7 +815,8 @@ watcher = [ { name = "watchdog" }, ] webservice = [ - { name = "flask" }, + { name = "port-for" }, + { name = "streamlit" }, ] [package.dev-dependencies] @@ -857,7 +831,6 @@ dev = [ requires-dist = [ { name = "coverage", extras = ["toml"], marker = "extra == 'test'", specifier = ">=6.2" }, { name = "deprecation", specifier = ">=2.1.0" }, - { name = "flask", marker = "extra == 'webservice'", specifier = ">=2.0.1" }, { name = "hypothesis", marker = "extra == 'test'", specifier = ">=6.36.0" }, { name = "img2pdf", specifier = ">=0.5" }, { name = "packaging", specifier = ">=20" }, @@ -866,6 +839,7 @@ requires-dist = [ { name = "pikepdf", specifier = ">=8.10.1" }, { name = "pillow", specifier = ">=10.0.1" }, { name = "pluggy", specifier = ">=1" }, + { name = "port-for", marker = "extra == 'webservice'", specifier = ">=0.7.4" }, { name = "pymupdf", marker = "extra == 'extended-test'", specifier = ">=1.19.1" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=6.2.5" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=3.0.0" }, @@ -877,6 +851,7 @@ requires-dist = [ { name = "sphinx", marker = "extra == 'docs'" }, { name = "sphinx-issues", marker = "extra == 'docs'" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'" }, + { name = "streamlit", marker = "extra == 'webservice'", specifier = ">=1.41.0" }, { name = "typer-slim", extras = ["standard"], marker = "extra == 'watcher'" }, { name = "types-humanfriendly", marker = "extra == 'test'" }, { name = "types-pillow", marker = "extra == 'test'" }, @@ -1127,6 +1102,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "port-for" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/84/ad5114c85217426d7a5170a74a6f9d6b724df117c2f3b75e41fc9d6c6811/port_for-0.7.4.tar.gz", hash = "sha256:fc7713e7b22f89442f335ce12536653656e8f35146739eccaeff43d28436028d", size = 25077 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/a2/579dcefbb0285b31f8d65b537f8a9932ed51319e0a3694e01b5bbc271f92/port_for-0.7.4-py3-none-any.whl", hash = "sha256:08404aa072651a53dcefe8d7a598ee8a1dca320d9ac44ac464da16ccf2a02c4a", size = 21369 }, +] + [[package]] name = "protobuf" version = "5.29.1" @@ -1621,7 +1605,7 @@ wheels = [ [[package]] name = "streamlit" -version = "1.40.2" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "altair" }, @@ -1644,9 +1628,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "watchdog", marker = "platform_system != 'Darwin'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/e5/2bf2daa9c98658f1474bb64e7de030cbc4182b5f2b2196536efedaef02cb/streamlit-1.40.2.tar.gz", hash = "sha256:0cc131fc9b18065feaff8f6f241c81164ad37d8d9e3a85499a0240aaaf6a6a61", size = 8265763 } +sdist = { url = "https://files.pythonhosted.org/packages/78/33/14b5ac0369ecf0af675911e5e84b934e6fcc2cec850857d2390eb373b0a6/streamlit-1.41.1.tar.gz", hash = "sha256:6626d32b098ba1458b71eebdd634c62af2dd876380e59c4b6a1e828a39d62d69", size = 8712473 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/53/418536f5d0b87bfbe7bbd8c001983c27e9474f82723bd2e529660fd9a534/streamlit-1.40.2-py2.py3-none-any.whl", hash = "sha256:7f6d1379a590f9625a6aee79ca73ceccff03cd2e05a3acbe5fe98915c27a7ffe", size = 8644775 }, + { url = "https://files.pythonhosted.org/packages/c2/87/b2e162869500062a94dde7589c167367b5538dab6eacce2e7c0f00d5c9c5/streamlit-1.41.1-py2.py3-none-any.whl", hash = "sha256:0def00822480071d642e6df36cd63c089f991da3a69fd9eb4ab8f65ce27de4e0", size = 9100386 }, ] [[package]] @@ -1832,18 +1816,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, ] -[[package]] -name = "werkzeug" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, -] - [[package]] name = "wrapt" version = "1.17.0"