From 2bb1fdc1f7f1ce5ead9605b7a87764fed34f223f Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 29 Sep 2025 18:01:34 -0500 Subject: [PATCH 01/51] Initial project made by Codex - Uses uv - Some project settings brought over from pdfassistant-chatbot by Codex. Assisted-by: Codex --- .editorconfig | 28 ++ .gitattributes | 108 +++++ .gitignore | 453 +++++++++++++++++ .pre-commit-config.yaml | 91 ++++ .python-version | 1 + README.md | 33 +- pyproject.toml | 102 ++++ src/pdfrest/__init__.py | 10 + src/pdfrest/py.typed | 0 tests/__init__.py | 0 uv.lock | 1025 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 1849 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version create mode 100644 pyproject.toml create mode 100644 src/pdfrest/__init__.py create mode 100644 src/pdfrest/py.typed create mode 100644 tests/__init__.py create mode 100644 uv.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..b780dfd7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +root = true + + +[*] +charset = utf-8 +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.md] +indent_size = 2 +max_line_length = 80 + +[{*.py,*.ipynb}] +indent_size = 4 +max_line_length = 88 + +[*.sh] +# like -i=4 +indent_style = space +indent_size = 4 + +shell_variant = bash # --language-variant +binary_next_line = true +switch_case_indent = true # --case-indent +space_redirects = true +keep_padding = false +function_next_line = true # --func-next-line diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..adc0809a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,108 @@ +# Common settings that generally should always be used with your language specific settings + +# Auto detect text files and perform LF normalization +# https://www.davidlaing.com/2012/09/19/customise-your-gitattributes-to-become-a-git-ninja/ +* text=auto + +# +# The above will handle all files NOT found below +# + +# Documents +*.bibtex text diff=bibtex +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain +*.md text +*.tex text diff=tex +*.adoc text +*.textile text +*.mustache text +*.csv text +*.tab text +*.tsv text +*.txt text +*.sql text + +# Graphics +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.tif binary +*.tiff binary +*.ico binary +# SVG treated as an asset (binary) by default. +*.svg text +# If you want to treat it as binary, +# use the following line instead. +# *.svg binary +*.eps binary + +# Scripts +*.bash text eol=lf +*.fish text eol=lf +*.sh text eol=lf +# These are explicitly windows files and should use crlf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Serialisation +*.json text +*.toml text +*.xml text +*.yaml text +*.yml text + +# Archives +*.7z binary +*.gz binary +*.tar binary +*.tgz binary +*.zip binary + +# Text files where line endings should be preserved +*.patch -text + +# +# Exclude files from exporting +# + +.gitattributes export-ignore +.gitignore export-ignore +# Basic .gitattributes for a python repo. + +# Source files +# ============ +*.pxd text diff=python +*.py text diff=python +*.py3 text diff=python +*.pyw text diff=python +*.pyx text diff=python +*.pyz text diff=python + +# Binary files +# ============ +*.db binary +*.p binary +*.pkl binary +*.pickle binary +*.pyc binary +*.pyd binary +*.pyo binary + +# Jupyter notebook +*.ipynb text + +# Note: .db, .p, and .pkl files are associated +# with the python modules ``pickle``, ``dbm.*``, +# ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` +# (among others). diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ad36eefe --- /dev/null +++ b/.gitignore @@ -0,0 +1,453 @@ +# Created by https://www.toptal.com/developers/gitignore/api/vim,emacs,linux,macos,python,pycharm,windows,git +# Edit at https://www.toptal.com/developers/gitignore?templates=vim,emacs,linux,macos,python,pycharm,windows,git + +### Emacs ### +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + +# network security +/network-security.data + + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### Linux ### + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# in version control. + +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/vim,emacs,linux,macos,python,pycharm,windows,git + +# Files for building Docker containers that don't have to get checked in +/installers/ + +# Database files +*.db +*.sqlite* + +# PDM + +# Profiling +*.prof.txt +*.speedscope.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..fc9f175f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,91 @@ +# For info on multiline regular expressions: https://pre-commit.com/index.html#regular-expressions +# Verbose regular expressions '(?x)': https://docs.python.org/3.9/library/re.html#re.X +exclude: | + (?x)^( + .idea/| + .venv/ + ) +default_install_hook_types: [pre-commit, pre-merge-commit, pre-push] +default_stages: [pre-commit, pre-merge-commit, pre-push, manual] +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-toml + - id: check-yaml + - id: check-added-large-files + args: [--maxkb=4000] + - repo: https://github.com/JoC0de/pre-commit-prettier + rev: v3.4.2 + hooks: + - id: prettier + exclude: .md$ + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.31.0 + hooks: + - id: check-github-workflows + - repo: https://github.com/editorconfig-checker/editorconfig-checker.python + rev: 3.0.3 + hooks: + - id: editorconfig-checker + - repo: https://github.com/executablebooks/mdformat + rev: 0.7.21 + hooks: + - id: mdformat + name: mdformat on non-.github files + exclude: ^.github/ + args: ["--wrap", "80", "--number"] + additional_dependencies: + - mdformat-gfm + - mdformat-frontmatter + - mdformat-footnote + - mdformat-toc + - id: mdformat + name: mdformat on .github files + files: ^.github/.*$ + args: ["--wrap", "no", "--number"] + additional_dependencies: + - mdformat-gfm + - mdformat-frontmatter + - mdformat-footnote + - mdformat-toc + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + args: [-x] + - repo: https://github.com/maxwinterstein/shfmt-py + rev: v3.7.0.1 + hooks: + - id: shfmt + - repo: https://github.com/pycqa/isort + rev: 6.1.0 + hooks: + - id: isort + args: ["--profile", "black", "--filter-files"] + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 24.10.0 + hooks: + - id: black-jupyter + language_version: python3.11 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.1 + hooks: + - id: ruff + types_or: [python, pyi, jupyter] + - repo: https://github.com/pappasam/toml-sort + rev: v0.24.2 + hooks: + - id: toml-sort-fix + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.18.2" + hooks: + - id: mypy + stages: [pre-commit, pre-merge-commit, pre-push, manual] + name: mypy + additional_dependencies: + # Need pydantic to load the pydantic.mypy plugin + - pydantic>=2.12.0 diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..2c073331 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/README.md b/README.md index e3213b7d..2dad8fa5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ -# pdfrest-python -Python API library for pdfRest +# pdfrest + +Python client library for the PDFRest service. The project is managed with +[uv](https://docs.astral.sh/uv/) and targets Python 3.9 and newer. + +## Getting started + +```bash +uv sync +uv run python -c "import pdfrest; print(pdfrest.__version__)" +``` + +## Development + +To install the tooling used by CI locally, include the `--group dev` flag: + +```bash +uv sync --group dev +``` + +It is recommended to enable the pre-commit hooks after installation: + +```bash +uv run pre-commit install +``` + +Run the test suite with: + +```bash +uv run pytest +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..2e158db3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,102 @@ +[project] +name = "pdfrest" +version = "0.1.0" +description = "Python client library for interacting with the PDFRest API" +readme = "README.md" +authors = [ + {name = "Datalogics"}, +] +requires-python = ">=3.9" +dependencies = [] + +[build-system] +requires = ["uv_build>=0.8.22,<0.9.0"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "pre-commit>=3.7.0", + "black>=24.8.0", + "isort>=5.13.2", + "ruff>=0.6.9", + "pytest>=8.3.3", + "pytest-cov>=5.0.0", + "mypy>=1.11.2", + "pip-audit>=2.7.3", +] + +[tool.isort] +combine_as_imports = true +split_on_trailing_comma = true +profile = "black" +known_first_party = ["pdfrest"] + +[tool.mypy] +python_version = "3.9" +warn_return_any = true +pretty = true +exclude = [ + '^\\.venv', + '^build', + '^dist', +] +files = "." +plugins = [ + "pydantic.mypy", +] +# Discover modules that were installed in the virtualenv +python_executable = ".venv/bin/python" +fixed_format_cache = true + +[tool.pytest.ini_options] +minversion = "7.4" +testpaths = ["tests"] +addopts = "-ra" + +[tool.ruff] +extend-include = ["*.ipynb"] +target-version = "py39" + +[tool.ruff.lint] +extend-select = [ + "I", + "B", + "C4", + "PGH", + "RUF", + "W", + "YTT", + "UP", + "N", + "PT", + "C90", + "FURB", + "S", + "BLE", + "EM", + "G", + "RET", + "DTZ", + "SIM", + "ARG", + "PTH", + "RSE", + "TRY", + "COM818", +] +fixable = ["ALL"] +ignore = [] +unfixable = [] + +[tool.ruff.lint.isort] +combine-as-imports = true +known-first-party = ["pdfrest"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "ARG", "FBT"] + +[tool.tomlsort] +sort_first = ["project", "build-system"] +spaces_before_inline_comment = 2 +spaces_indent_inline_array = 4 +trailing_comma_inline_array = true diff --git a/src/pdfrest/__init__.py b/src/pdfrest/__init__.py new file mode 100644 index 00000000..15cff104 --- /dev/null +++ b/src/pdfrest/__init__.py @@ -0,0 +1,10 @@ +"""Top-level package for the pdfrest client library.""" + +from importlib import metadata + +__all__ = ("__version__",) + +try: # pragma: no cover - fallback should never run in production builds + __version__ = metadata.version("pdfrest") +except metadata.PackageNotFoundError: # pragma: no cover + __version__ = "0.0.0" diff --git a/src/pdfrest/py.typed b/src/pdfrest/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..7069eef7 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1025 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "black" +version = "25.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/40/dbe31fc56b218a858c8fc6f5d8d3ba61c1fa7e989d43d4a4574b8b992840/black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7", size = 1715605, upload-time = "2025-09-19T00:36:13.483Z" }, + { url = "https://files.pythonhosted.org/packages/92/b2/f46800621200eab6479b1f4c0e3ede5b4c06b768e79ee228bc80270bcc74/black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92", size = 1571829, upload-time = "2025-09-19T00:32:42.13Z" }, + { url = "https://files.pythonhosted.org/packages/4e/64/5c7f66bd65af5c19b4ea86062bb585adc28d51d37babf70969e804dbd5c2/black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713", size = 1631888, upload-time = "2025-09-19T00:30:54.212Z" }, + { url = "https://files.pythonhosted.org/packages/3b/64/0b9e5bfcf67db25a6eef6d9be6726499a8a72ebab3888c2de135190853d3/black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1", size = 1327056, upload-time = "2025-09-19T00:31:08.877Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f4/7531d4a336d2d4ac6cc101662184c8e7d068b548d35d874415ed9f4116ef/black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa", size = 1698727, upload-time = "2025-09-19T00:31:14.264Z" }, + { url = "https://files.pythonhosted.org/packages/28/f9/66f26bfbbf84b949cc77a41a43e138d83b109502cd9c52dfc94070ca51f2/black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d", size = 1555679, upload-time = "2025-09-19T00:31:29.265Z" }, + { url = "https://files.pythonhosted.org/packages/bf/59/61475115906052f415f518a648a9ac679d7afbc8da1c16f8fdf68a8cebed/black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608", size = 1617453, upload-time = "2025-09-19T00:30:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5b/20fd5c884d14550c911e4fb1b0dae00d4abb60a4f3876b449c4d3a9141d5/black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f", size = 1333655, upload-time = "2025-09-19T00:30:56.715Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8e/319cfe6c82f7e2d5bfb4d3353c6cc85b523d677ff59edc61fdb9ee275234/black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0", size = 1742012, upload-time = "2025-09-19T00:33:08.678Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/f562fe5d0a40cd2a4e6ae3f685e4c36e365b1f7e494af99c26ff7f28117f/black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4", size = 1581421, upload-time = "2025-09-19T00:35:25.937Z" }, + { url = "https://files.pythonhosted.org/packages/84/67/6db6dff1ebc8965fd7661498aea0da5d7301074b85bba8606a28f47ede4d/black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e", size = 1655619, upload-time = "2025-09-19T00:30:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/10/10/3faef9aa2a730306cf469d76f7f155a8cc1f66e74781298df0ba31f8b4c8/black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a", size = 1342481, upload-time = "2025-09-19T00:31:29.625Z" }, + { url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" }, + { url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/0f724eb152bc9fc03029a9c903ddd77a288285042222a381050d27e64ac1/black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47", size = 1715243, upload-time = "2025-09-19T00:34:14.216Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/cb986ea2f0fabd0ee58668367724ba16c3a042842e9ebe009c139f8221c9/black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823", size = 1571246, upload-time = "2025-09-19T00:31:39.624Z" }, + { url = "https://files.pythonhosted.org/packages/82/ce/74cf4d66963fca33ab710e4c5817ceeff843c45649f61f41d88694c2e5db/black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140", size = 1631265, upload-time = "2025-09-19T00:31:05.341Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f3/9b11e001e84b4d1721f75e20b3c058854a748407e6fc1abe6da0aa22014f/black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933", size = 1326615, upload-time = "2025-09-19T00:31:25.347Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/3a/0cbeb04ea57d2493f3ec5a069a117ab467f85e4a10017c6d854ddcbff104/cachecontrol-0.14.3.tar.gz", hash = "sha256:73e7efec4b06b20d9267b441c1f733664f989fb8688391b670ca812d70795d11", size = 28985, upload-time = "2025-04-30T16:45:06.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/4c/800b0607b00b3fd20f1087f80ab53d6b4d005515b0f773e4831e37cfa83f/cachecontrol-0.14.3-py3-none-any.whl", hash = "sha256:b35e44a3113f17d2a31c1e6b27b9de6d4405f84ae51baa8c1d3cc5b633010cae", size = 21802, upload-time = "2025-04-30T16:45:03.863Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520, upload-time = "2025-08-09T07:57:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307, upload-time = "2025-08-09T07:57:12.4Z" }, + { url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448, upload-time = "2025-08-09T07:57:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758, upload-time = "2025-08-09T07:57:14.979Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487, upload-time = "2025-08-09T07:57:16.332Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054, upload-time = "2025-08-09T07:57:17.576Z" }, + { url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703, upload-time = "2025-08-09T07:57:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096, upload-time = "2025-08-09T07:57:21.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852, upload-time = "2025-08-09T07:57:22.608Z" }, + { url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840, upload-time = "2025-08-09T07:57:23.883Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438, upload-time = "2025-08-09T07:57:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cyclonedx-python-lib" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/fc/abaad5482f7b59c9a0a9d8f354ce4ce23346d582a0d85730b559562bbeb4/cyclonedx_python_lib-9.1.0.tar.gz", hash = "sha256:86935f2c88a7b47a529b93c724dbd3e903bc573f6f8bd977628a7ca1b5dadea1", size = 1048735, upload-time = "2025-02-27T17:23:40.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/f1/f3be2e9820a2c26fa77622223e91f9c504e1581830930d477e06146073f4/cyclonedx_python_lib-9.1.0-py3-none-any.whl", hash = "sha256:55693fca8edaecc3363b24af14e82cc6e659eb1e8353e58b587c42652ce0fb52", size = 374968, upload-time = "2025-02-27T17:23:37.766Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "identify" +version = "2.6.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "isort" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload-time = "2025-02-26T21:13:16.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/52/f30da112c1dc92cf64f57d08a273ac771e7b29dea10b4b30369b2d7e8546/msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed", size = 81799, upload-time = "2025-06-13T06:51:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/7bfc0def2f04ab4145f7f108e3563f9b4abae4ab0ed78a61f350518cc4d2/msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8", size = 78278, upload-time = "2025-06-13T06:51:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c5/df5d6c1c39856bc55f800bf82778fd4c11370667f9b9e9d51b2f5da88f20/msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2", size = 402805, upload-time = "2025-06-13T06:51:39.538Z" }, + { url = "https://files.pythonhosted.org/packages/20/8e/0bb8c977efecfe6ea7116e2ed73a78a8d32a947f94d272586cf02a9757db/msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4", size = 408642, upload-time = "2025-06-13T06:51:41.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/731d52c1aeec52006be6d1f8027c49fdc2cfc3ab7cbe7c28335b2910d7b6/msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0", size = 395143, upload-time = "2025-06-13T06:51:42.575Z" }, + { url = "https://files.pythonhosted.org/packages/2b/92/b42911c52cda2ba67a6418ffa7d08969edf2e760b09015593c8a8a27a97d/msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26", size = 395986, upload-time = "2025-06-13T06:51:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/61/dc/8ae165337e70118d4dab651b8b562dd5066dd1e6dd57b038f32ebc3e2f07/msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75", size = 402682, upload-time = "2025-06-13T06:51:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/555851cb98dcbd6ce041df1eacb25ac30646575e9cd125681aa2f4b1b6f1/msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338", size = 406368, upload-time = "2025-06-13T06:51:46.97Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/39a26add4ce16f24e99eabb9005e44c663db00e3fce17d4ae1ae9d61df99/msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd", size = 65004, upload-time = "2025-06-13T06:51:48.582Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/73dfa3e9d5d7450d39debde5b0d848139f7de23bd637a4506e36c9800fd6/msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8", size = 71548, upload-time = "2025-06-13T06:51:49.558Z" }, + { url = "https://files.pythonhosted.org/packages/7f/83/97f24bf9848af23fe2ba04380388216defc49a8af6da0c28cc636d722502/msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558", size = 82728, upload-time = "2025-06-13T06:51:50.68Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/2eaa388267a78401f6e182662b08a588ef4f3de6f0eab1ec09736a7aaa2b/msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d", size = 79279, upload-time = "2025-06-13T06:51:51.72Z" }, + { url = "https://files.pythonhosted.org/packages/f8/46/31eb60f4452c96161e4dfd26dbca562b4ec68c72e4ad07d9566d7ea35e8a/msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0", size = 423859, upload-time = "2025-06-13T06:51:52.749Z" }, + { url = "https://files.pythonhosted.org/packages/45/16/a20fa8c32825cc7ae8457fab45670c7a8996d7746ce80ce41cc51e3b2bd7/msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f", size = 429975, upload-time = "2025-06-13T06:51:53.97Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/6c958e07692367feeb1a1594d35e22b62f7f476f3c568b002a5ea09d443d/msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704", size = 413528, upload-time = "2025-06-13T06:51:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/ac84063c5dae79722bda9f68b878dc31fc3059adb8633c79f1e82c2cd946/msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2", size = 413338, upload-time = "2025-06-13T06:51:57.023Z" }, + { url = "https://files.pythonhosted.org/packages/69/e8/fe86b082c781d3e1c09ca0f4dacd457ede60a13119b6ce939efe2ea77b76/msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2", size = 422658, upload-time = "2025-06-13T06:51:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2b/bafc9924df52d8f3bb7c00d24e57be477f4d0f967c0a31ef5e2225e035c7/msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752", size = 427124, upload-time = "2025-06-13T06:51:59.969Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3b/1f717e17e53e0ed0b68fa59e9188f3f610c79d7151f0e52ff3cd8eb6b2dc/msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295", size = 65016, upload-time = "2025-06-13T06:52:01.294Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/9d1780768d3b249accecc5a38c725eb1e203d44a191f7b7ff1941f7df60c/msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458", size = 72267, upload-time = "2025-06-13T06:52:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" }, + { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload-time = "2025-06-13T06:52:07.501Z" }, + { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload-time = "2025-06-13T06:52:09.047Z" }, + { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" }, + { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" }, + { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" }, + { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" }, + { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" }, + { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/0792be119d7fe7dc2148689ef65c90507d82d20a204aab3b98c74a1f8684/msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b", size = 81882, upload-time = "2025-06-13T06:52:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/ce06c8e26a816ae8730a8e030d263c5289adcaff9f0476f9b270bdd7c5c2/msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232", size = 78414, upload-time = "2025-06-13T06:52:40.341Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/190576c497677fb4a0d05d896b24aea6cdccd910f206aaa7b511901befed/msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf", size = 400927, upload-time = "2025-06-13T06:52:41.399Z" }, + { url = "https://files.pythonhosted.org/packages/ed/af/6a0aa5a06762e70726ec3c10fb966600d84a7220b52635cb0ab2dc64d32f/msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf", size = 405903, upload-time = "2025-06-13T06:52:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1e/80/3f3da358cecbbe8eb12360814bd1277d59d2608485934742a074d99894a9/msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90", size = 393192, upload-time = "2025-06-13T06:52:43.986Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/3a0ec7fdebbb4f3f8f254696cd91d491c29c501dbebd86286c17e8f68cd7/msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1", size = 393851, upload-time = "2025-06-13T06:52:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/39/37/df50d5f8e68514b60fbe70f6e8337ea2b32ae2be030871bcd9d1cf7d4b62/msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88", size = 400292, upload-time = "2025-06-13T06:52:46.381Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/1e067292e02d2ceb4c8cb5ba222c4f7bb28730eef5676740609dc2627e0f/msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478", size = 401873, upload-time = "2025-06-13T06:52:47.957Z" }, + { url = "https://files.pythonhosted.org/packages/d3/31/e8c9c6b5b58d64c9efa99c8d181fcc25f38ead357b0360379fbc8a4234ad/msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57", size = 65028, upload-time = "2025-06-13T06:52:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/cd62cded572e5e25892747a5d27850170bcd03c855e9c69c538e024de6f9/msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084", size = 71700, upload-time = "2025-06-13T06:52:50.244Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a6/490ff491d8ecddf8ab91762d4f67635040202f76a44171420bcbe38ceee5/mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b", size = 12807230, upload-time = "2025-09-19T00:09:49.471Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/60076fc829645d167ece9e80db9e8375648d210dab44cc98beb5b322a826/mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133", size = 11895666, upload-time = "2025-09-19T00:10:53.678Z" }, + { url = "https://files.pythonhosted.org/packages/97/4a/1e2880a2a5dda4dc8d9ecd1a7e7606bc0b0e14813637eeda40c38624e037/mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6", size = 12499608, upload-time = "2025-09-19T00:09:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/00/81/a117f1b73a3015b076b20246b1f341c34a578ebd9662848c6b80ad5c4138/mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac", size = 13244551, upload-time = "2025-09-19T00:10:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/b9f48e1714ce87c7bf0358eb93f60663740ebb08f9ea886ffc670cea7933/mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b", size = 13491552, upload-time = "2025-09-19T00:10:13.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/b2c0af3b684fa80d1b27501a8bdd3d2daa467ea3992a8aa612f5ca17c2db/mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0", size = 9765635, upload-time = "2025-09-19T00:10:30.993Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/f0/de0ac00a4484c0d87b71e3d9985518278d89797fa725e90abd3453bccb42/packageurl_python-0.17.5.tar.gz", hash = "sha256:a7be3f3ba70d705f738ace9bf6124f31920245a49fa69d4b416da7037dd2de61", size = 43832, upload-time = "2025-08-06T14:08:20.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/78/9dbb7d2ef240d20caf6f79c0f66866737c9d0959601fd783ff635d1d019d/packageurl_python-0.17.5-py3-none-any.whl", hash = "sha256:f0e55452ab37b5c192c443de1458e3f3b4d8ac27f747df6e8c48adeab081d321", size = 30544, upload-time = "2025-08-06T14:08:19.055Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pdfrest" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pip-audit" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=24.8.0" }, + { name = "isort", specifier = ">=5.13.2" }, + { name = "mypy", specifier = ">=1.11.2" }, + { name = "pip-audit", specifier = ">=2.7.3" }, + { name = "pre-commit", specifier = ">=3.7.0" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "pip" +version = "25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/16/650289cd3f43d5a2fadfd98c68bd1e1e7f2550a1a5326768cddfbcedb2c5/pip-25.2.tar.gz", hash = "sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2", size = 1840021, upload-time = "2025-07-30T21:50:15.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/3f/945ef7ab14dc4f9d7f40288d2df998d1837ee0888ec3659c813487572faa/pip-25.2-py3-none-any.whl", hash = "sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717", size = 1752557, upload-time = "2025-07-30T21:50:13.323Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "toml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/7f/28fad19a9806f796f13192ab6974c07c4a04d9cbb8e30dd895c3c11ce7ee/pip_audit-2.9.0.tar.gz", hash = "sha256:0b998410b58339d7a231e5aa004326a294e4c7c6295289cdc9d5e1ef07b1f44d", size = 52089, upload-time = "2025-04-07T16:45:23.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/9e/f4dfd9d3dadb6d6dc9406f1111062f871e2e248ed7b584cca6020baf2ac1/pip_audit-2.9.0-py3-none-any.whl", hash = "sha256:348b16e60895749a0839875d7cc27ebd692e1584ebe5d5cb145941c8e25a80bd", size = 58634, upload-time = "2025-04-07T16:45:22.056Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytokens" +version = "0.1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/5f/e959a442435e24f6fb5a01aec6c657079ceaca1b3baf18561c3728d681da/pytokens-0.1.10.tar.gz", hash = "sha256:c9a4bfa0be1d26aebce03e6884ba454e842f186a59ea43a6d3b25af58223c044", size = 12171, upload-time = "2025-02-19T14:51:22.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/e5/63bed382f6a7a5ba70e7e132b8b7b8abbcf4888ffa6be4877698dcfbed7d/pytokens-0.1.10-py3-none-any.whl", hash = "sha256:db7b72284e480e69fb085d9f251f66b3d2df8b7166059261258ff35f50fb711b", size = 12046, upload-time = "2025-02-19T14:51:18.694Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "ruff" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" }, + { url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" }, + { url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" }, + { url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" }, + { url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" }, + { url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" }, + { url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" }, + { url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990, upload-time = "2025-09-25T14:54:03.647Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004, upload-time = "2025-09-25T14:54:06.05Z" }, + { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +] From 6908f3f8af80d43d3c13e282b046988a3337d936 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 29 Sep 2025 18:11:50 -0500 Subject: [PATCH 02/51] Project template: Comments in pyproject.toml, add .ecrc --- .ecrc | 19 ++++++++++++++ pyproject.toml | 71 ++++++++++++++++++++++++++++++-------------------- 2 files changed, 62 insertions(+), 28 deletions(-) create mode 100644 .ecrc diff --git a/.ecrc b/.ecrc new file mode 100644 index 00000000..d59b3a83 --- /dev/null +++ b/.ecrc @@ -0,0 +1,19 @@ +{ + "Version": "v3.0.3", + "Verbose": false, + "Debug": false, + "IgnoreDefaults": false, + "SpacesAftertabs": false, + "NoColor": false, + "Exclude": ["^\\.idea/", "\\.md$", "\\.py$", "\\.ipynb$"], + "AllowedContentTypes": [], + "PassedFiles": [], + "Disable": { + "EndOfLine": false, + "Indentation": false, + "InsertFinalNewline": false, + "TrimTrailingWhitespace": false, + "IndentSize": true, + "MaxLineLength": false + } +} diff --git a/pyproject.toml b/pyproject.toml index 2e158db3..5170f04a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,9 +36,9 @@ python_version = "3.9" warn_return_any = true pretty = true exclude = [ - '^\\.venv', - '^build', - '^dist', + '^\\.venv', # TOML literal string (single-quotes, no escaping necessary) + '^build', # Skip build artifacts + '^dist', # Skip distribution artifacts ] files = "." plugins = [ @@ -58,32 +58,41 @@ extend-include = ["*.ipynb"] target-version = "py39" [tool.ruff.lint] +# Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default. +# Rules cribbed from the PDM sources themselves. extend-select = [ - "I", - "B", - "C4", - "PGH", - "RUF", - "W", - "YTT", - "UP", - "N", - "PT", - "C90", - "FURB", - "S", - "BLE", - "EM", - "G", - "RET", - "DTZ", - "SIM", - "ARG", - "PTH", - "RSE", - "TRY", - "COM818", + # If you're doing a project of any substantial size, or anything that runs on a server, + # use logging instead of printing. To check this, uncomment the next line. + # "T20", # print https://docs.astral.sh/ruff/rules/#flake8-print-t20 don't print, use logging + "I", # isort https://beta.ruff.rs/docs/rules/#isort-i + "B", # flake8-bugbear https://beta.ruff.rs/docs/rules/#flake8-bugbear-b + "C4", # flake8-comprehensions https://beta.ruff.rs/docs/rules/#flake8-comprehensions-c4 + "PGH", # pygrep-hooks https://beta.ruff.rs/docs/rules/#pygrep-hooks-pgh + "RUF", # ruff https://beta.ruff.rs/docs/rules/#ruff-specific-rules-ruf + "W", # pycodestyle https://beta.ruff.rs/docs/rules/#warning-w + "YTT", # flake8-2020 https://beta.ruff.rs/docs/rules/#flake8-2020-ytt + "UP", # pyupgrade https://beta.ruff.rs/docs/rules/#pyupgrade-up + "N", # naming https://beta.ruff.rs/docs/rules/#pep8-naming-n + "PT", # pytest https://beta.ruff.rs/docs/rules/#flake8-pytest-style-pt + # https://dev.to/aws-builders/deploy-to-aws-with-github-actions-and-aws-cdk-4m1e suggests + # complexity checks with radon/xenon, but McCabe complexity is available in ruff and + # the cost/benefit of using radon/xenon can be discussed later. + "C90", # mccabe https://beta.ruff.rs/docs/rules/#mccabe-c90 + "FURB", # refurb https://beta.ruff.rs/docs/rules/#refurb-furb + "S", # bandit https://docs.astral.sh/ruff/rules/#flake8-bandit-s ...secure code + "BLE", # blind-except https://docs.astral.sh/ruff/rules/#flake8-blind-except-ble + "EM", # errmsg https://docs.astral.sh/ruff/rules/#flake8-errmsg-em + "G", # logging-format https://docs.astral.sh/ruff/rules/#flake8-logging-format-g + "RET", # return https://docs.astral.sh/ruff/rules/#flake8-return-ret + "DTZ", # datetimez https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz + "SIM", # simplify https://docs.astral.sh/ruff/rules/#flake8-simplify-sim + "ARG", # unused arguments https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg + "PTH", # use pathlib https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth + "RSE", # raise https://docs.astral.sh/ruff/rules/#flake8-raise-rse + "TRY", # tryceratops https://docs.astral.sh/ruff/rules/#tryceratops-try exception antipatterns + "COM818", # prohibit trailing bare commas making tuples, see https://docs.astral.sh/ruff/rules/trailing-comma-on-bare-tuple/ ] +# Allow autofix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] ignore = [] unfixable = [] @@ -93,7 +102,13 @@ combine-as-imports = true known-first-party = ["pdfrest"] [tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = ["S101", "ARG", "FBT"] +"tests/**/*.py" = [ + # From https://github.com/astral-sh/ruff/issues/4368#issue-1705468153 + # at least this three should be fine in tests: + "S101", # asserts allowed in tests... + "ARG", # Unused function args -> fixtures nevertheless are functionally relevant... + "FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize() +] [tool.tomlsort] sort_first = ["project", "build-system"] From 6c3f6086ce7a0d41a37792ab9c7cdbb4dd574127 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 29 Sep 2025 18:26:53 -0500 Subject: [PATCH 03/51] GitHub Workflows: Adapted from pdfassistant-chatbot Assisted-by: Codex --- .github/workflows/pre-commit.yml | 37 ++++++++++ .github/workflows/test-and-publish.yml | 93 ++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 .github/workflows/pre-commit.yml create mode 100644 .github/workflows/test-and-publish.yml diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 00000000..27fd51cf --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,37 @@ +name: pre-commit + +on: + pull_request: + push: + branches: + - main + - develop + - feature-* + +jobs: + pre-commit: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + packages: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install uv + uses: astral-sh/setup-uv@v1 + - name: Restore uv cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/uv + .venv + key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml') }} + - name: Synchronize project dependencies + run: uv sync --group dev + - name: Run pre-commit checks + run: uv run pre-commit run --all-files --hook-stage manual diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml new file mode 100644 index 00000000..ab7ca834 --- /dev/null +++ b/.github/workflows/test-and-publish.yml @@ -0,0 +1,93 @@ +name: Test and Publish + +on: + pull_request: + push: + branches: + - main + - develop + - feature-* + release: + types: + - published + +jobs: + tests: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + permissions: + id-token: write + contents: read + packages: write + pull-requests: write + env: + UV_PROJECT_ENVIRONMENT: .venv-${{ matrix.python-version }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@v1 + - name: Restore uv cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/uv + ${{ env.UV_PROJECT_ENVIRONMENT }} + key: ${{ runner.os }}-uv-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} + - name: Synchronize project dependencies + run: uv sync --group dev --python ${{ matrix.python-version }} + - name: Run tests + run: uv run pytest + + publish: + name: Publish to CodeArtifact + needs: tests + if: github.event_name == 'release' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + packages: write + env: + UV_PROJECT_ENVIRONMENT: .venv-release + steps: + - uses: actions/checkout@v4 + - name: Assume AWS role for repository CI + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::304774597385:role/cit-oidc-role-${{ github.event.repository.name }}-ci + aws-region: us-east-2 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install uv + uses: astral-sh/setup-uv@v1 + - name: Restore uv cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/uv + ${{ env.UV_PROJECT_ENVIRONMENT }} + key: ${{ runner.os }}-uv-release-${{ hashFiles('pyproject.toml') }} + - name: Synchronize project dependencies + run: uv sync --group dev + - name: Build distribution artifacts + run: uv build + - name: Log in to CodeArtifact for publishing + run: >- + aws codeartifact login + --tool twine + --domain datalogics + --domain-owner 304774597385 + --repository cit-pypi + --region us-east-2 + - name: Publish package to CodeArtifact + run: uv tool run --from twine twine upload --non-interactive --skip-existing dist/* From 726c9d21cc13c0164826bf47b10e4118f7c53de7 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 29 Sep 2025 18:36:21 -0500 Subject: [PATCH 04/51] AGENTS.md: Add file to guide Codex work. Assisted-by: Codex --- AGENTS.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..2d7421ea --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,66 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- Source lives in `src/pdfrest/`; expose public APIs via `__all__` and keep + package metadata in `pyproject.toml`. +- Tests sit in `tests/` mirroring the module layout (e.g., + `tests/test_client.py`). +- Workflow definitions are in `.github/workflows/`; adjust only when CI + requirements change. +- Documentation and contributor notes reside at the repo root (`README.md`, + `AGENTS.md`). + +## Build, Test, and Development Commands + +- `uv sync --group dev` — create/update the virtual environment with lint, + type-check, and test tooling. +- `uv run pre-commit run --all-files` — enforce formatting and lint rules before + pushing. +- `uv run pytest` — execute the suite with the active interpreter. +- `uv build` — produce wheels and sdists identical to the release workflow. + +## Coding Style & Naming Conventions + +- Target Python 3.9–3.13; use 4-space indentation and type hints for public + APIs. +- Black + isort (via ruff) enforce formatting; run through pre-commit prior to + review. +- Use `snake_case` for functions/modules, `PascalCase` for classes, and + `UPPER_SNAKE_CASE` for constants. +- Prefer `pathlib`, f-strings, and other modern stdlib features—pyupgrade rules + will flag legacy code. + +## Testing Guidelines + +- Write pytest tests: files named `test_*.py`, test functions `test_*`, fixtures + in `conftest.py` where shared. +- Ensure high-value coverage of public functions and edge cases; document intent + in test docstrings when non-obvious. +- For interpreter compatibility, execute the matrix locally: + ```bash + for py in 3.9 3.10 3.11 3.12 3.13; do + UV_PROJECT_ENVIRONMENT=.venv-$py uv sync --group dev --python $py + UV_PROJECT_ENVIRONMENT=.venv-$py uv run --python $py pytest + done + ``` + +## Commit & Pull Request Guidelines + +- Follow the `area: summary` convention seen in `pdfassistant-chatbot` (e.g., + `client: Add document merge service`). +- Keep commit messages imperative and focused; squash fixups before opening a + PR. +- Reference related issues or tickets in the PR description, and highlight + breaking changes. +- Confirm CI passes (`pre-commit`, Python matrix) and note any manual + verification or screenshots for behaviour updates. + +## CI & Publishing Notes + +- GitHub Actions run two workflows: `pre-commit` (no AWS credentials) and + `Test and Publish` (Python 3.9–3.13 matrix). +- Only the release job assumes the AWS OIDC role to `uv build` and publish with + `uv tool run --from twine twine upload`. +- Keep CodeArtifact credentials out of source control; day-to-day development + should rely solely on public dependencies. From 1e98a45fd2ce92a1cfbcbb880b59d08dd21610d4 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 2 Oct 2025 13:37:05 -0500 Subject: [PATCH 05/51] GitHub Workflows: Fix up UV and caching - Update `setup-uv` to v6 - Fix up caching. - Cache keys for virtualenv and the like are based on lockfile --- .github/workflows/pre-commit.yml | 21 +++++++++++++-------- .github/workflows/test-and-publish.yml | 15 ++++++++++----- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 27fd51cf..5a492801 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -18,19 +18,24 @@ jobs: pull-requests: write steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - name: Install uv - uses: astral-sh/setup-uv@v1 - - name: Restore uv cache + uses: astral-sh/setup-uv@v6 + with: + version: 0.8.22 + python-version: 3.11 + enable-cache: true + cache-suffix: pre-commit + cache-dependency-glob: uv.lock + - name: Restore venv cache uses: actions/cache@v4 with: path: | - ~/.cache/uv .venv - key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml') }} + key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }} + - uses: actions/cache@v4 + with: + path: .mypy_cache + key: mypy_cache|${{ hashFiles('pyproject.toml') }} - name: Synchronize project dependencies run: uv sync --group dev - name: Run pre-commit checks diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index ab7ca834..ad3f1893 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -33,16 +33,21 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v1 - - name: Restore uv cache + uses: astral-sh/setup-uv@v6 + with: + version: 0.8.22 + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-suffix: test-and-publish + cache-dependency-glob: uv.lock + - name: Restore venv cache uses: actions/cache@v4 with: path: | - ~/.cache/uv ${{ env.UV_PROJECT_ENVIRONMENT }} - key: ${{ runner.os }}-uv-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} + key: ${{ runner.os }}-uv-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }} - name: Synchronize project dependencies - run: uv sync --group dev --python ${{ matrix.python-version }} + run: uv sync --group dev - name: Run tests run: uv run pytest From 90f47dec84380adc9cc68f7eb2561e27245fd5bf Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 2 Oct 2025 13:58:07 -0500 Subject: [PATCH 06/51] pre-commit workflow: Fix up caching and options --- .github/workflows/pre-commit.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 5a492801..e5fd8908 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -32,11 +32,17 @@ jobs: path: | .venv key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }} - - uses: actions/cache@v4 + - name: Restore mypy cache + uses: actions/cache@v4 with: path: .mypy_cache key: mypy_cache|${{ hashFiles('pyproject.toml') }} + - name: Restore pre-commit cache + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-4|${{ hashFiles('.pre-commit-config.yaml') }} - name: Synchronize project dependencies run: uv sync --group dev - name: Run pre-commit checks - run: uv run pre-commit run --all-files --hook-stage manual + run: uv run pre-commit run --show-diff-on-failure --all-files --hook-stage manual From 0d8092dacffa3267beff976618adb61fb26c59e6 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 7 Oct 2025 12:36:27 -0500 Subject: [PATCH 07/51] pre-commit: Add uv lock check --- .pre-commit-config.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc9f175f..25bda979 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -89,3 +89,8 @@ repos: additional_dependencies: # Need pydantic to load the pydantic.mypy plugin - pydantic>=2.12.0 + - repo: https://github.com/astral-sh/uv-pre-commit + # uv version. + rev: 0.8.24 + hooks: + - id: uv-lock From c89d7b86e49d3e2143f35995ca434248fd3b90fc Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 7 Oct 2025 12:39:34 -0500 Subject: [PATCH 08/51] pre-commit: Update all the hooks --- .ecrc | 19 ------------------- .editorconfig-checker.json | 19 +++++++++++++++++++ .pre-commit-config.yaml | 20 ++++++++++---------- 3 files changed, 29 insertions(+), 29 deletions(-) delete mode 100644 .ecrc create mode 100644 .editorconfig-checker.json diff --git a/.ecrc b/.ecrc deleted file mode 100644 index d59b3a83..00000000 --- a/.ecrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "Version": "v3.0.3", - "Verbose": false, - "Debug": false, - "IgnoreDefaults": false, - "SpacesAftertabs": false, - "NoColor": false, - "Exclude": ["^\\.idea/", "\\.md$", "\\.py$", "\\.ipynb$"], - "AllowedContentTypes": [], - "PassedFiles": [], - "Disable": { - "EndOfLine": false, - "Indentation": false, - "InsertFinalNewline": false, - "TrimTrailingWhitespace": false, - "IndentSize": true, - "MaxLineLength": false - } -} diff --git a/.editorconfig-checker.json b/.editorconfig-checker.json new file mode 100644 index 00000000..684cbc35 --- /dev/null +++ b/.editorconfig-checker.json @@ -0,0 +1,19 @@ +{ + "Version": "v3.4.0", + "Verbose": false, + "Debug": false, + "IgnoreDefaults": false, + "SpacesAfterTabs": false, + "NoColor": false, + "Exclude": ["^\\.idea/", "\\.md$", "\\.py$", "\\.ipynb$"], + "AllowedContentTypes": [], + "PassedFiles": [], + "Disable": { + "EndOfLine": false, + "Indentation": false, + "InsertFinalNewline": false, + "TrimTrailingWhitespace": false, + "IndentSize": true, + "MaxLineLength": false + } +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 25bda979..f5dafe10 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ default_install_hook_types: [pre-commit, pre-merge-commit, pre-push] default_stages: [pre-commit, pre-merge-commit, pre-push, manual] repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] @@ -19,20 +19,20 @@ repos: - id: check-added-large-files args: [--maxkb=4000] - repo: https://github.com/JoC0de/pre-commit-prettier - rev: v3.4.2 + rev: v3.6.2 hooks: - id: prettier exclude: .md$ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.31.0 + rev: 0.34.0 hooks: - id: check-github-workflows - repo: https://github.com/editorconfig-checker/editorconfig-checker.python - rev: 3.0.3 + rev: 3.4.0 hooks: - id: editorconfig-checker - repo: https://github.com/executablebooks/mdformat - rev: 0.7.21 + rev: 0.7.22 hooks: - id: mdformat name: mdformat on non-.github files @@ -53,12 +53,12 @@ repos: - mdformat-footnote - mdformat-toc - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.10.0.1 + rev: v0.11.0.1 hooks: - id: shellcheck args: [-x] - repo: https://github.com/maxwinterstein/shfmt-py - rev: v3.7.0.1 + rev: v3.12.0.1 hooks: - id: shfmt - repo: https://github.com/pycqa/isort @@ -67,17 +67,17 @@ repos: - id: isort args: ["--profile", "black", "--filter-files"] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.10.0 + rev: 25.9.0 hooks: - id: black-jupyter language_version: python3.11 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.1 + rev: v0.13.3 hooks: - id: ruff types_or: [python, pyi, jupyter] - repo: https://github.com/pappasam/toml-sort - rev: v0.24.2 + rev: v0.24.3 hooks: - id: toml-sort-fix - repo: https://github.com/pre-commit/mirrors-mypy From 60d1bc8cdd581c3ed19c5c8604b0777474ffab0d Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 9 Oct 2025 10:25:27 -0500 Subject: [PATCH 09/51] Use ruff for formatting, over black --- .pre-commit-config.yaml | 14 ++----- pyproject.toml | 8 ---- uv.lock | 92 ----------------------------------------- 3 files changed, 3 insertions(+), 111 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f5dafe10..24e87493 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,20 +61,12 @@ repos: rev: v3.12.0.1 hooks: - id: shfmt - - repo: https://github.com/pycqa/isort - rev: 6.1.0 - hooks: - - id: isort - args: ["--profile", "black", "--filter-files"] - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.9.0 - hooks: - - id: black-jupyter - language_version: python3.11 - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.13.3 hooks: - - id: ruff + - id: ruff-check + types_or: [python, pyi, jupyter] + - id: ruff-format types_or: [python, pyi, jupyter] - repo: https://github.com/pappasam/toml-sort rev: v0.24.3 diff --git a/pyproject.toml b/pyproject.toml index 5170f04a..ce2f2600 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,6 @@ build-backend = "uv_build" [dependency-groups] dev = [ "pre-commit>=3.7.0", - "black>=24.8.0", - "isort>=5.13.2", "ruff>=0.6.9", "pytest>=8.3.3", "pytest-cov>=5.0.0", @@ -25,12 +23,6 @@ dev = [ "pip-audit>=2.7.3", ] -[tool.isort] -combine_as_imports = true -split_on_trailing_comma = true -profile = "black" -known_first_party = ["pdfrest"] - [tool.mypy] python_version = "3.9" warn_return_any = true diff --git a/uv.lock b/uv.lock index 7069eef7..2fc60e29 100644 --- a/uv.lock +++ b/uv.lock @@ -6,46 +6,6 @@ resolution-markers = [ "python_full_version < '3.10'", ] -[[package]] -name = "black" -version = "25.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/40/dbe31fc56b218a858c8fc6f5d8d3ba61c1fa7e989d43d4a4574b8b992840/black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7", size = 1715605, upload-time = "2025-09-19T00:36:13.483Z" }, - { url = "https://files.pythonhosted.org/packages/92/b2/f46800621200eab6479b1f4c0e3ede5b4c06b768e79ee228bc80270bcc74/black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92", size = 1571829, upload-time = "2025-09-19T00:32:42.13Z" }, - { url = "https://files.pythonhosted.org/packages/4e/64/5c7f66bd65af5c19b4ea86062bb585adc28d51d37babf70969e804dbd5c2/black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713", size = 1631888, upload-time = "2025-09-19T00:30:54.212Z" }, - { url = "https://files.pythonhosted.org/packages/3b/64/0b9e5bfcf67db25a6eef6d9be6726499a8a72ebab3888c2de135190853d3/black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1", size = 1327056, upload-time = "2025-09-19T00:31:08.877Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f4/7531d4a336d2d4ac6cc101662184c8e7d068b548d35d874415ed9f4116ef/black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa", size = 1698727, upload-time = "2025-09-19T00:31:14.264Z" }, - { url = "https://files.pythonhosted.org/packages/28/f9/66f26bfbbf84b949cc77a41a43e138d83b109502cd9c52dfc94070ca51f2/black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d", size = 1555679, upload-time = "2025-09-19T00:31:29.265Z" }, - { url = "https://files.pythonhosted.org/packages/bf/59/61475115906052f415f518a648a9ac679d7afbc8da1c16f8fdf68a8cebed/black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608", size = 1617453, upload-time = "2025-09-19T00:30:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5b/20fd5c884d14550c911e4fb1b0dae00d4abb60a4f3876b449c4d3a9141d5/black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f", size = 1333655, upload-time = "2025-09-19T00:30:56.715Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8e/319cfe6c82f7e2d5bfb4d3353c6cc85b523d677ff59edc61fdb9ee275234/black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0", size = 1742012, upload-time = "2025-09-19T00:33:08.678Z" }, - { url = "https://files.pythonhosted.org/packages/94/cc/f562fe5d0a40cd2a4e6ae3f685e4c36e365b1f7e494af99c26ff7f28117f/black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4", size = 1581421, upload-time = "2025-09-19T00:35:25.937Z" }, - { url = "https://files.pythonhosted.org/packages/84/67/6db6dff1ebc8965fd7661498aea0da5d7301074b85bba8606a28f47ede4d/black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e", size = 1655619, upload-time = "2025-09-19T00:30:49.241Z" }, - { url = "https://files.pythonhosted.org/packages/10/10/3faef9aa2a730306cf469d76f7f155a8cc1f66e74781298df0ba31f8b4c8/black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a", size = 1342481, upload-time = "2025-09-19T00:31:29.625Z" }, - { url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" }, - { url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/0f724eb152bc9fc03029a9c903ddd77a288285042222a381050d27e64ac1/black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47", size = 1715243, upload-time = "2025-09-19T00:34:14.216Z" }, - { url = "https://files.pythonhosted.org/packages/fb/be/cb986ea2f0fabd0ee58668367724ba16c3a042842e9ebe009c139f8221c9/black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823", size = 1571246, upload-time = "2025-09-19T00:31:39.624Z" }, - { url = "https://files.pythonhosted.org/packages/82/ce/74cf4d66963fca33ab710e4c5817ceeff843c45649f61f41d88694c2e5db/black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140", size = 1631265, upload-time = "2025-09-19T00:31:05.341Z" }, - { url = "https://files.pythonhosted.org/packages/ff/f3/9b11e001e84b4d1721f75e20b3c058854a748407e6fc1abe6da0aa22014f/black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933", size = 1326615, upload-time = "2025-09-19T00:31:25.347Z" }, - { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" }, -] - [[package]] name = "boolean-py" version = "5.0" @@ -166,36 +126,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, -] - -[[package]] -name = "click" -version = "8.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -402,15 +332,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] -[[package]] -name = "isort" -version = "6.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload-time = "2025-02-26T21:13:16.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, -] - [[package]] name = "license-expression" version = "30.4.4" @@ -623,8 +544,6 @@ source = { editable = "." } [package.dev-dependencies] dev = [ - { name = "black" }, - { name = "isort" }, { name = "mypy" }, { name = "pip-audit" }, { name = "pre-commit" }, @@ -637,8 +556,6 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "black", specifier = ">=24.8.0" }, - { name = "isort", specifier = ">=5.13.2" }, { name = "mypy", specifier = ">=1.11.2" }, { name = "pip-audit", specifier = ">=2.7.3" }, { name = "pre-commit", specifier = ">=3.7.0" }, @@ -797,15 +714,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] -[[package]] -name = "pytokens" -version = "0.1.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/5f/e959a442435e24f6fb5a01aec6c657079ceaca1b3baf18561c3728d681da/pytokens-0.1.10.tar.gz", hash = "sha256:c9a4bfa0be1d26aebce03e6884ba454e842f186a59ea43a6d3b25af58223c044", size = 12171, upload-time = "2025-02-19T14:51:22.001Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/e5/63bed382f6a7a5ba70e7e132b8b7b8abbcf4888ffa6be4877698dcfbed7d/pytokens-0.1.10-py3-none-any.whl", hash = "sha256:db7b72284e480e69fb085d9f251f66b3d2df8b7166059261258ff35f50fb711b", size = 12046, upload-time = "2025-02-19T14:51:18.694Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" From e21aa67602b86e759765198de280b0b2c519bebe Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 7 Oct 2025 13:51:54 -0500 Subject: [PATCH 10/51] Add pyright for type checking - Best type checker to use in an IDE - Use both pyright and mypy - mypy run on pushes and in CI --- .pre-commit-config.yaml | 6 +++++- pyproject.toml | 7 ++++++- uv.lock | 17 ++++++++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 24e87493..fe7b88e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,7 +76,7 @@ repos: rev: "v1.18.2" hooks: - id: mypy - stages: [pre-commit, pre-merge-commit, pre-push, manual] + stages: [pre-push, manual] name: mypy additional_dependencies: # Need pydantic to load the pydantic.mypy plugin @@ -86,3 +86,7 @@ repos: rev: 0.8.24 hooks: - id: uv-lock + - repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.406 + hooks: + - id: pyright diff --git a/pyproject.toml b/pyproject.toml index ce2f2600..07995eab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,8 +19,9 @@ dev = [ "ruff>=0.6.9", "pytest>=8.3.3", "pytest-cov>=5.0.0", - "mypy>=1.11.2", + "mypy>=1.18.2", "pip-audit>=2.7.3", + "pyright>=1.1.406", ] [tool.mypy] @@ -40,6 +41,10 @@ plugins = [ python_executable = ".venv/bin/python" fixed_format_cache = true +[tool.pyright] +venvPath = "." +venv = ".venv" + [tool.pytest.ini_options] minversion = "7.4" testpaths = ["tests"] diff --git a/uv.lock b/uv.lock index 2fc60e29..68520984 100644 --- a/uv.lock +++ b/uv.lock @@ -547,6 +547,7 @@ dev = [ { name = "mypy" }, { name = "pip-audit" }, { name = "pre-commit" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -556,9 +557,10 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=1.11.2" }, + { name = "mypy", specifier = ">=1.18.2" }, { name = "pip-audit", specifier = ">=2.7.3" }, { name = "pre-commit", specifier = ">=3.7.0" }, + { name = "pyright", specifier = ">=1.1.406" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "ruff", specifier = ">=0.6.9" }, @@ -682,6 +684,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, ] +[[package]] +name = "pyright" +version = "1.1.406" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/6b4fbdd1fef59a0292cbb99f790b44983e390321eccbc5921b4d161da5d1/pyright-1.1.406.tar.gz", hash = "sha256:c4872bc58c9643dac09e8a2e74d472c62036910b3bd37a32813989ef7576ea2c", size = 4113151, upload-time = "2025-10-02T01:04:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/a2/e309afbb459f50507103793aaef85ca4348b66814c86bc73908bdeb66d12/pyright-1.1.406-py3-none-any.whl", hash = "sha256:1d81fb43c2407bf566e97e57abb01c811973fdb21b2df8df59f870f688bdca71", size = 5980982, upload-time = "2025-10-02T01:04:43.137Z" }, +] + [[package]] name = "pytest" version = "8.4.2" From 22c311ab6d359d25e7208117bf6b126bfaeb5c31 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 7 Oct 2025 13:54:17 -0500 Subject: [PATCH 11/51] test-and-publish: Modernize the Publish to CodeArtifact job --- .github/workflows/test-and-publish.yml | 28 ++++++++++---------------- pyproject.toml | 9 +++++++++ 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index ad3f1893..aa8200e0 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -69,30 +69,24 @@ jobs: with: role-to-assume: arn:aws:iam::304774597385:role/cit-oidc-role-${{ github.event.repository.name }}-ci aws-region: us-east-2 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - name: Install uv - uses: astral-sh/setup-uv@v1 - - name: Restore uv cache + uses: astral-sh/setup-uv@v6 + with: + version: 0.8.22 + enable-cache: true + cache-suffix: pre-commit + cache-dependency-glob: uv.lock + - name: Restore venv cache uses: actions/cache@v4 with: path: | - ~/.cache/uv ${{ env.UV_PROJECT_ENVIRONMENT }} key: ${{ runner.os }}-uv-release-${{ hashFiles('pyproject.toml') }} + - name: Install keyring + run: uv tool install keyring --with keyrings.codeartifact - name: Synchronize project dependencies run: uv sync --group dev - name: Build distribution artifacts - run: uv build - - name: Log in to CodeArtifact for publishing - run: >- - aws codeartifact login - --tool twine - --domain datalogics - --domain-owner 304774597385 - --repository cit-pypi - --region us-east-2 + run: uv build --python 3.11 - name: Publish package to CodeArtifact - run: uv tool run --from twine twine upload --non-interactive --skip-existing dist/* + run: uv publish --index cit-pypi diff --git a/pyproject.toml b/pyproject.toml index 07995eab..f24fd5bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,3 +112,12 @@ sort_first = ["project", "build-system"] spaces_before_inline_comment = 2 spaces_indent_inline_array = 4 trailing_comma_inline_array = true + +[tool.uv] +keyring-provider = "subprocess" + +[[tool.uv.index]] +name = "cit-pypi" +url = "https://aws@datalogics-304774597385.d.codeartifact.us-east-2.amazonaws.com/pypi/cit-pypi/simple/" +publish-url = "https://aws@datalogics-304774597385.d.codeartifact.us-east-2.amazonaws.com/pypi/cit-pypi/" +username = "__token__" From 88b0a8505f0d84de7a08d731cf9268e989dc59f7 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 7 Oct 2025 16:13:43 -0500 Subject: [PATCH 12/51] uv: Add pydantic and httpx --- pyproject.toml | 5 +- uv.lock | 228 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f24fd5bf..20c69681 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,10 @@ authors = [ {name = "Datalogics"}, ] requires-python = ">=3.9" -dependencies = [] +dependencies = [ + "httpx>=0.28.1", + "pydantic>=2.12.0", +] [build-system] requires = ["uv_build>=0.8.22,<0.9.0"] diff --git a/uv.lock b/uv.lock index 68520984..f72900d6 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,30 @@ resolution-markers = [ "python_full_version < '3.10'", ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + [[package]] name = "boolean-py" version = "5.0" @@ -305,6 +329,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.14" @@ -541,6 +602,10 @@ wheels = [ name = "pdfrest" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] [package.dev-dependencies] dev = [ @@ -554,6 +619,10 @@ dev = [ ] [package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic", specifier = ">=2.12.0" }, +] [package.metadata.requires-dev] dev = [ @@ -666,6 +735,144 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/da/b8a7ee04378a53f6fefefc0c5e05570a3ebfdfa0523a878bcd3b475683ee/pydantic-2.12.0.tar.gz", hash = "sha256:c1a077e6270dbfb37bfd8b498b3981e2bb18f68103720e51fa6c306a5a9af563", size = 814760, upload-time = "2025-10-07T15:58:03.467Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/9d/d5c855424e2e5b6b626fbc6ec514d8e655a600377ce283008b115abb7445/pydantic-2.12.0-py3-none-any.whl", hash = "sha256:f6a1da352d42790537e95e83a8bdfb91c7efbae63ffd0b86fa823899e807116f", size = 459730, upload-time = "2025-10-07T15:58:01.576Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/14/12b4a0d2b0b10d8e1d9a24ad94e7bbb43335eaf29c0c4e57860e8a30734a/pydantic_core-2.41.1.tar.gz", hash = "sha256:1ad375859a6d8c356b7704ec0f547a58e82ee80bb41baa811ad710e124bc8f2f", size = 454870, upload-time = "2025-10-07T10:50:45.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2c/a5c4640dc7132540109f67fe83b566fbc7512ccf2a068cfa22a243df70c7/pydantic_core-2.41.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e63036298322e9aea1c8b7c0a6c1204d615dbf6ec0668ce5b83ff27f07404a61", size = 2113814, upload-time = "2025-10-06T21:09:50.892Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e7/a8694c3454a57842095d69c7a4ab3cf81c3c7b590f052738eabfdfc2e234/pydantic_core-2.41.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:241299ca91fc77ef64f11ed909d2d9220a01834e8e6f8de61275c4dd16b7c936", size = 1916660, upload-time = "2025-10-06T21:09:52.783Z" }, + { url = "https://files.pythonhosted.org/packages/9c/58/29f12e65b19c1877a0269eb4f23c5d2267eded6120a7d6762501ab843dc9/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ab7e594a2a5c24ab8013a7dc8cfe5f2260e80e490685814122081705c2cf2b0", size = 1975071, upload-time = "2025-10-06T21:09:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/98/26/4e677f2b7ec3fbdd10be6b586a82a814c8ebe3e474024c8df2d4260e564e/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b054ef1a78519cb934b58e9c90c09e93b837c935dcd907b891f2b265b129eb6e", size = 2067271, upload-time = "2025-10-06T21:09:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/29/50/50614bd906089904d7ca1be3b9ecf08c00a327143d48f1decfdc21b3c302/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f2ab7d10d0ab2ed6da54c757233eb0f48ebfb4f86e9b88ccecb3f92bbd61a538", size = 2253207, upload-time = "2025-10-06T21:09:56.709Z" }, + { url = "https://files.pythonhosted.org/packages/ea/58/b1e640b4ca559273cca7c28e0fe8891d5d8e9a600f5ab4882670ec107549/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2757606b7948bb853a27e4040820306eaa0ccb9e8f9f8a0fa40cb674e170f350", size = 2375052, upload-time = "2025-10-06T21:09:57.97Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/cd47df3bfb24350e03835f0950288d1054f1cc9a8023401dabe6d4ff2834/pydantic_core-2.41.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cec0e75eb61f606bad0a32f2be87507087514e26e8c73db6cbdb8371ccd27917", size = 2076834, upload-time = "2025-10-06T21:09:59.58Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b4/71b2c77e5df527fbbc1a03e72c3fd96c44cd10d4241a81befef8c12b9fc4/pydantic_core-2.41.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0234236514f44a5bf552105cfe2543a12f48203397d9d0f866affa569345a5b5", size = 2195374, upload-time = "2025-10-06T21:10:01.18Z" }, + { url = "https://files.pythonhosted.org/packages/aa/08/4b8a50733005865efde284fec45da75fe16a258f706e16323c5ace4004eb/pydantic_core-2.41.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1b974e41adfbb4ebb0f65fc4ca951347b17463d60893ba7d5f7b9bb087c83897", size = 2156060, upload-time = "2025-10-06T21:10:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/83/c3/1037cb603ef2130c210150a51b1710d86825b5c28df54a55750099f91196/pydantic_core-2.41.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:248dafb3204136113c383e91a4d815269f51562b6659b756cf3df14eefc7d0bb", size = 2331640, upload-time = "2025-10-06T21:10:04.39Z" }, + { url = "https://files.pythonhosted.org/packages/56/4c/52d111869610e6b1a46e1f1035abcdc94d0655587e39104433a290e9f377/pydantic_core-2.41.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:678f9d76a91d6bcedd7568bbf6beb77ae8447f85d1aeebaab7e2f0829cfc3a13", size = 2329844, upload-time = "2025-10-06T21:10:05.68Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/4b435f0b52ab543967761aca66b84ad3f0026e491e57de47693d15d0a8db/pydantic_core-2.41.1-cp310-cp310-win32.whl", hash = "sha256:dff5bee1d21ee58277900692a641925d2dddfde65182c972569b1a276d2ac8fb", size = 1991289, upload-time = "2025-10-06T21:10:07.199Z" }, + { url = "https://files.pythonhosted.org/packages/88/52/31b4deafc1d3cb96d0e7c0af70f0dc05454982d135d07f5117e6336153e8/pydantic_core-2.41.1-cp310-cp310-win_amd64.whl", hash = "sha256:5042da12e5d97d215f91567110fdfa2e2595a25f17c19b9ff024f31c34f9b53e", size = 2027747, upload-time = "2025-10-06T21:10:08.503Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/ec440f02e57beabdfd804725ef1e38ac1ba00c49854d298447562e119513/pydantic_core-2.41.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4f276a6134fe1fc1daa692642a3eaa2b7b858599c49a7610816388f5e37566a1", size = 2111456, upload-time = "2025-10-06T21:10:09.824Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f9/6bc15bacfd8dcfc073a1820a564516d9c12a435a9a332d4cbbfd48828ddd/pydantic_core-2.41.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07588570a805296ece009c59d9a679dc08fab72fb337365afb4f3a14cfbfc176", size = 1915012, upload-time = "2025-10-06T21:10:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/38/8a/d9edcdcdfe80bade17bed424284427c08bea892aaec11438fa52eaeaf79c/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28527e4b53400cd60ffbd9812ccb2b5135d042129716d71afd7e45bf42b855c0", size = 1973762, upload-time = "2025-10-06T21:10:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b3/ff225c6d49fba4279de04677c1c876fc3dc6562fd0c53e9bfd66f58c51a8/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46a1c935c9228bad738c8a41de06478770927baedf581d172494ab36a6b96575", size = 2065386, upload-time = "2025-10-06T21:10:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/47/ba/183e8c0be4321314af3fd1ae6bfc7eafdd7a49bdea5da81c56044a207316/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:447ddf56e2b7d28d200d3e9eafa936fe40485744b5a824b67039937580b3cb20", size = 2252317, upload-time = "2025-10-06T21:10:15.719Z" }, + { url = "https://files.pythonhosted.org/packages/57/c5/aab61e94fd02f45c65f1f8c9ec38bb3b33fbf001a1837c74870e97462572/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63892ead40c1160ac860b5debcc95c95c5a0035e543a8b5a4eac70dd22e995f4", size = 2373405, upload-time = "2025-10-06T21:10:17.017Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4f/3aaa3bd1ea420a15acc42d7d3ccb3b0bbc5444ae2f9dbc1959f8173e16b8/pydantic_core-2.41.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4a9543ca355e6df8fbe9c83e9faab707701e9103ae857ecb40f1c0cf8b0e94d", size = 2073794, upload-time = "2025-10-06T21:10:18.383Z" }, + { url = "https://files.pythonhosted.org/packages/58/bd/e3975cdebe03ec080ef881648de316c73f2a6be95c14fc4efb2f7bdd0d41/pydantic_core-2.41.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f2611bdb694116c31e551ed82e20e39a90bea9b7ad9e54aaf2d045ad621aa7a1", size = 2194430, upload-time = "2025-10-06T21:10:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/6b7e7217f147d3b3105b57fb1caec3c4f667581affdfaab6d1d277e1f749/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fecc130893a9b5f7bfe230be1bb8c61fe66a19db8ab704f808cb25a82aad0bc9", size = 2154611, upload-time = "2025-10-06T21:10:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/239c2fe76bd8b7eef9ae2140d737368a3c6fea4fd27f8f6b4cde6baa3ce9/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:1e2df5f8344c99b6ea5219f00fdc8950b8e6f2c422fbc1cc122ec8641fac85a1", size = 2329809, upload-time = "2025-10-06T21:10:22.678Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/77a821a67ff0786f2f14856d6bd1348992f695ee90136a145d7a445c1ff6/pydantic_core-2.41.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:35291331e9d8ed94c257bab6be1cb3a380b5eee570a2784bffc055e18040a2ea", size = 2327907, upload-time = "2025-10-06T21:10:24.447Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9a/b54512bb9df7f64c586b369328c30481229b70ca6a5fcbb90b715e15facf/pydantic_core-2.41.1-cp311-cp311-win32.whl", hash = "sha256:2876a095292668d753f1a868c4a57c4ac9f6acbd8edda8debe4218d5848cf42f", size = 1989964, upload-time = "2025-10-06T21:10:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/9d/72/63c9a4f1a5c950e65dd522d7dd67f167681f9d4f6ece3b80085a0329f08f/pydantic_core-2.41.1-cp311-cp311-win_amd64.whl", hash = "sha256:b92d6c628e9a338846a28dfe3fcdc1a3279388624597898b105e078cdfc59298", size = 2025158, upload-time = "2025-10-06T21:10:27.522Z" }, + { url = "https://files.pythonhosted.org/packages/d8/16/4e2706184209f61b50c231529257c12eb6bd9eb36e99ea1272e4815d2200/pydantic_core-2.41.1-cp311-cp311-win_arm64.whl", hash = "sha256:7d82ae99409eb69d507a89835488fb657faa03ff9968a9379567b0d2e2e56bc5", size = 1972297, upload-time = "2025-10-06T21:10:28.814Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bc/5f520319ee1c9e25010412fac4154a72e0a40d0a19eb00281b1f200c0947/pydantic_core-2.41.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:db2f82c0ccbce8f021ad304ce35cbe02aa2f95f215cac388eed542b03b4d5eb4", size = 2099300, upload-time = "2025-10-06T21:10:30.463Z" }, + { url = "https://files.pythonhosted.org/packages/31/14/010cd64c5c3814fb6064786837ec12604be0dd46df3327cf8474e38abbbd/pydantic_core-2.41.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47694a31c710ced9205d5f1e7e8af3ca57cbb8a503d98cb9e33e27c97a501601", size = 1910179, upload-time = "2025-10-06T21:10:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/8e/2e/23fc2a8a93efad52df302fdade0a60f471ecc0c7aac889801ac24b4c07d6/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e9decce94daf47baf9e9d392f5f2557e783085f7c5e522011545d9d6858e00", size = 1957225, upload-time = "2025-10-06T21:10:33.11Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b6/6db08b2725b2432b9390844852e11d320281e5cea8a859c52c68001975fa/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab0adafdf2b89c8b84f847780a119437a0931eca469f7b44d356f2b426dd9741", size = 2053315, upload-time = "2025-10-06T21:10:34.87Z" }, + { url = "https://files.pythonhosted.org/packages/61/d9/4de44600f2d4514b44f3f3aeeda2e14931214b6b5bf52479339e801ce748/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5da98cc81873f39fd56882e1569c4677940fbc12bce6213fad1ead784192d7c8", size = 2224298, upload-time = "2025-10-06T21:10:36.233Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ae/dbe51187a7f35fc21b283c5250571a94e36373eb557c1cba9f29a9806dcf/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:209910e88afb01fd0fd403947b809ba8dba0e08a095e1f703294fda0a8fdca51", size = 2351797, upload-time = "2025-10-06T21:10:37.601Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a7/975585147457c2e9fb951c7c8dab56deeb6aa313f3aa72c2fc0df3f74a49/pydantic_core-2.41.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365109d1165d78d98e33c5bfd815a9b5d7d070f578caefaabcc5771825b4ecb5", size = 2074921, upload-time = "2025-10-06T21:10:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/62/37/ea94d1d0c01dec1b7d236c7cec9103baab0021f42500975de3d42522104b/pydantic_core-2.41.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:706abf21e60a2857acdb09502bc853ee5bce732955e7b723b10311114f033115", size = 2187767, upload-time = "2025-10-06T21:10:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/d3/fe/694cf9fdd3a777a618c3afd210dba7b414cb8a72b1bd29b199c2e5765fee/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bf0bd5417acf7f6a7ec3b53f2109f587be176cb35f9cf016da87e6017437a72d", size = 2136062, upload-time = "2025-10-06T21:10:42.09Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/174aeabd89916fbd2988cc37b81a59e1186e952afd2a7ed92018c22f31ca/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2e71b1c6ceb9c78424ae9f63a07292fb769fb890a4e7efca5554c47f33a60ea5", size = 2317819, upload-time = "2025-10-06T21:10:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/65/e8/e9aecafaebf53fc456314f72886068725d6fba66f11b013532dc21259343/pydantic_core-2.41.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:80745b9770b4a38c25015b517451c817799bfb9d6499b0d13d8227ec941cb513", size = 2312267, upload-time = "2025-10-06T21:10:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/35/2f/1c2e71d2a052f9bb2f2df5a6a05464a0eb800f9e8d9dd800202fe31219e1/pydantic_core-2.41.1-cp312-cp312-win32.whl", hash = "sha256:83b64d70520e7890453f1aa21d66fda44e7b35f1cfea95adf7b4289a51e2b479", size = 1990927, upload-time = "2025-10-06T21:10:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/b1/78/562998301ff2588b9c6dcc5cb21f52fa919d6e1decc75a35055feb973594/pydantic_core-2.41.1-cp312-cp312-win_amd64.whl", hash = "sha256:377defd66ee2003748ee93c52bcef2d14fde48fe28a0b156f88c3dbf9bc49a50", size = 2034703, upload-time = "2025-10-06T21:10:48.524Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/d95699ce5a5cdb44bb470bd818b848b9beadf51459fd4ea06667e8ede862/pydantic_core-2.41.1-cp312-cp312-win_arm64.whl", hash = "sha256:c95caff279d49c1d6cdfe2996e6c2ad712571d3b9caaa209a404426c326c4bde", size = 1972719, upload-time = "2025-10-06T21:10:50.256Z" }, + { url = "https://files.pythonhosted.org/packages/27/8a/6d54198536a90a37807d31a156642aae7a8e1263ed9fe6fc6245defe9332/pydantic_core-2.41.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70e790fce5f05204ef4403159857bfcd587779da78627b0babb3654f75361ebf", size = 2105825, upload-time = "2025-10-06T21:10:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/4784fd7b22ac9c8439db25bf98ffed6853d01e7e560a346e8af821776ccc/pydantic_core-2.41.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9cebf1ca35f10930612d60bd0f78adfacee824c30a880e3534ba02c207cceceb", size = 1910126, upload-time = "2025-10-06T21:10:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/31eb0748059ba5bd0aa708fb4bab9fcb211461ddcf9e90702a6542f22d0d/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:170406a37a5bc82c22c3274616bf6f17cc7df9c4a0a0a50449e559cb755db669", size = 1961472, upload-time = "2025-10-06T21:10:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/ab/91/946527792275b5c4c7dde4cfa3e81241bf6900e9fee74fb1ba43e0c0f1ab/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12d4257fc9187a0ccd41b8b327d6a4e57281ab75e11dda66a9148ef2e1fb712f", size = 2063230, upload-time = "2025-10-06T21:10:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/31/5d/a35c5d7b414e5c0749f1d9f0d159ee2ef4bab313f499692896b918014ee3/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a75a33b4db105dd1c8d57839e17ee12db8d5ad18209e792fa325dbb4baeb00f4", size = 2229469, upload-time = "2025-10-06T21:10:59.409Z" }, + { url = "https://files.pythonhosted.org/packages/21/4d/8713737c689afa57ecfefe38db78259d4484c97aa494979e6a9d19662584/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08a589f850803a74e0fcb16a72081cafb0d72a3cdda500106942b07e76b7bf62", size = 2347986, upload-time = "2025-10-06T21:11:00.847Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/929f9a3a5ed5cda767081494bacd32f783e707a690ce6eeb5e0730ec4986/pydantic_core-2.41.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a97939d6ea44763c456bd8a617ceada2c9b96bb5b8ab3dfa0d0827df7619014", size = 2072216, upload-time = "2025-10-06T21:11:02.43Z" }, + { url = "https://files.pythonhosted.org/packages/26/55/a33f459d4f9cc8786d9db42795dbecc84fa724b290d7d71ddc3d7155d46a/pydantic_core-2.41.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2ae423c65c556f09569524b80ffd11babff61f33055ef9773d7c9fabc11ed8d", size = 2193047, upload-time = "2025-10-06T21:11:03.787Z" }, + { url = "https://files.pythonhosted.org/packages/77/af/d5c6959f8b089f2185760a2779079e3c2c411bfc70ea6111f58367851629/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:4dc703015fbf8764d6a8001c327a87f1823b7328d40b47ce6000c65918ad2b4f", size = 2140613, upload-time = "2025-10-06T21:11:05.607Z" }, + { url = "https://files.pythonhosted.org/packages/58/e5/2c19bd2a14bffe7fabcf00efbfbd3ac430aaec5271b504a938ff019ac7be/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:968e4ffdfd35698a5fe659e5e44c508b53664870a8e61c8f9d24d3d145d30257", size = 2327641, upload-time = "2025-10-06T21:11:07.143Z" }, + { url = "https://files.pythonhosted.org/packages/93/ef/e0870ccda798c54e6b100aff3c4d49df5458fd64217e860cb9c3b0a403f4/pydantic_core-2.41.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:fff2b76c8e172d34771cd4d4f0ade08072385310f214f823b5a6ad4006890d32", size = 2318229, upload-time = "2025-10-06T21:11:08.73Z" }, + { url = "https://files.pythonhosted.org/packages/b1/4b/c3b991d95f5deb24d0bd52e47bcf716098fa1afe0ce2d4bd3125b38566ba/pydantic_core-2.41.1-cp313-cp313-win32.whl", hash = "sha256:a38a5263185407ceb599f2f035faf4589d57e73c7146d64f10577f6449e8171d", size = 1997911, upload-time = "2025-10-06T21:11:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/5c316fd62e01f8d6be1b7ee6b54273214e871772997dc2c95e204997a055/pydantic_core-2.41.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42ae7fd6760782c975897e1fdc810f483b021b32245b0105d40f6e7a3803e4b", size = 2034301, upload-time = "2025-10-06T21:11:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/29/41/902640cfd6a6523194123e2c3373c60f19006447f2fb06f76de4e8466c5b/pydantic_core-2.41.1-cp313-cp313-win_arm64.whl", hash = "sha256:ad4111acc63b7384e205c27a2f15e23ac0ee21a9d77ad6f2e9cb516ec90965fb", size = 1977238, upload-time = "2025-10-06T21:11:14.1Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/28b040e88c1b89d851278478842f0bdf39c7a05da9e850333c6c8cbe7dfa/pydantic_core-2.41.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:440d0df7415b50084a4ba9d870480c16c5f67c0d1d4d5119e3f70925533a0edc", size = 1875626, upload-time = "2025-10-06T21:11:15.69Z" }, + { url = "https://files.pythonhosted.org/packages/d6/58/b41dd3087505220bb58bc81be8c3e8cbc037f5710cd3c838f44f90bdd704/pydantic_core-2.41.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71eaa38d342099405dae6484216dcf1e8e4b0bebd9b44a4e08c9b43db6a2ab67", size = 2045708, upload-time = "2025-10-06T21:11:17.258Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b8/760f23754e40bf6c65b94a69b22c394c24058a0ef7e2aa471d2e39219c1a/pydantic_core-2.41.1-cp313-cp313t-win_amd64.whl", hash = "sha256:555ecf7e50f1161d3f693bc49f23c82cf6cdeafc71fa37a06120772a09a38795", size = 1997171, upload-time = "2025-10-06T21:11:18.822Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/cec246429ddfa2778d2d6301eca5362194dc8749ecb19e621f2f65b5090f/pydantic_core-2.41.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:05226894a26f6f27e1deb735d7308f74ef5fa3a6de3e0135bb66cdcaee88f64b", size = 2107836, upload-time = "2025-10-06T21:11:20.432Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/baba47f8d8b87081302498e610aefc37142ce6a1cc98b2ab6b931a162562/pydantic_core-2.41.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:85ff7911c6c3e2fd8d3779c50925f6406d770ea58ea6dde9c230d35b52b16b4a", size = 1904449, upload-time = "2025-10-06T21:11:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/9a3d87cae2c75a5178334b10358d631bd094b916a00a5993382222dbfd92/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47f1f642a205687d59b52dc1a9a607f45e588f5a2e9eeae05edd80c7a8c47674", size = 1961750, upload-time = "2025-10-06T21:11:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/27/42/a96c9d793a04cf2a9773bff98003bb154087b94f5530a2ce6063ecfec583/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df11c24e138876ace5ec6043e5cae925e34cf38af1a1b3d63589e8f7b5f5cdc4", size = 2063305, upload-time = "2025-10-06T21:11:26.556Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8d/028c4b7d157a005b1f52c086e2d4b0067886b213c86220c1153398dbdf8f/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f0bf7f5c8f7bf345c527e8a0d72d6b26eda99c1227b0c34e7e59e181260de31", size = 2228959, upload-time = "2025-10-06T21:11:28.426Z" }, + { url = "https://files.pythonhosted.org/packages/08/f7/ee64cda8fcc9ca3f4716e6357144f9ee71166775df582a1b6b738bf6da57/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82b887a711d341c2c47352375d73b029418f55b20bd7815446d175a70effa706", size = 2345421, upload-time = "2025-10-06T21:11:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/e8ec05f0f5ee7a3656973ad9cd3bc73204af99f6512c1a4562f6fb4b3f7d/pydantic_core-2.41.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5f1d5d6bbba484bdf220c72d8ecd0be460f4bd4c5e534a541bb2cd57589fb8b", size = 2065288, upload-time = "2025-10-06T21:11:32.019Z" }, + { url = "https://files.pythonhosted.org/packages/0a/25/d77a73ff24e2e4fcea64472f5e39b0402d836da9b08b5361a734d0153023/pydantic_core-2.41.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bf1917385ebe0f968dc5c6ab1375886d56992b93ddfe6bf52bff575d03662be", size = 2189759, upload-time = "2025-10-06T21:11:33.753Z" }, + { url = "https://files.pythonhosted.org/packages/66/45/4a4ebaaae12a740552278d06fe71418c0f2869537a369a89c0e6723b341d/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f94f3ab188f44b9a73f7295663f3ecb8f2e2dd03a69c8f2ead50d37785ecb04", size = 2140747, upload-time = "2025-10-06T21:11:35.781Z" }, + { url = "https://files.pythonhosted.org/packages/da/6d/b727ce1022f143194a36593243ff244ed5a1eb3c9122296bf7e716aa37ba/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:3925446673641d37c30bd84a9d597e49f72eacee8b43322c8999fa17d5ae5bc4", size = 2327416, upload-time = "2025-10-06T21:11:37.75Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8c/02df9d8506c427787059f87c6c7253435c6895e12472a652d9616ee0fc95/pydantic_core-2.41.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:49bd51cc27adb980c7b97357ae036ce9b3c4d0bb406e84fbe16fb2d368b602a8", size = 2318138, upload-time = "2025-10-06T21:11:39.463Z" }, + { url = "https://files.pythonhosted.org/packages/98/67/0cf429a7d6802536941f430e6e3243f6d4b68f41eeea4b242372f1901794/pydantic_core-2.41.1-cp314-cp314-win32.whl", hash = "sha256:a31ca0cd0e4d12ea0df0077df2d487fc3eb9d7f96bbb13c3c5b88dcc21d05159", size = 1998429, upload-time = "2025-10-06T21:11:41.989Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/742fef93de5d085022d2302a6317a2b34dbfe15258e9396a535c8a100ae7/pydantic_core-2.41.1-cp314-cp314-win_amd64.whl", hash = "sha256:1b5c4374a152e10a22175d7790e644fbd8ff58418890e07e2073ff9d4414efae", size = 2028870, upload-time = "2025-10-06T21:11:43.66Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/cdd8ccb8555ef7720bd7715899bd6cfbe3c29198332710e1b61b8f5dd8b8/pydantic_core-2.41.1-cp314-cp314-win_arm64.whl", hash = "sha256:4fee76d757639b493eb600fba668f1e17475af34c17dd61db7a47e824d464ca9", size = 1974275, upload-time = "2025-10-06T21:11:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7e/8ac10ccb047dc0221aa2530ec3c7c05ab4656d4d4bd984ee85da7f3d5525/pydantic_core-2.41.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f9b9c968cfe5cd576fdd7361f47f27adeb120517e637d1b189eea1c3ece573f4", size = 1875124, upload-time = "2025-10-06T21:11:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/7d9791efeb9c7d97e7268f8d20e0da24d03438a7fa7163ab58f1073ba968/pydantic_core-2.41.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1ebc7ab67b856384aba09ed74e3e977dded40e693de18a4f197c67d0d4e6d8e", size = 2043075, upload-time = "2025-10-06T21:11:49.542Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c3/3f6e6b2342ac11ac8cd5cb56e24c7b14afa27c010e82a765ffa5f771884a/pydantic_core-2.41.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8ae0dc57b62a762985bc7fbf636be3412394acc0ddb4ade07fe104230f1b9762", size = 1995341, upload-time = "2025-10-06T21:11:51.497Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/90421a4749f15aa4f06dd1d25a6419b91b181ae7994a4e7c4ed0a6415057/pydantic_core-2.41.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:10ce489cf09a4956a1549af839b983edc59b0f60e1b068c21b10154e58f54f80", size = 2114974, upload-time = "2025-10-06T21:11:53.549Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/3b5c2ba43da5380f252b35f7e74851e1379f4935c8bccbbda05992b5fe4d/pydantic_core-2.41.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ff548c908caffd9455fd1342366bcf8a1ec8a3fca42f35c7fc60883d6a901074", size = 1940064, upload-time = "2025-10-06T21:11:55.268Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/050595183529316cf95d0f97662a4fe782dbea5f31dba0cf366015b67fad/pydantic_core-2.41.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d43bf082025082bda13be89a5f876cc2386b7727c7b322be2d2b706a45cea8e", size = 1976637, upload-time = "2025-10-06T21:11:57.024Z" }, + { url = "https://files.pythonhosted.org/packages/46/a8/846a8e466edd841c67f11f0ae738ca5c5d87968f6d8630bc449e2e6e11f2/pydantic_core-2.41.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:666aee751faf1c6864b2db795775dd67b61fdcf646abefa309ed1da039a97209", size = 2069274, upload-time = "2025-10-06T21:11:59.129Z" }, + { url = "https://files.pythonhosted.org/packages/4c/dc/19d01747082daf3667f952b6deee73e9e63338caa9c61442558cbdf8c876/pydantic_core-2.41.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b83aaeff0d7bde852c32e856f3ee410842ebc08bc55c510771d87dcd1c01e1ed", size = 2255302, upload-time = "2025-10-07T10:49:36.917Z" }, + { url = "https://files.pythonhosted.org/packages/fa/99/0d4f031aeddf2cf03a5eb8eafde50147259067716c32174551b786aa72e1/pydantic_core-2.41.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:055c7931b0329cb8acde20cdde6d9c2cbc2a02a0a8e54a792cddd91e2ea92c65", size = 2386549, upload-time = "2025-10-07T10:49:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/09/7f/027061a060718733a6c016e7d4acc864c8bb69f0092d9b3da7e3888b102f/pydantic_core-2.41.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:530bbb1347e3e5ca13a91ac087c4971d7da09630ef8febd27a20a10800c2d06d", size = 2079817, upload-time = "2025-10-07T10:49:41.409Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/791c16d5e2a0b394c2c236f7d2556dbc381f8666bc12db7d35dc051c67e3/pydantic_core-2.41.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65a0ea16cfea7bfa9e43604c8bd726e63a3788b61c384c37664b55209fcb1d74", size = 2196276, upload-time = "2025-10-07T10:49:43.367Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/2c7010145da82fdd30955c1c0e1e75723ca7aef32b52f2565383fd2347d2/pydantic_core-2.41.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8fa93fadff794c6d15c345c560513b160197342275c6d104cc879f932b978afc", size = 2157417, upload-time = "2025-10-07T10:49:45.176Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/b8f2ac7fa15479e989d0c2ea88e5e28eeb923096b2462804b9113bce51b5/pydantic_core-2.41.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:c8a1af9ac51969a494c6a82b563abae6859dc082d3b999e8fa7ba5ee1b05e8e8", size = 2333884, upload-time = "2025-10-07T10:49:46.896Z" }, + { url = "https://files.pythonhosted.org/packages/60/e8/06387d852bf67402fb0129b3297aa0c358aa9647e59f795c0965a7bedefe/pydantic_core-2.41.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:30edab28829703f876897c9471a857e43d847b8799c3c9e2fbce644724b50aa4", size = 2330735, upload-time = "2025-10-07T10:49:48.79Z" }, + { url = "https://files.pythonhosted.org/packages/07/41/8964006fd8897df13cb0eec374bda053d1936cbc81315acdd755d85c99d5/pydantic_core-2.41.1-cp39-cp39-win32.whl", hash = "sha256:84d0ff869f98be2e93efdf1ae31e5a15f0926d22af8677d51676e373abbfe57a", size = 1992855, upload-time = "2025-10-07T10:49:50.806Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c9/0f68c22ba0cac693326a7de73f04c7543886e0b240e2320f8ced861f0c3d/pydantic_core-2.41.1-cp39-cp39-win_amd64.whl", hash = "sha256:b5674314987cdde5a5511b029fa5fb1556b3d147a367e01dd583b19cfa8e35df", size = 2030219, upload-time = "2025-10-07T10:49:52.712Z" }, + { url = "https://files.pythonhosted.org/packages/16/89/d0afad37ba25f5801735af1472e650b86baad9fe807a42076508e4824a2a/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:68f2251559b8efa99041bb63571ec7cdd2d715ba74cc82b3bc9eff824ebc8bf0", size = 2124001, upload-time = "2025-10-07T10:49:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c4/08609134b34520568ddebb084d9ed0a2a3f5f52b45739e6e22cb3a7112eb/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:c7bc140c596097cb53b30546ca257dbe3f19282283190b1b5142928e5d5d3a20", size = 1941841, upload-time = "2025-10-07T10:49:56.248Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/94a4877094e5fe19a3f37e7e817772263e2c573c94f1e3fa2b1eee56ef3b/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2896510fce8f4725ec518f8b9d7f015a00db249d2fd40788f442af303480063d", size = 1961129, upload-time = "2025-10-07T10:49:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/a2/30/23a224d7e25260eb5f69783a63667453037e07eb91ff0e62dabaadd47128/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ced20e62cfa0f496ba68fa5d6c7ee71114ea67e2a5da3114d6450d7f4683572a", size = 2148770, upload-time = "2025-10-07T10:49:59.959Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3e/a51c5f5d37b9288ba30683d6e96f10fa8f1defad1623ff09f1020973b577/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b04fa9ed049461a7398138c604b00550bc89e3e1151d84b81ad6dc93e39c4c06", size = 2115344, upload-time = "2025-10-07T10:50:02.466Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/389504c9e0600ef4502cd5238396b527afe6ef8981a6a15cd1814fc7b434/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b3b7d9cfbfdc43c80a16638c6dc2768e3956e73031fca64e8e1a3ae744d1faeb", size = 1927994, upload-time = "2025-10-07T10:50:04.379Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9c/5111c6b128861cb792a4c082677e90dac4f2e090bb2e2fe06aa5b2d39027/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eec83fc6abef04c7f9bec616e2d76ee9a6a4ae2a359b10c21d0f680e24a247ca", size = 1959394, upload-time = "2025-10-07T10:50:06.335Z" }, + { url = "https://files.pythonhosted.org/packages/14/3f/cfec8b9a0c48ce5d64409ec5e1903cb0b7363da38f14b41de2fcb3712700/pydantic_core-2.41.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6771a2d9f83c4038dfad5970a3eef215940682b2175e32bcc817bdc639019b28", size = 2147365, upload-time = "2025-10-07T10:50:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/f403d7ca8352e3e4df352ccacd200f5f7f7fe81cef8e458515f015091625/pydantic_core-2.41.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:fabcbdb12de6eada8d6e9a759097adb3c15440fafc675b3e94ae5c9cb8d678a0", size = 2114268, upload-time = "2025-10-07T10:50:10.257Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b5/334473b6d2810df84db67f03d4f666acacfc538512c2d2a254074fee0889/pydantic_core-2.41.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:80e97ccfaf0aaf67d55de5085b0ed0d994f57747d9d03f2de5cc9847ca737b08", size = 1935786, upload-time = "2025-10-07T10:50:12.333Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/45513e4dc621f47397cfa5fef12ba8fa5e8b1c4c07f2ff2a5fef8ff81b25/pydantic_core-2.41.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34df1fe8fea5d332484a763702e8b6a54048a9d4fe6ccf41e34a128238e01f52", size = 1971995, upload-time = "2025-10-07T10:50:14.071Z" }, + { url = "https://files.pythonhosted.org/packages/22/e3/f1797c168e5f52b973bed1c585e99827a22d5e579d1ed57d51bc15b14633/pydantic_core-2.41.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:421b5595f845842fc093f7250e24ee395f54ca62d494fdde96f43ecf9228ae01", size = 2191264, upload-time = "2025-10-07T10:50:15.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e1/24ef4c3b4ab91c21c3a09a966c7d2cffe101058a7bfe5cc8b2c7c7d574e2/pydantic_core-2.41.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dce8b22663c134583aaad24827863306a933f576c79da450be3984924e2031d1", size = 2152430, upload-time = "2025-10-07T10:50:18.018Z" }, + { url = "https://files.pythonhosted.org/packages/35/74/70c1e225d67f7ef3fdba02c506d9011efaf734020914920b2aa3d1a45e61/pydantic_core-2.41.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:300a9c162fea9906cc5c103893ca2602afd84f0ec90d3be36f4cc360125d22e1", size = 2324691, upload-time = "2025-10-07T10:50:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/dd4d21037c8bef0d8cce90a86a3f2dcb011c30086db2a10113c3eea23eba/pydantic_core-2.41.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e019167628f6e6161ae7ab9fb70f6d076a0bf0d55aa9b20833f86a320c70dd65", size = 2324493, upload-time = "2025-10-07T10:50:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/7e/78/3093b334e9c9796c8236a4701cd2ddef1c56fb0928fe282a10c797644380/pydantic_core-2.41.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:13ab9cc2de6f9d4ab645a050ae5aee61a2424ac4d3a16ba23d4c2027705e0301", size = 2146156, upload-time = "2025-10-07T10:50:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6c/fa3e45c2b054a1e627a89a364917f12cbe3abc3e91b9004edaae16e7b3c5/pydantic_core-2.41.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:af2385d3f98243fb733862f806c5bb9122e5fba05b373e3af40e3c82d711cef1", size = 2112094, upload-time = "2025-10-07T10:50:25.513Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/7eebc38b4658cc8e6902d0befc26388e4c2a5f2e179c561eeb43e1922c7b/pydantic_core-2.41.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6550617a0c2115be56f90c31a5370261d8ce9dbf051c3ed53b51172dd34da696", size = 1935300, upload-time = "2025-10-07T10:50:27.715Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/9fe640194a1717a464ab861d43595c268830f98cb1e2705aa134b3544b70/pydantic_core-2.41.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc17b6ecf4983d298686014c92ebc955a9f9baf9f57dad4065e7906e7bee6222", size = 1970417, upload-time = "2025-10-07T10:50:29.573Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ad/f4cdfaf483b78ee65362363e73b6b40c48e067078d7b146e8816d5945ad6/pydantic_core-2.41.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42ae9352cf211f08b04ea110563d6b1e415878eea5b4c70f6bdb17dca3b932d2", size = 2190745, upload-time = "2025-10-07T10:50:31.48Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/18f416d40a10f44e9387497ba449f40fdb1478c61ba05c4b6bdb82300362/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e82947de92068b0a21681a13dd2102387197092fbe7defcfb8453e0913866506", size = 2150888, upload-time = "2025-10-07T10:50:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/42/30/134c8a921630d8a88d6f905a562495a6421e959a23c19b0f49b660801d67/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e244c37d5471c9acdcd282890c6c4c83747b77238bfa19429b8473586c907656", size = 2324489, upload-time = "2025-10-07T10:50:36.48Z" }, + { url = "https://files.pythonhosted.org/packages/9c/48/a9263aeaebdec81e941198525b43edb3b44f27cfa4cb8005b8d3eb8dec72/pydantic_core-2.41.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1e798b4b304a995110d41ec93653e57975620ccb2842ba9420037985e7d7284e", size = 2322763, upload-time = "2025-10-07T10:50:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/755d2bd2593f701c5839fc084e9c2c5e2418f460383ad04e3b5d0befc3ca/pydantic_core-2.41.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f1fc716c0eb1663c59699b024428ad5ec2bcc6b928527b8fe28de6cb89f47efb", size = 2144046, upload-time = "2025-10-07T10:50:40.686Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -857,6 +1064,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -923,6 +1139,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" From 69d673c38155f7bb0a28c8ec4de4021f019cfa67 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 7 Oct 2025 18:10:24 -0500 Subject: [PATCH 13/51] uv: Add helper modules for pytest --- pyproject.toml | 6 +++ uv.lock | 116 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 20c69681..b8ae4892 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,12 @@ dev = [ "mypy>=1.18.2", "pip-audit>=2.7.3", "pyright>=1.1.406", + "pytest-md>=0.2.0", + "pytest-emoji>=0.2.0", + "pytest-dotenv>=0.5.2", + "pytest-asyncio>=1.2.0", + "pytest-rerunfailures>=16.0.1", + "pytest-xdist>=3.8.0", ] [tool.mypy] diff --git a/uv.lock b/uv.lock index f72900d6..dde3ba97 100644 --- a/uv.lock +++ b/uv.lock @@ -30,6 +30,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "boolean-py" version = "5.0" @@ -320,6 +329,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, +] + [[package]] name = "filelock" version = "3.19.1" @@ -614,7 +632,13 @@ dev = [ { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-dotenv" }, + { name = "pytest-emoji" }, + { name = "pytest-md" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -631,7 +655,13 @@ dev = [ { name = "pre-commit", specifier = ">=3.7.0" }, { name = "pyright", specifier = ">=1.1.406" }, { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "pytest-dotenv", specifier = ">=0.5.2" }, + { name = "pytest-emoji", specifier = ">=0.2.0" }, + { name = "pytest-md", specifier = ">=0.2.0" }, + { name = "pytest-rerunfailures", specifier = ">=16.0.1" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.6.9" }, ] @@ -922,6 +952,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -936,6 +980,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-dotenv" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/b0/cafee9c627c1bae228eb07c9977f679b3a7cb111b488307ab9594ba9e4da/pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732", size = 3782, upload-time = "2020-06-16T12:38:03.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993, upload-time = "2020-06-16T12:38:01.139Z" }, +] + +[[package]] +name = "pytest-emoji" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/4d/d489f939f0717a034cea7955d36bc2a7a5ba1b263871e63ad8cb16d47555/pytest-emoji-0.2.0.tar.gz", hash = "sha256:e1bd4790d87649c2d09c272c88bdfc4d37c1cc7c7a46583087d7c510944571e8", size = 6171, upload-time = "2019-02-19T09:33:17.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/51/80af966c0aded877da7577d21c4601ca98c6f603c6e6073ddea071af01ec/pytest_emoji-0.2.0-py3-none-any.whl", hash = "sha256:6e34ed21970fa4b80a56ad11417456bd873eb066c02315fe9df0fafe6d4d4436", size = 5664, upload-time = "2019-02-19T09:33:15.771Z" }, +] + +[[package]] +name = "pytest-md" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/55/1d4248f08a97255abb23b05d8ba07586333194fadb17beda96b707aebecd/pytest-md-0.2.0.tar.gz", hash = "sha256:3b248d5b360ea5198e05b4f49c7442234812809a63137ec6cdd3643a40cf0112", size = 5985, upload-time = "2019-07-11T08:15:59.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/71/23d03f57c18116c6770141478e33b3500c4e92500cf4b49a396e9226733f/pytest_md-0.2.0-py3-none-any.whl", hash = "sha256:4c4cd16fea6d1485e87ee254558712c804a96d2aa9674b780e7eb8fb6526e1d1", size = 6117, upload-time = "2019-07-11T08:15:57.829Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/53/a543a76f922a5337d10df22441af8bf68f1b421cadf9aedf8a77943b81f6/pytest_rerunfailures-16.0.1.tar.gz", hash = "sha256:ed4b3a6e7badb0a720ddd93f9de1e124ba99a0cb13bc88561b3c168c16062559", size = 27612, upload-time = "2025-09-02T06:48:25.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/73/67dc14cda1942914e70fbb117fceaf11e259362c517bdadd76b0dd752524/pytest_rerunfailures-16.0.1-py3-none-any.whl", hash = "sha256:0bccc0e3b0e3388275c25a100f7077081318196569a121217688ed05e58984b9", size = 13610, upload-time = "2025-09-02T06:48:23.615Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" From 7f0a898edcbe54cdeb840c7ef1019348b82c822f Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 7 Oct 2025 18:16:59 -0500 Subject: [PATCH 14/51] wip initial cut of interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduces typed sync and async clients for the pdfRest API with configurable base URL, API key (with env var fallback), custom headers, and timeouts. - Adds centralized, library-specific exceptions, translating HTTP layer timeout, transport, and request errors, and wrapping non-2xx API responses with status and message details. - Validates and normalizes requests using internal Pydantic models, including header/param coercion, endpoint shape checks, and timeout handling. - Implements an “up” endpoint method in both clients that returns a validated health/metadata response model. - Updates the package exports to expose clients, request option types, response model, and error helpers. Assisted-by: Codex --- src/pdfrest/__init__.py | 28 ++- src/pdfrest/client.py | 402 ++++++++++++++++++++++++++++++++++++++ src/pdfrest/exceptions.py | 74 +++++++ src/pdfrest/models.py | 27 +++ tests/test_client.py | 189 ++++++++++++++++++ 5 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 src/pdfrest/client.py create mode 100644 src/pdfrest/exceptions.py create mode 100644 src/pdfrest/models.py create mode 100644 tests/test_client.py diff --git a/src/pdfrest/__init__.py b/src/pdfrest/__init__.py index 15cff104..9ee6d0b5 100644 --- a/src/pdfrest/__init__.py +++ b/src/pdfrest/__init__.py @@ -2,7 +2,33 @@ from importlib import metadata -__all__ = ("__version__",) +from .client import AsyncPdfRestClient, PdfRestClient, RequestOptions, UpRequestOptions +from .exceptions import ( + PdfRestApiError, + PdfRestConfigurationError, + PdfRestError, + PdfRestRequestError, + PdfRestTimeoutError, + PdfRestTransportError, + translate_httpx_error, +) +from .models import UpResponse + +__all__ = ( + "AsyncPdfRestClient", + "PdfRestApiError", + "PdfRestClient", + "PdfRestConfigurationError", + "PdfRestError", + "PdfRestRequestError", + "PdfRestTimeoutError", + "PdfRestTransportError", + "RequestOptions", + "UpRequestOptions", + "UpResponse", + "__version__", + "translate_httpx_error", +) try: # pragma: no cover - fallback should never run in production builds __version__ = metadata.version("pdfrest") diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py new file mode 100644 index 00000000..fa999364 --- /dev/null +++ b/src/pdfrest/client.py @@ -0,0 +1,402 @@ +"""Sync and async client interfaces for the pdfrest API.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from typing import Any, Generic, Literal, TypedDict, TypeVar, cast + +import httpx +from httpx import URL +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from .exceptions import ( + PdfRestApiError, + PdfRestConfigurationError, + translate_httpx_error, +) +from .models import PdfRestErrorResponse, UpResponse + +__all__ = ("AsyncPdfRestClient", "PdfRestClient", "RequestOptions", "UpRequestOptions") + +DEFAULT_BASE_URL = "https://api.pdfrest.com" +API_KEY_ENV_VAR = "PDFREST_API_KEY" +DEFAULT_TIMEOUT_SECONDS = 10.0 + +HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] +QueryParamValue = str | int | float | bool | None +TimeoutTypes = float | httpx.Timeout | None + + +ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient) + + +class RequestOptions(TypedDict, total=False): + """Shared request customisation options for pdfrest endpoints.""" + + headers: Mapping[str, str] + params: Mapping[str, QueryParamValue] + timeout: float | httpx.Timeout + + +UpRequestOptions = RequestOptions + + +class _ClientConfig(BaseModel): + """Internal representation of client configuration validated by Pydantic.""" + + base_url: URL + api_key: str + timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS + headers: dict[str, str] = Field(default_factory=dict) + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @field_validator("base_url", mode="before") + @classmethod + def _parse_base_url(cls, value: Any) -> URL: + url_value = value or DEFAULT_BASE_URL + url = URL(str(url_value)) + if url.scheme not in {"http", "https"}: + msg = "base_url must use http or https scheme." + raise PdfRestConfigurationError(msg) + return ( + url + if not url.path or url.path == "/" + else url.copy_with(path=url.path.rstrip("/")) + ) + + @field_validator("api_key") + @classmethod + def _validate_api_key(cls, value: str) -> str: + if not value or not value.strip(): + msg = "API key must not be empty." + raise PdfRestConfigurationError(msg) + return value.strip() + + @field_validator("headers", mode="before") + @classmethod + def _validate_headers(cls, value: Any) -> dict[str, str]: + if value is None: + return {} + converted: dict[str, str] = {} + for key, item in dict(value).items(): + converted[str(key)] = str(item) + return converted + + @field_validator("timeout", mode="before") + @classmethod + def _validate_timeout(cls, value: Any) -> TimeoutTypes: + if value is None: + return DEFAULT_TIMEOUT_SECONDS + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, httpx.Timeout): + return value + msg = "timeout must be a float (seconds) or httpx.Timeout instance." + raise PdfRestConfigurationError(msg) + + +class _RequestModel(BaseModel): + """Internal request data validated prior to dispatch.""" + + method: HttpMethod + endpoint: str + params: dict[str, str] | None = None + headers: dict[str, str] = Field(default_factory=dict) + timeout: TimeoutTypes + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @field_validator("endpoint") + @classmethod + def _validate_endpoint(cls, value: str) -> str: + if not value.startswith("/"): + msg = "endpoint must start with '/'." + raise PdfRestConfigurationError(msg) + return value + + @field_validator("params", mode="before") + @classmethod + def _normalize_params(cls, value: Any) -> dict[str, str]: + if value is None: + return {} + normalized: dict[str, str] = {} + for key, candidate in dict(value).items(): + if candidate is None: + continue + normalized[str(key)] = str(candidate) + return normalized + + @field_validator("headers", mode="before") + @classmethod + def _normalize_headers(cls, value: Any) -> dict[str, str]: + if value is None: + return {} + normalized: dict[str, str] = {} + for key, candidate in dict(value).items(): + normalized[str(key)] = str(candidate) + return normalized + + +class _BaseApiClient(Generic[ClientType]): + """Shared logic between sync and async client variants.""" + + _config: _ClientConfig + _client: ClientType + _owns_http_client: bool + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | URL | None = None, + timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, + headers: Mapping[str, str] | None = None, + ) -> None: + resolved_api_key = (api_key or os.getenv(API_KEY_ENV_VAR) or "").strip() + if not resolved_api_key: + msg = "API key was not provided and the PDFREST_API_KEY environment variable is not set." + raise PdfRestConfigurationError(msg) + + default_headers: dict[str, str] = { + "Authorization": f"Bearer {resolved_api_key}", + "Accept": "application/json", + } + if headers: + for key, value in headers.items(): + default_headers[str(key)] = str(value) + + resolved_base_url = ( + URL(str(base_url)) if base_url is not None else URL(DEFAULT_BASE_URL) + ) + + try: + self._config = _ClientConfig( + base_url=resolved_base_url, + api_key=resolved_api_key, + timeout=timeout, + headers=default_headers, + ) + except PdfRestConfigurationError: + raise + except ValidationError as exc: # pragma: no cover - defensive + raise PdfRestConfigurationError(str(exc)) from exc + + @property + def base_url(self) -> URL: + """Resolved base URL for the client.""" + + return self._config.base_url + + def _prepare_request( + self, method: HttpMethod, endpoint: str, options: RequestOptions | None = None + ) -> _RequestModel: + option_dict: dict[str, Any] = dict(options or {}) + combined_headers: dict[str, str] = dict(self._config.headers) + if "headers" in option_dict and option_dict["headers"] is not None: + combined_headers.update( + { + str(key): str(value) + for key, value in cast( + Mapping[str, Any], option_dict["headers"] + ).items() + } + ) + + params_dict: dict[str, str] | None = None + if "params" in option_dict and option_dict["params"] is not None: + raw_params = cast(Mapping[str, QueryParamValue], option_dict["params"]) + converted_params: dict[str, str] = {} + for key, value in raw_params.items(): + if value is None: + continue + converted_params[str(key)] = str(value) + params_dict = converted_params if converted_params else None + + timeout_override = option_dict.get("timeout") + timeout_value = ( + timeout_override if timeout_override is not None else self._config.timeout + ) + + try: + request = _RequestModel( + method=method, + endpoint=endpoint, + params=params_dict, + headers=combined_headers, + timeout=timeout_value, + ) + except PdfRestConfigurationError: + raise + except ValidationError as exc: # pragma: no cover - defensive + raise PdfRestConfigurationError(str(exc)) from exc + return request + + def _handle_response(self, response: httpx.Response) -> Any: + if response.is_success: + return self._decode_json(response) + error_payload: Any = None + message: str | None = None + try: + pdfrest_error = PdfRestErrorResponse.model_validate_json(response.content) + message = pdfrest_error.error + except ValidationError: + error_payload = response.text + raise PdfRestApiError( + response.status_code, message=message, response_content=error_payload + ) + + def _decode_json(self, response: httpx.Response) -> Any: + try: + return response.json() + except ValueError as exc: + raise PdfRestApiError( + response.status_code, + message="Response body is not valid JSON.", + response_content=response.text, + ) from exc + + +class _SyncApiClient(_BaseApiClient[httpx.Client]): + """Internal synchronous client implementation.""" + + _client: httpx.Client + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | URL | None = None, + timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, + headers: Mapping[str, str] | None = None, + http_client: httpx.Client | None = None, + transport: httpx.BaseTransport | None = None, + ) -> None: + super().__init__( + api_key=api_key, + base_url=base_url, + timeout=timeout, + headers=headers, + ) + self._owns_http_client = http_client is None + self._client = http_client or httpx.Client( + base_url=self.base_url, + headers=dict(self._config.headers), + timeout=self._config.timeout, + transport=transport, + ) + + def close(self) -> None: + if self._owns_http_client: + self._client.close() + + def __enter__(self) -> _SyncApiClient: + return self + + def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + self.close() + + def _send_request(self, request: _RequestModel) -> Any: + http_client = self._client + try: + response = http_client.request( + method=request.method, + url=request.endpoint, + params=request.params or None, + headers=request.headers or None, + timeout=request.timeout, + ) + except httpx.HTTPError as exc: + raise translate_httpx_error(exc) from exc + return self._handle_response(response) + + +class _AsyncApiClient(_BaseApiClient[httpx.AsyncClient]): + """Internal asynchronous client implementation.""" + + _client: httpx.AsyncClient + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | URL | None = None, + timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, + headers: Mapping[str, str] | None = None, + http_client: httpx.AsyncClient | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + super().__init__( + api_key=api_key, + base_url=base_url, + timeout=timeout, + headers=headers, + ) + self._owns_http_client = http_client is None + self._client = http_client or httpx.AsyncClient( + base_url=self.base_url, + headers=dict(self._config.headers), + timeout=self._config.timeout, + transport=transport, + ) + + async def aclose(self) -> None: + if self._owns_http_client: + await self._client.aclose() + + async def __aenter__(self) -> _AsyncApiClient: + return self + + async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + await self.aclose() + + async def _send_request(self, request: _RequestModel) -> Any: + http_client = self._client + try: + response = await http_client.request( + method=request.method, + url=request.endpoint, + params=request.params or None, + headers=request.headers or None, + timeout=request.timeout, + ) + except httpx.HTTPError as exc: + raise translate_httpx_error(exc) from exc + return self._handle_response(response) + + +class PdfRestClient(_SyncApiClient): + """Synchronous client for interacting with the pdfrest API.""" + + def __enter__(self) -> PdfRestClient: + super().__enter__() + return self + + def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + super().__exit__(exc_type, exc, traceback) + + def up(self, options: UpRequestOptions | None = None) -> UpResponse: + """Call the `/up` health endpoint and return server metadata.""" + + request = self._prepare_request("GET", "/up", options) + payload = self._send_request(request) + return UpResponse.model_validate(payload) + + +class AsyncPdfRestClient(_AsyncApiClient): + """Asynchronous client for interacting with the pdfrest API.""" + + async def __aenter__(self) -> AsyncPdfRestClient: + await super().__aenter__() + return self + + async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + await super().__aexit__(exc_type, exc, traceback) + + async def up(self, options: UpRequestOptions | None = None) -> UpResponse: + """Call the `/up` health endpoint asynchronously and return server metadata.""" + + request = self._prepare_request("GET", "/up", options) + payload = await self._send_request(request) + return UpResponse.model_validate(payload) diff --git a/src/pdfrest/exceptions.py b/src/pdfrest/exceptions.py new file mode 100644 index 00000000..cd1b96de --- /dev/null +++ b/src/pdfrest/exceptions.py @@ -0,0 +1,74 @@ +"""Library-specific exception types for the pdfrest client.""" + +from __future__ import annotations + +from typing import Any + +import httpx + +__all__ = ( + "PdfRestApiError", + "PdfRestConfigurationError", + "PdfRestError", + "PdfRestRequestError", + "PdfRestTimeoutError", + "PdfRestTransportError", + "translate_httpx_error", +) + + +class PdfRestError(Exception): + """Base exception for all pdfrest client errors.""" + + +class PdfRestConfigurationError(PdfRestError): + """Raised when the client is misconfigured (for example, missing API key).""" + + +class PdfRestTimeoutError(PdfRestError): + """Raised when a request to pdfrest exceeds the configured timeout.""" + + +class PdfRestTransportError(PdfRestError): + """Raised when a transport-level error occurs while communicating with pdfrest.""" + + +class PdfRestRequestError(PdfRestError): + """Raised when the request fails before receiving a response.""" + + +class PdfRestApiError(PdfRestError): + """Raised when the pdfrest API returns a non-successful response.""" + + def __init__( + self, + status_code: int, + message: str | None = None, + response_content: Any | None = None, + ) -> None: + self.status_code = status_code + self.response_content = response_content + detail = message or f"pdfRest API returned status code {status_code}" + super().__init__(detail) + + def __str__(self) -> str: # pragma: no cover - mirrors Exception.__str__ + base = super().__str__() + if self.response_content is None: + return base + return f"{base}: {self.response_content}" + + +def translate_httpx_error(exc: httpx.HTTPError) -> PdfRestError: + """Convert an httpx exception into a library-specific exception.""" + + if isinstance(exc, httpx.TimeoutException): + return PdfRestTimeoutError( + str(exc) or "Request timed out while calling pdfRest." + ) + if isinstance(exc, httpx.TransportError): + return PdfRestTransportError( + str(exc) or "Transport-level error while calling pdfRest." + ) + return PdfRestRequestError( + str(exc) or "Request failed before receiving a response from pdfRest." + ) diff --git a/src/pdfrest/models.py b/src/pdfrest/models.py new file mode 100644 index 00000000..5ed1beb7 --- /dev/null +++ b/src/pdfrest/models.py @@ -0,0 +1,27 @@ +"""Pydantic models for pdfrest API payloads.""" + +from __future__ import annotations + +from datetime import date + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = ("UpResponse",) + + +class UpResponse(BaseModel): + """Response payload returned by the `/up` health endpoint.""" + + status: str + product: str + release_date: date = Field(alias="releaseDate") + version: str + + model_config = ConfigDict(populate_by_name=True, frozen=True) + + +class PdfRestErrorResponse(BaseModel): + """Error response payloads from pdfRest.""" + + error: str | None = Field(alias="message") + model_config = ConfigDict(extra="allow", frozen=True) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 00000000..352e1b08 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import os +from datetime import date +from typing import Any + +import httpx +import pytest + +from pdfrest import ( + AsyncPdfRestClient, + PdfRestApiError, + PdfRestClient, + PdfRestConfigurationError, + PdfRestTimeoutError, + RequestOptions, + UpResponse, +) + +LIVE_BASE_URL_CANDIDATES: tuple[str, ...] = ( + "http://localhost:3000", + "https://apidev.pdfrest.com", + "https://api.pdfrest.com", +) + + +def _build_up_response() -> dict[str, Any]: + return { + "status": "OK", + "product": "pdfRest API Toolkit", + "releaseDate": "2025-09-25", + "version": "2.31.1", + } + + +def test_client_uses_provided_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["Authorization"] == "Bearer explicit-key" + assert request.url.path == "/up" + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(api_key="explicit-key", transport=transport) + try: + response = client.up() + finally: + client.close() + + assert isinstance(response, UpResponse) + assert response.release_date == date(2025, 9, 25) + + +def test_client_reads_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PDFREST_API_KEY", "environment-key") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["Authorization"] == "Bearer environment-key" + assert request.url.host == "example.com" + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(base_url="https://example.com", transport=transport) + try: + response = client.up() + finally: + client.close() + + assert response.product == "pdfRest API Toolkit" + + +def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + with pytest.raises(PdfRestConfigurationError): + PdfRestClient() + + +def test_up_with_custom_headers(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["Authorization"] == "Bearer custom-key" + assert request.headers["X-Test-Header"] == "value" + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(api_key="custom-key", transport=transport) + options: RequestOptions = {"headers": {"X-Test-Header": "value"}} + try: + response = client.up(options=options) + finally: + client.close() + + assert response.version == "2.31.1" + + +def test_client_raises_for_non_success_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PDFREST_API_KEY", "key") + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"message": "server error"}) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(transport=transport) + + with pytest.raises(PdfRestApiError) as exc_info: + client.up() + + client.close() + assert exc_info.value.status_code == 500 + + +@pytest.mark.asyncio +async def test_async_client_up(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PDFREST_API_KEY", "async-key") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["Authorization"] == "Bearer async-key" + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + client = AsyncPdfRestClient(transport=transport) + async with client: + response = await client.up() + + assert response.status == "OK" + + +@pytest.mark.asyncio +async def test_async_client_translates_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PDFREST_API_KEY", "async-key") + + def handler(_: httpx.Request) -> httpx.Response: + message = "timeout" + raise httpx.TimeoutException(message) + + transport = httpx.MockTransport(handler) + client = AsyncPdfRestClient(transport=transport) + + with pytest.raises(PdfRestTimeoutError): + async with client: + await client.up() + + +@pytest.fixture(scope="session") +def pdfrest_api_key() -> str: + key = os.getenv("PDFREST_API_KEY") + if not key: + pytest.fail("PDFREST_API_KEY is not configured.") + return key + + +@pytest.fixture(scope="session") +def pdfrest_live_base_url(pdfrest_api_key: str) -> str: + headers = {"Authorization": f"Bearer {pdfrest_api_key}"} + timeout = httpx.Timeout(2.0) + for base_url in LIVE_BASE_URL_CANDIDATES: + try: + with httpx.Client(base_url=base_url, timeout=timeout) as client: + response = client.get("/up", headers=headers) + except httpx.HTTPError: + continue + if response.is_success: + return base_url + pytest.fail("No reachable pdfRest API instance for live tests.") + + +def test_live_client_up(pdfrest_api_key: str, pdfrest_live_base_url: str) -> None: + client = PdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) + try: + response = client.up() + finally: + client.close() + assert response.status.upper() == "OK" + assert response.product + + +@pytest.mark.asyncio +async def test_live_async_client_up( + pdfrest_api_key: str, pdfrest_live_base_url: str +) -> None: + client = AsyncPdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) + async with client: + response = await client.up() + assert response.version From baab4f2e3715781bd5d20c94292d379373c17b11 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 9 Oct 2025 10:38:33 -0500 Subject: [PATCH 15/51] Drop Python 3.9 support in favor of 3.14 - Python 3.9 is EOL after its last security release in October 2025. - Python 3.14 is newly released. - Change version ranges to support Python 3.10-3.14 - Use uv-build up to 0.10.0. - Test Python 3.14 in CI --- .github/workflows/test-and-publish.yml | 2 +- pyproject.toml | 10 +-- uv.lock | 90 +------------------------- 3 files changed, 10 insertions(+), 92 deletions(-) diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index aa8200e0..164f0ef6 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] permissions: id-token: write contents: read diff --git a/pyproject.toml b/pyproject.toml index b8ae4892..de196f83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,14 +6,14 @@ readme = "README.md" authors = [ {name = "Datalogics"}, ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "httpx>=0.28.1", "pydantic>=2.12.0", ] [build-system] -requires = ["uv_build>=0.8.22,<0.9.0"] +requires = ["uv_build>=0.8.22,<0.10.0"] build-backend = "uv_build" [dependency-groups] @@ -34,7 +34,7 @@ dev = [ ] [tool.mypy] -python_version = "3.9" +python_version = "3.10" warn_return_any = true pretty = true exclude = [ @@ -53,6 +53,8 @@ fixed_format_cache = true [tool.pyright] venvPath = "." venv = ".venv" +pythonVersion = "3.10" +typeCheckingMode = "strict" [tool.pytest.ini_options] minversion = "7.4" @@ -61,7 +63,7 @@ addopts = "-ra" [tool.ruff] extend-include = ["*.ipynb"] -target-version = "py39" +target-version = "py310" [tool.ruff.lint] # Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default. diff --git a/uv.lock b/uv.lock index dde3ba97..0cc1deec 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.9" -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version < '3.10'", -] +requires-python = ">=3.10" [[package]] name = "annotated-types" @@ -145,17 +141,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520, upload-time = "2025-08-09T07:57:11.026Z" }, - { url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307, upload-time = "2025-08-09T07:57:12.4Z" }, - { url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448, upload-time = "2025-08-09T07:57:13.712Z" }, - { url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758, upload-time = "2025-08-09T07:57:14.979Z" }, - { url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487, upload-time = "2025-08-09T07:57:16.332Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054, upload-time = "2025-08-09T07:57:17.576Z" }, - { url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703, upload-time = "2025-08-09T07:57:20.012Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096, upload-time = "2025-08-09T07:57:21.329Z" }, - { url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852, upload-time = "2025-08-09T07:57:22.608Z" }, - { url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840, upload-time = "2025-08-09T07:57:23.883Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438, upload-time = "2025-08-09T07:57:25.287Z" }, { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] @@ -264,18 +249,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, - { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, - { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, - { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, - { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, - { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, - { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, - { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, ] @@ -423,30 +396,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, ] -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "mdurl", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.10'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -508,16 +463,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" }, { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" }, { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, - { url = "https://files.pythonhosted.org/packages/1f/bd/0792be119d7fe7dc2148689ef65c90507d82d20a204aab3b98c74a1f8684/msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b", size = 81882, upload-time = "2025-06-13T06:52:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/75/77/ce06c8e26a816ae8730a8e030d263c5289adcaff9f0476f9b270bdd7c5c2/msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232", size = 78414, upload-time = "2025-06-13T06:52:40.341Z" }, - { url = "https://files.pythonhosted.org/packages/73/27/190576c497677fb4a0d05d896b24aea6cdccd910f206aaa7b511901befed/msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf", size = 400927, upload-time = "2025-06-13T06:52:41.399Z" }, - { url = "https://files.pythonhosted.org/packages/ed/af/6a0aa5a06762e70726ec3c10fb966600d84a7220b52635cb0ab2dc64d32f/msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf", size = 405903, upload-time = "2025-06-13T06:52:42.699Z" }, - { url = "https://files.pythonhosted.org/packages/1e/80/3f3da358cecbbe8eb12360814bd1277d59d2608485934742a074d99894a9/msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90", size = 393192, upload-time = "2025-06-13T06:52:43.986Z" }, - { url = "https://files.pythonhosted.org/packages/98/c6/3a0ec7fdebbb4f3f8f254696cd91d491c29c501dbebd86286c17e8f68cd7/msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1", size = 393851, upload-time = "2025-06-13T06:52:45.177Z" }, - { url = "https://files.pythonhosted.org/packages/39/37/df50d5f8e68514b60fbe70f6e8337ea2b32ae2be030871bcd9d1cf7d4b62/msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88", size = 400292, upload-time = "2025-06-13T06:52:46.381Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ec/1e067292e02d2ceb4c8cb5ba222c4f7bb28730eef5676740609dc2627e0f/msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478", size = 401873, upload-time = "2025-06-13T06:52:47.957Z" }, - { url = "https://files.pythonhosted.org/packages/d3/31/e8c9c6b5b58d64c9efa99c8d181fcc25f38ead357b0360379fbc8a4234ad/msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57", size = 65028, upload-time = "2025-06-13T06:52:49.166Z" }, - { url = "https://files.pythonhosted.org/packages/20/d6/cd62cded572e5e25892747a5d27850170bcd03c855e9c69c538e024de6f9/msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084", size = 71700, upload-time = "2025-06-13T06:52:50.244Z" }, ] [[package]] @@ -562,12 +507,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, - { url = "https://files.pythonhosted.org/packages/3f/a6/490ff491d8ecddf8ab91762d4f67635040202f76a44171420bcbe38ceee5/mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b", size = 12807230, upload-time = "2025-09-19T00:09:49.471Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2e/60076fc829645d167ece9e80db9e8375648d210dab44cc98beb5b322a826/mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133", size = 11895666, upload-time = "2025-09-19T00:10:53.678Z" }, - { url = "https://files.pythonhosted.org/packages/97/4a/1e2880a2a5dda4dc8d9ecd1a7e7606bc0b0e14813637eeda40c38624e037/mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6", size = 12499608, upload-time = "2025-09-19T00:09:36.204Z" }, - { url = "https://files.pythonhosted.org/packages/00/81/a117f1b73a3015b076b20246b1f341c34a578ebd9662848c6b80ad5c4138/mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac", size = 13244551, upload-time = "2025-09-19T00:10:17.531Z" }, - { url = "https://files.pythonhosted.org/packages/9b/61/b9f48e1714ce87c7bf0358eb93f60663740ebb08f9ea886ffc670cea7933/mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b", size = 13491552, upload-time = "2025-09-19T00:10:13.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/b2c0af3b684fa80d1b27501a8bdd3d2daa467ea3992a8aa612f5ca17c2db/mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0", size = 9765635, upload-time = "2025-09-19T00:10:30.993Z" }, { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, ] @@ -864,19 +803,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/7e/8ac10ccb047dc0221aa2530ec3c7c05ab4656d4d4bd984ee85da7f3d5525/pydantic_core-2.41.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f9b9c968cfe5cd576fdd7361f47f27adeb120517e637d1b189eea1c3ece573f4", size = 1875124, upload-time = "2025-10-06T21:11:47.591Z" }, { url = "https://files.pythonhosted.org/packages/c3/e4/7d9791efeb9c7d97e7268f8d20e0da24d03438a7fa7163ab58f1073ba968/pydantic_core-2.41.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1ebc7ab67b856384aba09ed74e3e977dded40e693de18a4f197c67d0d4e6d8e", size = 2043075, upload-time = "2025-10-06T21:11:49.542Z" }, { url = "https://files.pythonhosted.org/packages/2d/c3/3f6e6b2342ac11ac8cd5cb56e24c7b14afa27c010e82a765ffa5f771884a/pydantic_core-2.41.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8ae0dc57b62a762985bc7fbf636be3412394acc0ddb4ade07fe104230f1b9762", size = 1995341, upload-time = "2025-10-06T21:11:51.497Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d2/90421a4749f15aa4f06dd1d25a6419b91b181ae7994a4e7c4ed0a6415057/pydantic_core-2.41.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:10ce489cf09a4956a1549af839b983edc59b0f60e1b068c21b10154e58f54f80", size = 2114974, upload-time = "2025-10-06T21:11:53.549Z" }, - { url = "https://files.pythonhosted.org/packages/39/6a/3b5c2ba43da5380f252b35f7e74851e1379f4935c8bccbbda05992b5fe4d/pydantic_core-2.41.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ff548c908caffd9455fd1342366bcf8a1ec8a3fca42f35c7fc60883d6a901074", size = 1940064, upload-time = "2025-10-06T21:11:55.268Z" }, - { url = "https://files.pythonhosted.org/packages/81/a9/050595183529316cf95d0f97662a4fe782dbea5f31dba0cf366015b67fad/pydantic_core-2.41.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d43bf082025082bda13be89a5f876cc2386b7727c7b322be2d2b706a45cea8e", size = 1976637, upload-time = "2025-10-06T21:11:57.024Z" }, - { url = "https://files.pythonhosted.org/packages/46/a8/846a8e466edd841c67f11f0ae738ca5c5d87968f6d8630bc449e2e6e11f2/pydantic_core-2.41.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:666aee751faf1c6864b2db795775dd67b61fdcf646abefa309ed1da039a97209", size = 2069274, upload-time = "2025-10-06T21:11:59.129Z" }, - { url = "https://files.pythonhosted.org/packages/4c/dc/19d01747082daf3667f952b6deee73e9e63338caa9c61442558cbdf8c876/pydantic_core-2.41.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b83aaeff0d7bde852c32e856f3ee410842ebc08bc55c510771d87dcd1c01e1ed", size = 2255302, upload-time = "2025-10-07T10:49:36.917Z" }, - { url = "https://files.pythonhosted.org/packages/fa/99/0d4f031aeddf2cf03a5eb8eafde50147259067716c32174551b786aa72e1/pydantic_core-2.41.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:055c7931b0329cb8acde20cdde6d9c2cbc2a02a0a8e54a792cddd91e2ea92c65", size = 2386549, upload-time = "2025-10-07T10:49:39.385Z" }, - { url = "https://files.pythonhosted.org/packages/09/7f/027061a060718733a6c016e7d4acc864c8bb69f0092d9b3da7e3888b102f/pydantic_core-2.41.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:530bbb1347e3e5ca13a91ac087c4971d7da09630ef8febd27a20a10800c2d06d", size = 2079817, upload-time = "2025-10-07T10:49:41.409Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/791c16d5e2a0b394c2c236f7d2556dbc381f8666bc12db7d35dc051c67e3/pydantic_core-2.41.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65a0ea16cfea7bfa9e43604c8bd726e63a3788b61c384c37664b55209fcb1d74", size = 2196276, upload-time = "2025-10-07T10:49:43.367Z" }, - { url = "https://files.pythonhosted.org/packages/a3/99/2c7010145da82fdd30955c1c0e1e75723ca7aef32b52f2565383fd2347d2/pydantic_core-2.41.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8fa93fadff794c6d15c345c560513b160197342275c6d104cc879f932b978afc", size = 2157417, upload-time = "2025-10-07T10:49:45.176Z" }, - { url = "https://files.pythonhosted.org/packages/c6/df/b8f2ac7fa15479e989d0c2ea88e5e28eeb923096b2462804b9113bce51b5/pydantic_core-2.41.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:c8a1af9ac51969a494c6a82b563abae6859dc082d3b999e8fa7ba5ee1b05e8e8", size = 2333884, upload-time = "2025-10-07T10:49:46.896Z" }, - { url = "https://files.pythonhosted.org/packages/60/e8/06387d852bf67402fb0129b3297aa0c358aa9647e59f795c0965a7bedefe/pydantic_core-2.41.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:30edab28829703f876897c9471a857e43d847b8799c3c9e2fbce644724b50aa4", size = 2330735, upload-time = "2025-10-07T10:49:48.79Z" }, - { url = "https://files.pythonhosted.org/packages/07/41/8964006fd8897df13cb0eec374bda053d1936cbc81315acdd755d85c99d5/pydantic_core-2.41.1-cp39-cp39-win32.whl", hash = "sha256:84d0ff869f98be2e93efdf1ae31e5a15f0926d22af8677d51676e373abbfe57a", size = 1992855, upload-time = "2025-10-07T10:49:50.806Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c9/0f68c22ba0cac693326a7de73f04c7543886e0b240e2320f8ced861f0c3d/pydantic_core-2.41.1-cp39-cp39-win_amd64.whl", hash = "sha256:b5674314987cdde5a5511b029fa5fb1556b3d147a367e01dd583b19cfa8e35df", size = 2030219, upload-time = "2025-10-07T10:49:52.712Z" }, { url = "https://files.pythonhosted.org/packages/16/89/d0afad37ba25f5801735af1472e650b86baad9fe807a42076508e4824a2a/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:68f2251559b8efa99041bb63571ec7cdd2d715ba74cc82b3bc9eff824ebc8bf0", size = 2124001, upload-time = "2025-10-07T10:49:54.369Z" }, { url = "https://files.pythonhosted.org/packages/8e/c4/08609134b34520568ddebb084d9ed0a2a3f5f52b45739e6e22cb3a7112eb/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:c7bc140c596097cb53b30546ca257dbe3f19282283190b1b5142928e5d5d3a20", size = 1941841, upload-time = "2025-10-07T10:49:56.248Z" }, { url = "https://files.pythonhosted.org/packages/2a/43/94a4877094e5fe19a3f37e7e817772263e2c573c94f1e3fa2b1eee56ef3b/pydantic_core-2.41.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2896510fce8f4725ec518f8b9d7f015a00db249d2fd40788f442af303480063d", size = 1961129, upload-time = "2025-10-07T10:49:58.298Z" }, @@ -1114,15 +1040,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, - { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, - { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] [[package]] @@ -1145,8 +1062,7 @@ name = "rich" version = "14.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "markdown-it-py" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } From 654239873ffc030f2fc30a3b3a6b26e249c2585c Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 9 Oct 2025 13:28:12 -0500 Subject: [PATCH 16/51] testing: Use nox for running tests on multiple versions - Modern Python-configured replacement for tox - Use nox in the GitHub Workflow as well - Update msgpack (only); the new version has built wheels for Python 3.14. --- .github/workflows/test-and-publish.yml | 18 +-- noxfile.py | 19 +++ pyproject.toml | 1 + uv.lock | 164 ++++++++++++++++++------- 4 files changed, 142 insertions(+), 60 deletions(-) create mode 100644 noxfile.py diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index 164f0ef6..1fc612a7 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -24,14 +24,8 @@ jobs: contents: read packages: write pull-requests: write - env: - UV_PROJECT_ENVIRONMENT: .venv-${{ matrix.python-version }} steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v6 with: @@ -40,16 +34,8 @@ jobs: enable-cache: true cache-suffix: test-and-publish cache-dependency-glob: uv.lock - - name: Restore venv cache - uses: actions/cache@v4 - with: - path: | - ${{ env.UV_PROJECT_ENVIRONMENT }} - key: ${{ runner.os }}-uv-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }} - - name: Synchronize project dependencies - run: uv sync --group dev - - name: Run tests - run: uv run pytest + - name: Run tests with nox + run: uvx nox --python ${{ matrix.python-version }} --session tests publish: name: Publish to CodeArtifact diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 00000000..fc25b89a --- /dev/null +++ b/noxfile.py @@ -0,0 +1,19 @@ +import nox + +nox.options.default_venv_backend = "uv" + +python_versions = ["3.10", "3.11", "3.12", "3.13", "3.14"] + + +@nox.session(name="tests", python=python_versions, reuse_venv=True) +def tests(session: nox.Session) -> None: + session.run_install( + "uv", + "sync", + "--no-default-groups", + "--group=dev", + "--reinstall-package=pdfrest", + f"--python={session.virtualenv.location}", + env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, + ) + session.run("pytest", "--cov=pdfrest", "--cov-report=term-missing") diff --git a/pyproject.toml b/pyproject.toml index de196f83..365a24da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dev = [ "pytest-asyncio>=1.2.0", "pytest-rerunfailures>=16.0.1", "pytest-xdist>=3.8.0", + "nox>=2025.5.1", ] [tool.mypy] diff --git a/uv.lock b/uv.lock index 0cc1deec..1a4b3243 100644 --- a/uv.lock +++ b/uv.lock @@ -26,6 +26,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "argcomplete" +version = "3.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403, upload-time = "2025-04-03T04:57:03.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -153,6 +171,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorlog" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624, upload-time = "2024-10-29T18:34:51.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload-time = "2024-10-29T18:34:49.815Z" }, +] + [[package]] name = "coverage" version = "7.10.7" @@ -281,6 +311,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "dependency-groups" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/55/f054de99871e7beb81935dea8a10b90cd5ce42122b1c3081d5282fdb3621/dependency_groups-1.3.1.tar.gz", hash = "sha256:78078301090517fd938c19f64a53ce98c32834dfe0dee6b88004a569a6adfefd", size = 10093, upload-time = "2025-05-02T00:34:29.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/c7/d1ec24fb280caa5a79b6b950db565dab30210a66259d17d5bb2b3a9f878d/dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030", size = 8664, upload-time = "2025-05-02T00:34:27.085Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -419,50 +462,63 @@ wheels = [ [[package]] name = "msgpack" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/52/f30da112c1dc92cf64f57d08a273ac771e7b29dea10b4b30369b2d7e8546/msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed", size = 81799, upload-time = "2025-06-13T06:51:37.228Z" }, - { url = "https://files.pythonhosted.org/packages/e4/35/7bfc0def2f04ab4145f7f108e3563f9b4abae4ab0ed78a61f350518cc4d2/msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8", size = 78278, upload-time = "2025-06-13T06:51:38.534Z" }, - { url = "https://files.pythonhosted.org/packages/e8/c5/df5d6c1c39856bc55f800bf82778fd4c11370667f9b9e9d51b2f5da88f20/msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2", size = 402805, upload-time = "2025-06-13T06:51:39.538Z" }, - { url = "https://files.pythonhosted.org/packages/20/8e/0bb8c977efecfe6ea7116e2ed73a78a8d32a947f94d272586cf02a9757db/msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4", size = 408642, upload-time = "2025-06-13T06:51:41.092Z" }, - { url = "https://files.pythonhosted.org/packages/59/a1/731d52c1aeec52006be6d1f8027c49fdc2cfc3ab7cbe7c28335b2910d7b6/msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0", size = 395143, upload-time = "2025-06-13T06:51:42.575Z" }, - { url = "https://files.pythonhosted.org/packages/2b/92/b42911c52cda2ba67a6418ffa7d08969edf2e760b09015593c8a8a27a97d/msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26", size = 395986, upload-time = "2025-06-13T06:51:43.807Z" }, - { url = "https://files.pythonhosted.org/packages/61/dc/8ae165337e70118d4dab651b8b562dd5066dd1e6dd57b038f32ebc3e2f07/msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75", size = 402682, upload-time = "2025-06-13T06:51:45.534Z" }, - { url = "https://files.pythonhosted.org/packages/58/27/555851cb98dcbd6ce041df1eacb25ac30646575e9cd125681aa2f4b1b6f1/msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338", size = 406368, upload-time = "2025-06-13T06:51:46.97Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/39a26add4ce16f24e99eabb9005e44c663db00e3fce17d4ae1ae9d61df99/msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd", size = 65004, upload-time = "2025-06-13T06:51:48.582Z" }, - { url = "https://files.pythonhosted.org/packages/7d/18/73dfa3e9d5d7450d39debde5b0d848139f7de23bd637a4506e36c9800fd6/msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8", size = 71548, upload-time = "2025-06-13T06:51:49.558Z" }, - { url = "https://files.pythonhosted.org/packages/7f/83/97f24bf9848af23fe2ba04380388216defc49a8af6da0c28cc636d722502/msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558", size = 82728, upload-time = "2025-06-13T06:51:50.68Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/2eaa388267a78401f6e182662b08a588ef4f3de6f0eab1ec09736a7aaa2b/msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d", size = 79279, upload-time = "2025-06-13T06:51:51.72Z" }, - { url = "https://files.pythonhosted.org/packages/f8/46/31eb60f4452c96161e4dfd26dbca562b4ec68c72e4ad07d9566d7ea35e8a/msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0", size = 423859, upload-time = "2025-06-13T06:51:52.749Z" }, - { url = "https://files.pythonhosted.org/packages/45/16/a20fa8c32825cc7ae8457fab45670c7a8996d7746ce80ce41cc51e3b2bd7/msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f", size = 429975, upload-time = "2025-06-13T06:51:53.97Z" }, - { url = "https://files.pythonhosted.org/packages/86/ea/6c958e07692367feeb1a1594d35e22b62f7f476f3c568b002a5ea09d443d/msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704", size = 413528, upload-time = "2025-06-13T06:51:55.507Z" }, - { url = "https://files.pythonhosted.org/packages/75/05/ac84063c5dae79722bda9f68b878dc31fc3059adb8633c79f1e82c2cd946/msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2", size = 413338, upload-time = "2025-06-13T06:51:57.023Z" }, - { url = "https://files.pythonhosted.org/packages/69/e8/fe86b082c781d3e1c09ca0f4dacd457ede60a13119b6ce939efe2ea77b76/msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2", size = 422658, upload-time = "2025-06-13T06:51:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2b/bafc9924df52d8f3bb7c00d24e57be477f4d0f967c0a31ef5e2225e035c7/msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752", size = 427124, upload-time = "2025-06-13T06:51:59.969Z" }, - { url = "https://files.pythonhosted.org/packages/a2/3b/1f717e17e53e0ed0b68fa59e9188f3f610c79d7151f0e52ff3cd8eb6b2dc/msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295", size = 65016, upload-time = "2025-06-13T06:52:01.294Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/9d1780768d3b249accecc5a38c725eb1e203d44a191f7b7ff1941f7df60c/msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458", size = 72267, upload-time = "2025-06-13T06:52:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" }, - { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" }, - { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" }, - { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload-time = "2025-06-13T06:52:07.501Z" }, - { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload-time = "2025-06-13T06:52:09.047Z" }, - { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" }, - { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" }, - { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" }, - { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" }, - { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" }, - { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" }, - { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" }, - { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" }, - { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" }, - { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" }, - { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" }, - { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" }, + { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] [[package]] @@ -528,6 +584,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "nox" +version = "2025.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "attrs" }, + { name = "colorlog" }, + { name = "dependency-groups" }, + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/80/47712208c410defec169992e57c179f0f4d92f5dd17ba8daca50a8077e23/nox-2025.5.1.tar.gz", hash = "sha256:2a571dfa7a58acc726521ac3cd8184455ebcdcbf26401c7b737b5bc6701427b2", size = 4023334, upload-time = "2025-05-01T16:35:48.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/be/7b423b02b09eb856beffe76fe8c4121c99852db74dd12a422dcb72d1134e/nox-2025.5.1-py3-none-any.whl", hash = "sha256:56abd55cf37ff523c254fcec4d152ed51e5fe80e2ab8317221d8b828ac970a31", size = 71753, upload-time = "2025-05-01T16:35:46.037Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.5" @@ -567,6 +641,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "nox" }, { name = "pip-audit" }, { name = "pre-commit" }, { name = "pyright" }, @@ -590,6 +665,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.18.2" }, + { name = "nox", specifier = ">=2025.5.1" }, { name = "pip-audit", specifier = ">=2.7.3" }, { name = "pre-commit", specifier = ">=3.7.0" }, { name = "pyright", specifier = ">=1.1.406" }, From 9eb76a1af3729a0cdcddbcb55572318e99efb326 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 9 Oct 2025 16:16:41 -0500 Subject: [PATCH 17/51] pyproject: pyright and mypy settings that work together --- pyproject.toml | 74 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 365a24da..36d26e53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,26 +36,84 @@ dev = [ [tool.mypy] python_version = "3.10" -warn_return_any = true pretty = true -exclude = [ - '^\\.venv', # TOML literal string (single-quotes, no escaping necessary) - '^build', # Skip build artifacts - '^dist', # Skip distribution artifacts -] -files = "." plugins = [ "pydantic.mypy", ] # Discover modules that were installed in the virtualenv python_executable = ".venv/bin/python" fixed_format_cache = true +ignore_missing_imports = false +follow_imports = "silent" +# Balanced strictness (not “strict = true”); catches many bugs without being overbearing +no_implicit_optional = true +check_untyped_defs = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_return_any = true +warn_no_return = true +strict_equality = true +# Defaults here stay a bit lenient; ratchet up over time if desired +disallow_untyped_defs = false +disallow_incomplete_defs = false +disallow_untyped_calls = false +disallow_any_generics = false +implicit_reexport = true +namespace_packages = true +show_error_codes = true +# Typical project structure +packages = ["pdfrest"] # if using setuptools; or rely on src/ layout +exclude = '(build|dist|\.venv|scripts|examples|docs)' + +# Example: tighten src/, loosen tests/ +[[tool.mypy.overrides]] +module = ["pdfrest.*"] +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[[tool.mypy.overrides]] +module = ["tests.*"] +disallow_untyped_defs = false +allow_redefinition = true [tool.pyright] venvPath = "." venv = ".venv" +typeCheckingMode = "standard" pythonVersion = "3.10" -typeCheckingMode = "strict" +reportMissingTypeStubs = true +# mypy catches this, so catch it in pyright too +reportOptionalMemberAccess = true +# Sensible signal without being punitive; tune severities as needed +reportUnusedImport = "error" +reportUnusedVariable = "error" +reportUnknownMemberType = "warning" +reportUnknownArgumentType = "warning" +reportUnknownVariableType = "warning" +reportPrivateUsage = "error" +# Keep false positives low in typical library code +useLibraryCodeForTypes = true +# Project layout +include = ["src", "tests"] +exclude = [ + "**/.venv", + "build", + "dist", + "scripts", + "examples", + "docs", +] + +[[tool.pyright.executionEnvironments]] +root = "src" + +[[tool.pyright.executionEnvironments]] +root = "tests" +typeCheckingMode = "basic" +reportUnknownMemberType = "none" +reportUnknownArgumentType = "none" +reportUnknownVariableType = "none" +reportPrivateUsage = "none" [tool.pytest.ini_options] minversion = "7.4" From be88794fbcc645b3d1a75c5ce0c186332b37df32 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 10 Oct 2025 13:19:15 -0500 Subject: [PATCH 18/51] Add API key validation, enhance `/up` client methods - Implemented UUID format validation for API keys. - Enhanced the `/up` client methods to accept additional headers, query parameters, and timeouts. - Raised `PdfRestAuthenticationError` for authentication failures. - Refactored request composition for improved reuse and readability. - Updated tests to cover new validations and functionalities. Assisted-by: Codex --- src/pdfrest/__init__.py | 6 +- src/pdfrest/client.py | 307 ++++++++++++++++++++++++++------------ src/pdfrest/exceptions.py | 5 + src/pdfrest/models.py | 4 +- tests/test_client.py | 215 ++++++++++++++++++++++++-- 5 files changed, 423 insertions(+), 114 deletions(-) diff --git a/src/pdfrest/__init__.py b/src/pdfrest/__init__.py index 9ee6d0b5..f18112ed 100644 --- a/src/pdfrest/__init__.py +++ b/src/pdfrest/__init__.py @@ -2,9 +2,10 @@ from importlib import metadata -from .client import AsyncPdfRestClient, PdfRestClient, RequestOptions, UpRequestOptions +from .client import AsyncPdfRestClient, PdfRestClient from .exceptions import ( PdfRestApiError, + PdfRestAuthenticationError, PdfRestConfigurationError, PdfRestError, PdfRestRequestError, @@ -17,14 +18,13 @@ __all__ = ( "AsyncPdfRestClient", "PdfRestApiError", + "PdfRestAuthenticationError", "PdfRestClient", "PdfRestConfigurationError", "PdfRestError", "PdfRestRequestError", "PdfRestTimeoutError", "PdfRestTransportError", - "RequestOptions", - "UpRequestOptions", "UpResponse", "__version__", "translate_httpx_error", diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index fa999364..cab64bc5 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -3,8 +3,9 @@ from __future__ import annotations import os +import uuid from collections.abc import Mapping -from typing import Any, Generic, Literal, TypedDict, TypeVar, cast +from typing import Any, Generic, Literal, TypeVar import httpx from httpx import URL @@ -12,12 +13,13 @@ from .exceptions import ( PdfRestApiError, + PdfRestAuthenticationError, PdfRestConfigurationError, translate_httpx_error, ) from .models import PdfRestErrorResponse, UpResponse -__all__ = ("AsyncPdfRestClient", "PdfRestClient", "RequestOptions", "UpRequestOptions") +__all__ = ("AsyncPdfRestClient", "PdfRestClient") DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" @@ -26,27 +28,19 @@ HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] QueryParamValue = str | int | float | bool | None TimeoutTypes = float | httpx.Timeout | None +AnyMapping = Mapping[str, Any] +Query = Mapping[str, QueryParamValue] +Body = Mapping[str, Any] ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient) -class RequestOptions(TypedDict, total=False): - """Shared request customisation options for pdfrest endpoints.""" - - headers: Mapping[str, str] - params: Mapping[str, QueryParamValue] - timeout: float | httpx.Timeout - - -UpRequestOptions = RequestOptions - - class _ClientConfig(BaseModel): """Internal representation of client configuration validated by Pydantic.""" base_url: URL - api_key: str + api_key: str | None = None timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS headers: dict[str, str] = Field(default_factory=dict) @@ -68,11 +62,13 @@ def _parse_base_url(cls, value: Any) -> URL: @field_validator("api_key") @classmethod - def _validate_api_key(cls, value: str) -> str: - if not value or not value.strip(): - msg = "API key must not be empty." - raise PdfRestConfigurationError(msg) - return value.strip() + def _validate_api_key(cls, value: str | None) -> str | None: + if value is None: + return None + trimmed = value.strip() + if not trimmed: + return None + return trimmed @field_validator("headers", mode="before") @classmethod @@ -102,9 +98,10 @@ class _RequestModel(BaseModel): method: HttpMethod endpoint: str - params: dict[str, str] | None = None + params: dict[str, QueryParamValue] | None = None headers: dict[str, str] = Field(default_factory=dict) timeout: TimeoutTypes + json_body: dict[str, Any] | None = None model_config = ConfigDict(arbitrary_types_allowed=True) @@ -116,28 +113,6 @@ def _validate_endpoint(cls, value: str) -> str: raise PdfRestConfigurationError(msg) return value - @field_validator("params", mode="before") - @classmethod - def _normalize_params(cls, value: Any) -> dict[str, str]: - if value is None: - return {} - normalized: dict[str, str] = {} - for key, candidate in dict(value).items(): - if candidate is None: - continue - normalized[str(key)] = str(candidate) - return normalized - - @field_validator("headers", mode="before") - @classmethod - def _normalize_headers(cls, value: Any) -> dict[str, str]: - if value is None: - return {} - normalized: dict[str, str] = {} - for key, candidate in dict(value).items(): - normalized[str(key)] = str(candidate) - return normalized - class _BaseApiClient(Generic[ClientType]): """Shared logic between sync and async client variants.""" @@ -152,25 +127,36 @@ def __init__( api_key: str | None = None, base_url: str | URL | None = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, - headers: Mapping[str, str] | None = None, + headers: AnyMapping | None = None, ) -> None: - resolved_api_key = (api_key or os.getenv(API_KEY_ENV_VAR) or "").strip() - if not resolved_api_key: - msg = "API key was not provided and the PDFREST_API_KEY environment variable is not set." + raw_api_key = api_key if api_key is not None else os.getenv(API_KEY_ENV_VAR) + resolved_api_key = ( + raw_api_key.strip() if raw_api_key and raw_api_key.strip() else None + ) + + resolved_base_url = ( + URL(str(base_url)) if base_url is not None else URL(DEFAULT_BASE_URL) + ) + + if resolved_api_key is None and self._base_url_requires_api_key( + resolved_base_url + ): + msg = ( + "API key is required when communicating with pdfRest-hosted " + "endpoints. Provide `api_key` or set the PDFREST_API_KEY environment variable." + ) raise PdfRestConfigurationError(msg) - default_headers: dict[str, str] = { - "Authorization": f"Bearer {resolved_api_key}", - "Accept": "application/json", - } + if resolved_api_key is not None: + self._validate_pdfrest_api_key(resolved_api_key, resolved_base_url) + + default_headers: dict[str, str] = {"Accept": "application/json"} + if resolved_api_key is not None: + default_headers["Authorization"] = f"Bearer {resolved_api_key}" if headers: for key, value in headers.items(): default_headers[str(key)] = str(value) - resolved_base_url = ( - URL(str(base_url)) if base_url is not None else URL(DEFAULT_BASE_URL) - ) - try: self._config = _ClientConfig( base_url=resolved_base_url, @@ -183,6 +169,24 @@ def __init__( except ValidationError as exc: # pragma: no cover - defensive raise PdfRestConfigurationError(str(exc)) from exc + @staticmethod + def _base_url_requires_api_key(url: URL) -> bool: + host = url.host or "" + return host.lower().endswith("pdfrest.com") + + @staticmethod + def _validate_pdfrest_api_key(api_key: str, url: URL) -> None: + if not _BaseApiClient._base_url_requires_api_key(url): + return + if len(api_key) != 36: + msg = "pdfRest API keys must be 36 characters (UUID format)." + raise PdfRestConfigurationError(msg) + try: + uuid.UUID(api_key) + except ValueError: + msg = "pdfRest API keys must be valid UUID strings." + raise PdfRestConfigurationError(msg) from None + @property def base_url(self) -> URL: """Resolved base URL for the client.""" @@ -190,42 +194,30 @@ def base_url(self) -> URL: return self._config.base_url def _prepare_request( - self, method: HttpMethod, endpoint: str, options: RequestOptions | None = None + self, + method: HttpMethod, + endpoint: str, + *, + query: Query | None = None, + json_body: Body | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, ) -> _RequestModel: - option_dict: dict[str, Any] = dict(options or {}) - combined_headers: dict[str, str] = dict(self._config.headers) - if "headers" in option_dict and option_dict["headers"] is not None: - combined_headers.update( - { - str(key): str(value) - for key, value in cast( - Mapping[str, Any], option_dict["headers"] - ).items() - } - ) - - params_dict: dict[str, str] | None = None - if "params" in option_dict and option_dict["params"] is not None: - raw_params = cast(Mapping[str, QueryParamValue], option_dict["params"]) - converted_params: dict[str, str] = {} - for key, value in raw_params.items(): - if value is None: - continue - converted_params[str(key)] = str(value) - params_dict = converted_params if converted_params else None - - timeout_override = option_dict.get("timeout") - timeout_value = ( - timeout_override if timeout_override is not None else self._config.timeout - ) + headers = self._compose_headers(extra_headers) + params = self._compose_query_params(query, extra_query) + json_payload = self._compose_json_body(json_body, extra_body) + timeout_value = timeout if timeout is not None else self._config.timeout try: request = _RequestModel( method=method, endpoint=endpoint, - params=params_dict, - headers=combined_headers, + params=params, + headers=headers, timeout=timeout_value, + json_body=json_payload, ) except PdfRestConfigurationError: raise @@ -233,16 +225,57 @@ def _prepare_request( raise PdfRestConfigurationError(str(exc)) from exc return request + def _compose_headers(self, extra_headers: AnyMapping | None) -> dict[str, str]: + combined_headers: dict[str, str] = dict(self._config.headers) + if extra_headers is None: + return combined_headers + for key, value in extra_headers.items(): + combined_headers[str(key)] = str(value) + return combined_headers + + @staticmethod + def _compose_query_params( + query: Query | None, + extra_query: Query | None, + ) -> dict[str, QueryParamValue] | None: + params: dict[str, QueryParamValue] = {} + for mapping in (query, extra_query): + if mapping is None: + continue + for key, value in mapping.items(): + params[str(key)] = value + return params or None + + @staticmethod + def _compose_json_body( + json_body: Body | None, + extra_body: Body | None, + ) -> dict[str, Any] | None: + if json_body is None: + if extra_body is not None: + msg = "extra_body can only be used with JSON requests." + raise PdfRestConfigurationError(msg) + return None + payload: dict[str, Any] = dict(json_body) + if extra_body is not None: + for key, value in extra_body.items(): + payload[str(key)] = value + return payload + def _handle_response(self, response: httpx.Response) -> Any: if response.is_success: return self._decode_json(response) - error_payload: Any = None - message: str | None = None - try: - pdfrest_error = PdfRestErrorResponse.model_validate_json(response.content) - message = pdfrest_error.error - except ValidationError: - error_payload = response.text + + message, error_payload = self._extract_error_details(response) + + if response.status_code == 401: + auth_message = message or "Authentication with pdfRest failed." + raise PdfRestAuthenticationError( + response.status_code, + message=auth_message, + response_content=error_payload, + ) + raise PdfRestApiError( response.status_code, message=message, response_content=error_payload ) @@ -257,6 +290,16 @@ def _decode_json(self, response: httpx.Response) -> Any: response_content=response.text, ) from exc + @staticmethod + def _extract_error_details( + response: httpx.Response, + ) -> tuple[str | None, Any | None]: + try: + pdfrest_error = PdfRestErrorResponse.model_validate_json(response.content) + except ValidationError: + return None, response.text + return pdfrest_error.error, None + class _SyncApiClient(_BaseApiClient[httpx.Client]): """Internal synchronous client implementation.""" @@ -269,7 +312,7 @@ def __init__( api_key: str | None = None, base_url: str | URL | None = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, - headers: Mapping[str, str] | None = None, + headers: AnyMapping | None = None, http_client: httpx.Client | None = None, transport: httpx.BaseTransport | None = None, ) -> None: @@ -306,6 +349,7 @@ def _send_request(self, request: _RequestModel) -> Any: params=request.params or None, headers=request.headers or None, timeout=request.timeout, + json=request.json_body, ) except httpx.HTTPError as exc: raise translate_httpx_error(exc) from exc @@ -323,7 +367,7 @@ def __init__( api_key: str | None = None, base_url: str | URL | None = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, - headers: Mapping[str, str] | None = None, + headers: AnyMapping | None = None, http_client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, ) -> None: @@ -360,6 +404,7 @@ async def _send_request(self, request: _RequestModel) -> Any: params=request.params or None, headers=request.headers or None, timeout=request.timeout, + json=request.json_body, ) except httpx.HTTPError as exc: raise translate_httpx_error(exc) from exc @@ -369,6 +414,27 @@ async def _send_request(self, request: _RequestModel) -> Any: class PdfRestClient(_SyncApiClient): """Synchronous client for interacting with the pdfrest API.""" + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | URL | None = None, + timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, + headers: AnyMapping | None = None, + http_client: httpx.Client | None = None, + transport: httpx.BaseTransport | None = None, + ) -> None: + """Create a synchronous pdfRest client.""" + + super().__init__( + api_key=api_key, + base_url=base_url, + timeout=timeout, + headers=headers, + http_client=http_client, + transport=transport, + ) + def __enter__(self) -> PdfRestClient: super().__enter__() return self @@ -376,10 +442,24 @@ def __enter__(self) -> PdfRestClient: def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: super().__exit__(exc_type, exc, traceback) - def up(self, options: UpRequestOptions | None = None) -> UpResponse: + def up( + self, + *, + extra_headers: AnyMapping | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> UpResponse: """Call the `/up` health endpoint and return server metadata.""" - request = self._prepare_request("GET", "/up", options) + request = self._prepare_request( + "GET", + "/up", + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) payload = self._send_request(request) return UpResponse.model_validate(payload) @@ -387,6 +467,27 @@ def up(self, options: UpRequestOptions | None = None) -> UpResponse: class AsyncPdfRestClient(_AsyncApiClient): """Asynchronous client for interacting with the pdfrest API.""" + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | URL | None = None, + timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, + headers: AnyMapping | None = None, + http_client: httpx.AsyncClient | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + """Create an asynchronous pdfRest client.""" + + super().__init__( + api_key=api_key, + base_url=base_url, + timeout=timeout, + headers=headers, + http_client=http_client, + transport=transport, + ) + async def __aenter__(self) -> AsyncPdfRestClient: await super().__aenter__() return self @@ -394,9 +495,23 @@ async def __aenter__(self) -> AsyncPdfRestClient: async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: await super().__aexit__(exc_type, exc, traceback) - async def up(self, options: UpRequestOptions | None = None) -> UpResponse: + async def up( + self, + *, + extra_headers: AnyMapping | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> UpResponse: """Call the `/up` health endpoint asynchronously and return server metadata.""" - request = self._prepare_request("GET", "/up", options) + request = self._prepare_request( + "GET", + "/up", + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) payload = await self._send_request(request) return UpResponse.model_validate(payload) diff --git a/src/pdfrest/exceptions.py b/src/pdfrest/exceptions.py index cd1b96de..d08556e1 100644 --- a/src/pdfrest/exceptions.py +++ b/src/pdfrest/exceptions.py @@ -8,6 +8,7 @@ __all__ = ( "PdfRestApiError", + "PdfRestAuthenticationError", "PdfRestConfigurationError", "PdfRestError", "PdfRestRequestError", @@ -58,6 +59,10 @@ def __str__(self) -> str: # pragma: no cover - mirrors Exception.__str__ return f"{base}: {self.response_content}" +class PdfRestAuthenticationError(PdfRestApiError): + """Raised when authentication with the pdfRest API fails.""" + + def translate_httpx_error(exc: httpx.HTTPError) -> PdfRestError: """Convert an httpx exception into a library-specific exception.""" diff --git a/src/pdfrest/models.py b/src/pdfrest/models.py index 5ed1beb7..fa3be785 100644 --- a/src/pdfrest/models.py +++ b/src/pdfrest/models.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field -__all__ = ("UpResponse",) +__all__ = ("PdfRestErrorResponse", "UpResponse") class UpResponse(BaseModel): @@ -17,7 +17,7 @@ class UpResponse(BaseModel): release_date: date = Field(alias="releaseDate") version: str - model_config = ConfigDict(populate_by_name=True, frozen=True) + model_config = ConfigDict(frozen=True) class PdfRestErrorResponse(BaseModel): diff --git a/tests/test_client.py b/tests/test_client.py index 352e1b08..7944a83f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,10 +10,10 @@ from pdfrest import ( AsyncPdfRestClient, PdfRestApiError, + PdfRestAuthenticationError, PdfRestClient, PdfRestConfigurationError, PdfRestTimeoutError, - RequestOptions, UpResponse, ) @@ -23,6 +23,10 @@ "https://api.pdfrest.com", ) +VALID_API_KEY = "12345678-1234-1234-1234-123456789abc" +ANOTHER_VALID_API_KEY = "abcdefab-cdef-abcd-efab-cdefabcdef12" +ASYNC_API_KEY = "fedcba98-7654-3210-fedc-ba9876543210" + def _build_up_response() -> dict[str, Any]: return { @@ -37,12 +41,12 @@ def test_client_uses_provided_api_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["Authorization"] == "Bearer explicit-key" + assert request.headers["Authorization"] == f"Bearer {VALID_API_KEY}" assert request.url.path == "/up" return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = PdfRestClient(api_key="explicit-key", transport=transport) + client = PdfRestClient(api_key=VALID_API_KEY, transport=transport) try: response = client.up() finally: @@ -53,10 +57,10 @@ def handler(request: httpx.Request) -> httpx.Response: def test_client_reads_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("PDFREST_API_KEY", "environment-key") + monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY) def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["Authorization"] == "Bearer environment-key" + assert request.headers["Authorization"] == f"Bearer {VALID_API_KEY}" assert request.url.host == "example.com" return httpx.Response(200, json=_build_up_response()) @@ -77,29 +81,165 @@ def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: PdfRestClient() +def test_invalid_length_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + with pytest.raises(PdfRestConfigurationError): + PdfRestClient(api_key="too-short") + + +def test_invalid_uuid_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + bad_uuid = "12345678-1234-1234-1234-123456789abz" + + with pytest.raises(PdfRestConfigurationError): + PdfRestClient(api_key=bad_uuid) + + +def test_client_allows_missing_api_key_for_custom_host( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(request: httpx.Request) -> httpx.Response: + assert "Authorization" not in request.headers + assert request.url.host == "internal.example" + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(base_url="https://internal.example", transport=transport) + try: + response = client.up() + finally: + client.close() + + assert response.status == "OK" + + def test_up_with_custom_headers(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["Authorization"] == "Bearer custom-key" + assert request.headers["Authorization"] == f"Bearer {ANOTHER_VALID_API_KEY}" assert request.headers["X-Test-Header"] == "value" return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = PdfRestClient(api_key="custom-key", transport=transport) - options: RequestOptions = {"headers": {"X-Test-Header": "value"}} + client = PdfRestClient(api_key=ANOTHER_VALID_API_KEY, transport=transport) try: - response = client.up(options=options) + response = client.up(extra_headers={"X-Test-Header": "value"}) finally: client.close() assert response.version == "2.31.1" +def test_up_with_query_and_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY) + + captured_timeout: dict[str, float | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["view"] == "full" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(transport=transport) + try: + response = client.up( + extra_query={"view": "full", "unused": None}, + timeout=0.5, + ) + finally: + client.close() + + assert response.product == "pdfRest API Toolkit" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.5) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.5) + + +def test_up_rejects_extra_body(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY) + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(transport=transport) + with pytest.raises(PdfRestConfigurationError): + client.up(extra_body={"unexpected": "value"}) + client.close() + + +def test_prepare_request_merges_queries(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PDFREST_API_KEY", "key") + client = PdfRestClient(api_key=VALID_API_KEY) + try: + request = client._prepare_request( + "GET", + "/test", + query={"base": "value", "skip": None}, + extra_query={"base": "override", "extra": 42, "ignore": None}, + ) + finally: + client.close() + + assert request.params == { + "base": "override", + "extra": 42, + "skip": None, + "ignore": None, + } + + +def test_authentication_error_raises_specific_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY) + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"message": "The provided key is not valid."}) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(transport=transport) + + with pytest.raises(PdfRestAuthenticationError) as exc_info: + client.up() + + client.close() + assert "The provided key is not valid." in str(exc_info.value) + + +def test_authentication_error_handles_non_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY) + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(401, text="Unauthorized") + + transport = httpx.MockTransport(handler) + client = PdfRestClient(transport=transport) + + with pytest.raises(PdfRestAuthenticationError) as exc_info: + client.up() + + client.close() + assert "Authentication with pdfRest failed." in str(exc_info.value) + assert exc_info.value.response_content == "Unauthorized" + + def test_client_raises_for_non_success_response( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setenv("PDFREST_API_KEY", "key") + monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY) def handler(_: httpx.Request) -> httpx.Response: return httpx.Response(500, json={"message": "server error"}) @@ -116,10 +256,10 @@ def handler(_: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_client_up(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("PDFREST_API_KEY", "async-key") + monkeypatch.setenv("PDFREST_API_KEY", ASYNC_API_KEY) def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["Authorization"] == "Bearer async-key" + assert request.headers["Authorization"] == f"Bearer {ASYNC_API_KEY}" return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) @@ -130,9 +270,41 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.status == "OK" +@pytest.mark.asyncio +async def test_async_up_with_query_and_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PDFREST_API_KEY", ASYNC_API_KEY) + + captured_timeout: dict[str, float | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["mode"] == "ping" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + client = AsyncPdfRestClient(transport=transport) + async with client: + response = await client.up( + extra_query={"mode": "ping"}, + timeout=0.25, + ) + + assert response.status == "OK" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.25) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.25) + + @pytest.mark.asyncio async def test_async_client_translates_timeout(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("PDFREST_API_KEY", "async-key") + monkeypatch.setenv("PDFREST_API_KEY", ASYNC_API_KEY) def handler(_: httpx.Request) -> httpx.Response: message = "timeout" @@ -146,6 +318,23 @@ def handler(_: httpx.Request) -> httpx.Response: await client.up() +@pytest.mark.asyncio +async def test_async_up_rejects_extra_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PDFREST_API_KEY", ASYNC_API_KEY) + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + client = AsyncPdfRestClient(transport=transport) + + with pytest.raises(PdfRestConfigurationError): + async with client: + await client.up(extra_body={"unexpected": "value"}) + + @pytest.fixture(scope="session") def pdfrest_api_key() -> str: key = os.getenv("PDFREST_API_KEY") From 3220c95b9802b621335d52f088150d6ee120a0cd Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 10 Oct 2025 13:41:42 -0500 Subject: [PATCH 19/51] test-and-publish: Add PDFREST_API_KEY for testing --- .github/workflows/test-and-publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index 1fc612a7..fcdd7acd 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -36,6 +36,8 @@ jobs: cache-dependency-glob: uv.lock - name: Run tests with nox run: uvx nox --python ${{ matrix.python-version }} --session tests + env: + PDFREST_API_KEY: ${{ secrets.PDFREST_API_KEY }} publish: name: Publish to CodeArtifact From be3d471cfb8d2258704b246f3a0e777e67b79de6 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 27 Oct 2025 15:58:29 -0500 Subject: [PATCH 20/51] Correct authorization to use Api-Key - Updated client and tests to use `Api-Key` as the header name for API key injection. - Refactored default headers construction to reflect this change. - Modified related test assertions to validate the new header semantics. Assisted-by: Codex --- src/pdfrest/client.py | 3 ++- tests/test_client.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index cab64bc5..477a4bac 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -23,6 +23,7 @@ DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" +API_KEY_HEADER_NAME = "Api-Key" DEFAULT_TIMEOUT_SECONDS = 10.0 HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] @@ -152,7 +153,7 @@ def __init__( default_headers: dict[str, str] = {"Accept": "application/json"} if resolved_api_key is not None: - default_headers["Authorization"] = f"Bearer {resolved_api_key}" + default_headers[API_KEY_HEADER_NAME] = resolved_api_key if headers: for key, value in headers.items(): default_headers[str(key)] = str(value) diff --git a/tests/test_client.py b/tests/test_client.py index 7944a83f..66d9c871 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -41,7 +41,7 @@ def test_client_uses_provided_api_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["Authorization"] == f"Bearer {VALID_API_KEY}" + assert request.headers["Api-Key"] == VALID_API_KEY assert request.url.path == "/up" return httpx.Response(200, json=_build_up_response()) @@ -60,7 +60,7 @@ def test_client_reads_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY) def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["Authorization"] == f"Bearer {VALID_API_KEY}" + assert request.headers["Api-Key"] == VALID_API_KEY assert request.url.host == "example.com" return httpx.Response(200, json=_build_up_response()) @@ -102,7 +102,7 @@ def test_client_allows_missing_api_key_for_custom_host( monkeypatch.delenv("PDFREST_API_KEY", raising=False) def handler(request: httpx.Request) -> httpx.Response: - assert "Authorization" not in request.headers + assert "Api-Key" not in request.headers assert request.url.host == "internal.example" return httpx.Response(200, json=_build_up_response()) @@ -120,7 +120,7 @@ def test_up_with_custom_headers(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["Authorization"] == f"Bearer {ANOTHER_VALID_API_KEY}" + assert request.headers["Api-Key"] == ANOTHER_VALID_API_KEY assert request.headers["X-Test-Header"] == "value" return httpx.Response(200, json=_build_up_response()) @@ -259,7 +259,7 @@ async def test_async_client_up(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PDFREST_API_KEY", ASYNC_API_KEY) def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["Authorization"] == f"Bearer {ASYNC_API_KEY}" + assert request.headers["Api-Key"] == ASYNC_API_KEY return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) From bdfbe194cb9b59b9bb8e014b2fce1eceaae20de4 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 27 Oct 2025 16:08:50 -0500 Subject: [PATCH 21/51] AGENTS.md: Check in incremental updates Assisted-by: Codex --- AGENTS.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2d7421ea..75d594d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ - Workflow definitions are in `.github/workflows/`; adjust only when CI requirements change. - Documentation and contributor notes reside at the repo root (`README.md`, - `AGENTS.md`). + `AGENTS.md`). Automation sessions live in `noxfile.py`; keep shared task logic + there. ## Build, Test, and Development Commands @@ -19,10 +20,12 @@ pushing. - `uv run pytest` — execute the suite with the active interpreter. - `uv build` — produce wheels and sdists identical to the release workflow. +- `uvx nox -s tests` — create matrix virtualenvs via nox and execute the pytest + session. ## Coding Style & Naming Conventions -- Target Python 3.9–3.13; use 4-space indentation and type hints for public +- Target Python 3.10–3.14; use 4-space indentation and type hints for public APIs. - Black + isort (via ruff) enforce formatting; run through pre-commit prior to review. @@ -30,6 +33,9 @@ `UPPER_SNAKE_CASE` for constants. - Prefer `pathlib`, f-strings, and other modern stdlib features—pyupgrade rules will flag legacy code. +- When calling pdfRest, supply the API key via the `Api-Key` header (not + `Authorization: Bearer`); keep tests and client defaults in sync with this + convention. ## Testing Guidelines @@ -37,13 +43,8 @@ in `conftest.py` where shared. - Ensure high-value coverage of public functions and edge cases; document intent in test docstrings when non-obvious. -- For interpreter compatibility, execute the matrix locally: - ```bash - for py in 3.9 3.10 3.11 3.12 3.13; do - UV_PROJECT_ENVIRONMENT=.venv-$py uv sync --group dev --python $py - UV_PROJECT_ENVIRONMENT=.venv-$py uv run --python $py pytest - done - ``` +- Use `uvx nox -s tests` to exercise the full interpreter matrix locally when + validating compatibility. ## Commit & Pull Request Guidelines @@ -59,8 +60,8 @@ ## CI & Publishing Notes - GitHub Actions run two workflows: `pre-commit` (no AWS credentials) and - `Test and Publish` (Python 3.9–3.13 matrix). + `Test and Publish` (Python 3.10–3.14 matrix). - Only the release job assumes the AWS OIDC role to `uv build` and publish with - `uv tool run --from twine twine upload`. + `uv publish`. - Keep CodeArtifact credentials out of source control; day-to-day development should rely solely on public dependencies. From 9aca168da7b26db0e8de879289f68588e369edfe Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 10 Oct 2025 15:24:48 -0500 Subject: [PATCH 22/51] wip first cut file uploads Assisted-by: Codex --- src/pdfrest/client.py | 166 +++++++++++++++++++++++++++++++++++- src/pdfrest/models.py | 38 ++++++++- tests/conftest.py | 35 ++++++++ tests/resources/__init__.py | 7 ++ tests/resources/report.docx | Bin 0 -> 8678 bytes tests/resources/report.pdf | Bin 0 -> 25588 bytes tests/test_client.py | 32 +------ tests/test_files.py | 143 +++++++++++++++++++++++++++++++ 8 files changed, 385 insertions(+), 36 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/resources/__init__.py create mode 100644 tests/resources/report.docx create mode 100644 tests/resources/report.pdf create mode 100644 tests/test_files.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 477a4bac..eb9f9f19 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -2,10 +2,12 @@ from __future__ import annotations +import asyncio import os import uuid -from collections.abc import Mapping -from typing import Any, Generic, Literal, TypeVar +from collections.abc import Iterable, Mapping, Sequence +from pathlib import Path +from typing import IO, Any, Generic, Literal, TypeVar, cast import httpx from httpx import URL @@ -17,7 +19,7 @@ PdfRestConfigurationError, translate_httpx_error, ) -from .models import PdfRestErrorResponse, UpResponse +from .models import PdfRestErrorResponse, PdfRestFile, UpResponse __all__ = ("AsyncPdfRestClient", "PdfRestClient") @@ -25,6 +27,8 @@ API_KEY_ENV_VAR = "PDFREST_API_KEY" API_KEY_HEADER_NAME = "Api-Key" DEFAULT_TIMEOUT_SECONDS = 10.0 +FILE_UPLOAD_FIELD_NAME = "file" +DEFAULT_FILE_INFO_CONCURRENCY = 8 HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] QueryParamValue = str | int | float | bool | None @@ -34,6 +38,49 @@ Body = Mapping[str, Any] +def _normalize_file_inputs(files: Iterable[IO[bytes]]) -> list[IO[bytes]]: + normalized = list(files) + if not normalized: + msg = "At least one file must be provided." + raise ValueError(msg) + for file_obj in normalized: + if not hasattr(file_obj, "read"): + msg = "files must be file-like objects opened in binary mode." + raise TypeError(msg) + return normalized + + +def _build_multipart_payload( + file_objects: Sequence[IO[bytes]], +) -> list[tuple[str, tuple[str, IO[bytes], str | None]]]: + multipart: list[tuple[str, tuple[str, IO[bytes], str | None]]] = [] + for file_obj in file_objects: + name_attr = getattr(file_obj, "name", None) + filename = Path(str(name_attr)).name if name_attr else FILE_UPLOAD_FIELD_NAME + multipart.append((FILE_UPLOAD_FIELD_NAME, (filename, file_obj, None))) + return multipart + + +def _extract_uploaded_file_ids(payload: Any) -> list[str]: + try: + files_payload = payload["files"] + except (TypeError, KeyError) as exc: # pragma: no cover - defensive + raise PdfRestApiError( + 500, message="Upload response missing 'files' collection." + ) from exc + if not isinstance(files_payload, Sequence): # pragma: no cover - defensive + raise PdfRestApiError(500, message="Upload response 'files' is not a sequence.") + entries = cast(Sequence[Mapping[str, Any]], files_payload) + file_ids: list[str] = [] + for entry in entries: + if "id" not in entry: + raise PdfRestApiError( + 500, message="Upload response contains invalid file references." + ) + file_ids.append(str(entry["id"])) + return file_ids + + ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient) @@ -103,6 +150,8 @@ class _RequestModel(BaseModel): headers: dict[str, str] = Field(default_factory=dict) timeout: TimeoutTypes json_body: dict[str, Any] | None = None + files: Any | None = None + data: Any | None = None model_config = ConfigDict(arbitrary_types_allowed=True) @@ -205,6 +254,8 @@ def _prepare_request( extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, + files: Any | None = None, + data: Any | None = None, ) -> _RequestModel: headers = self._compose_headers(extra_headers) params = self._compose_query_params(query, extra_query) @@ -219,6 +270,8 @@ def _prepare_request( headers=headers, timeout=timeout_value, json_body=json_payload, + files=files, + data=data, ) except PdfRestConfigurationError: raise @@ -226,6 +279,33 @@ def _prepare_request( raise PdfRestConfigurationError(str(exc)) from exc return request + def prepare_request( + self, + method: HttpMethod, + endpoint: str, + *, + query: Query | None = None, + json_body: Body | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + files: Any | None = None, + data: Any | None = None, + ) -> _RequestModel: + return self._prepare_request( + method, + endpoint, + query=query, + json_body=json_body, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + files=files, + data=data, + ) + def _compose_headers(self, extra_headers: AnyMapping | None) -> dict[str, str]: combined_headers: dict[str, str] = dict(self._config.headers) if extra_headers is None: @@ -351,11 +431,25 @@ def _send_request(self, request: _RequestModel) -> Any: headers=request.headers or None, timeout=request.timeout, json=request.json_body, + files=request.files, + data=request.data, ) except httpx.HTTPError as exc: raise translate_httpx_error(exc) from exc return self._handle_response(response) + def send_request(self, request: _RequestModel) -> Any: + return self._send_request(request) + + def fetch_file_info(self, file_id: str) -> PdfRestFile: + request = self.prepare_request( + "GET", + f"/resource/{file_id}", + query={"format": "info"}, + ) + payload = self._send_request(request) + return PdfRestFile.model_validate(payload) + class _AsyncApiClient(_BaseApiClient[httpx.AsyncClient]): """Internal asynchronous client implementation.""" @@ -406,11 +500,67 @@ async def _send_request(self, request: _RequestModel) -> Any: headers=request.headers or None, timeout=request.timeout, json=request.json_body, + files=request.files, + data=request.data, ) except httpx.HTTPError as exc: raise translate_httpx_error(exc) from exc return self._handle_response(response) + async def send_request(self, request: _RequestModel) -> Any: + return await self._send_request(request) + + async def fetch_file_info(self, file_id: str) -> PdfRestFile: + request = self.prepare_request( + "GET", + f"/resource/{file_id}", + query={"format": "info"}, + ) + payload = await self._send_request(request) + return PdfRestFile.model_validate(payload) + + +class _FilesClient: + """Expose file-related operations for the synchronous client.""" + + def __init__(self, client: _SyncApiClient) -> None: + self._client = client + + def create(self, files: Iterable[IO[bytes]]) -> list[PdfRestFile]: + file_objects = _normalize_file_inputs(files) + multipart = _build_multipart_payload(file_objects) + request = self._client.prepare_request("POST", "/upload", files=multipart) + payload = self._client.send_request(request) + file_ids = _extract_uploaded_file_ids(payload) + return [self._client.fetch_file_info(file_id) for file_id in file_ids] + + +class _AsyncFilesClient: + """Expose file-related operations for the asynchronous client.""" + + def __init__( + self, + client: _AsyncApiClient, + *, + concurrency_limit: int = DEFAULT_FILE_INFO_CONCURRENCY, + ) -> None: + self._client = client + self._concurrency_limit = concurrency_limit + + async def create(self, files: Iterable[IO[bytes]]) -> list[PdfRestFile]: + file_objects = _normalize_file_inputs(files) + multipart = _build_multipart_payload(file_objects) + request = self._client.prepare_request("POST", "/upload", files=multipart) + payload = await self._client.send_request(request) + file_ids = _extract_uploaded_file_ids(payload) + semaphore = asyncio.Semaphore(self._concurrency_limit) + + async def fetch(file_id: str) -> PdfRestFile: + async with semaphore: + return await self._client.fetch_file_info(file_id) + + return await asyncio.gather(*(fetch(file_id) for file_id in file_ids)) + class PdfRestClient(_SyncApiClient): """Synchronous client for interacting with the pdfrest API.""" @@ -435,6 +585,7 @@ def __init__( http_client=http_client, transport=transport, ) + self._files_client = _FilesClient(self) def __enter__(self) -> PdfRestClient: super().__enter__() @@ -443,6 +594,10 @@ def __enter__(self) -> PdfRestClient: def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: super().__exit__(exc_type, exc, traceback) + @property + def files(self) -> _FilesClient: + return self._files_client + def up( self, *, @@ -488,6 +643,7 @@ def __init__( http_client=http_client, transport=transport, ) + self._files_client = _AsyncFilesClient(self) async def __aenter__(self) -> AsyncPdfRestClient: await super().__aenter__() @@ -496,6 +652,10 @@ async def __aenter__(self) -> AsyncPdfRestClient: async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: await super().__aexit__(exc_type, exc, traceback) + @property + def files(self) -> _AsyncFilesClient: + return self._files_client + async def up( self, *, diff --git a/src/pdfrest/models.py b/src/pdfrest/models.py index fa3be785..37fcdbe7 100644 --- a/src/pdfrest/models.py +++ b/src/pdfrest/models.py @@ -4,9 +4,9 @@ from datetime import date -from pydantic import BaseModel, ConfigDict, Field +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, HttpUrl -__all__ = ("PdfRestErrorResponse", "UpResponse") +__all__ = ("PdfRestErrorResponse", "PdfRestFile", "UpResponse") class UpResponse(BaseModel): @@ -25,3 +25,37 @@ class PdfRestErrorResponse(BaseModel): error: str | None = Field(alias="message") model_config = ConfigDict(extra="allow", frozen=True) + + +class PdfRestFile(BaseModel): + """Represents a file on the pdfRest server.""" + + id: str = Field( + min_length=1, + description="Identifier of the file on the pdfRest server", + ) + name: str = Field( + min_length=1, + description="Name of the file", + ) + url: HttpUrl = Field( + description="URL from which the file can be downloaded", + ) + type: str = Field( + min_length=1, + description="MIME type of the file", + ) + size: int = Field( + description="Size of the file", + ) + modified: AwareDatetime = Field( + description="The last modified time of the file, which must include time zone " + "info.", + ) + scheduled_deletion_time_utc: AwareDatetime | None = Field( + alias="scheduledDeletionTimeUtc", + default=None, + description="The UTC time at which the file will be deleted from the server.", + ) + + model_config = ConfigDict(frozen=True) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..50fb297f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os + +import httpx +import pytest + +LIVE_BASE_URL_CANDIDATES: tuple[str, ...] = ( + "http://localhost:3000", + "https://apidev.pdfrest.com", + "https://api.pdfrest.com", +) + + +@pytest.fixture(scope="session") +def pdfrest_api_key() -> str: + key = os.getenv("PDFREST_API_KEY") + if not key: + pytest.fail("PDFREST_API_KEY is not configured.") + return key + + +@pytest.fixture(scope="session") +def pdfrest_live_base_url(pdfrest_api_key: str) -> str: + headers = {"Authorization": f"Bearer {pdfrest_api_key}"} + timeout = httpx.Timeout(2.0) + for base_url in LIVE_BASE_URL_CANDIDATES: + try: + with httpx.Client(base_url=base_url, timeout=timeout) as client: + response = client.get("/up", headers=headers) + except httpx.HTTPError: + continue + if response.is_success: + return base_url + pytest.fail("No reachable pdfRest API instance for live tests.") diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py new file mode 100644 index 00000000..4867293b --- /dev/null +++ b/tests/resources/__init__.py @@ -0,0 +1,7 @@ +from pathlib import Path + +here = Path(__file__).parent + + +def get_test_resource_path(*pathsegments: str) -> Path: + return here.joinpath(*pathsegments) diff --git a/tests/resources/report.docx b/tests/resources/report.docx new file mode 100644 index 0000000000000000000000000000000000000000..1effe9c628dd881278e139073fc77a86c138404c GIT binary patch literal 8678 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 literal 0 HcmV?d00001 diff --git a/tests/test_client.py b/tests/test_client.py index 66d9c871..27cf7ab5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from datetime import date from typing import Any @@ -17,12 +16,6 @@ UpResponse, ) -LIVE_BASE_URL_CANDIDATES: tuple[str, ...] = ( - "http://localhost:3000", - "https://apidev.pdfrest.com", - "https://api.pdfrest.com", -) - VALID_API_KEY = "12345678-1234-1234-1234-123456789abc" ANOTHER_VALID_API_KEY = "abcdefab-cdef-abcd-efab-cdefabcdef12" ASYNC_API_KEY = "fedcba98-7654-3210-fedc-ba9876543210" @@ -182,7 +175,7 @@ def test_prepare_request_merges_queries(monkeypatch: pytest.MonkeyPatch) -> None monkeypatch.setenv("PDFREST_API_KEY", "key") client = PdfRestClient(api_key=VALID_API_KEY) try: - request = client._prepare_request( + request = client.prepare_request( "GET", "/test", query={"base": "value", "skip": None}, @@ -335,29 +328,6 @@ def handler(_: httpx.Request) -> httpx.Response: await client.up(extra_body={"unexpected": "value"}) -@pytest.fixture(scope="session") -def pdfrest_api_key() -> str: - key = os.getenv("PDFREST_API_KEY") - if not key: - pytest.fail("PDFREST_API_KEY is not configured.") - return key - - -@pytest.fixture(scope="session") -def pdfrest_live_base_url(pdfrest_api_key: str) -> str: - headers = {"Authorization": f"Bearer {pdfrest_api_key}"} - timeout = httpx.Timeout(2.0) - for base_url in LIVE_BASE_URL_CANDIDATES: - try: - with httpx.Client(base_url=base_url, timeout=timeout) as client: - response = client.get("/up", headers=headers) - except httpx.HTTPError: - continue - if response.is_success: - return base_url - pytest.fail("No reachable pdfRest API instance for live tests.") - - def test_live_client_up(pdfrest_api_key: str, pdfrest_live_base_url: str) -> None: client = PdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) try: diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 00000000..a6394ddd --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Any + +import httpx +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile + +from .resources import get_test_resource_path + +VALID_API_KEY = "12345678-1234-1234-1234-123456789abc" + + +def _build_file_info_payload(file_id: str, name: str) -> dict[str, Any]: + return { + "id": file_id, + "name": name, + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "application/pdf" + if name.endswith(".pdf") + else "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "size": 1, + "modified": datetime(2024, 1, 1, tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + "scheduledDeletionTimeUtc": None, + } + + +def test_files_create_uses_upload_and_info() -> None: + uploaded_file_id = str(uuid.uuid4()) + info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_file_id}, + ] + }, + ) + if ( + request.method == "GET" + and request.url.path == f"/resource/{uploaded_file_id}" + ): + assert request.url.params["format"] == "info" + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(api_key=VALID_API_KEY, transport=transport) + report_pdf = get_test_resource_path("report.pdf") + try: + with report_pdf.open("rb") as pdf_file: + response = client.files.create([pdf_file]) + finally: + client.close() + + assert isinstance(response, list) + assert len(response) == 1 + file_repr = response[0] + assert isinstance(file_repr, PdfRestFile) + assert file_repr.id == uploaded_file_id + assert file_repr.name == "report.pdf" + assert str(file_repr.url).endswith(uploaded_file_id) + + +@pytest.mark.asyncio +async def test_async_files_create_uses_upload_and_info() -> None: + uploaded_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + info_payloads = { + uploaded_ids[0]: _build_file_info_payload(uploaded_ids[0], "report.pdf"), + uploaded_ids[1]: _build_file_info_payload(uploaded_ids[1], "report.docx"), + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_ids[0]}, + {"name": "report.docx", "id": uploaded_ids[1]}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + file_id = request.url.path.split("/")[-1] + assert request.url.params["format"] == "info" + payload = info_payloads[file_id] + return httpx.Response(200, json=payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + client = AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) + + report_pdf = get_test_resource_path("report.pdf") + report_docx = get_test_resource_path("report.docx") + async with client: + with report_pdf.open("rb") as pdf_file, report_docx.open("rb") as docx_file: + response = await client.files.create([pdf_file, docx_file]) + + assert isinstance(response, list) + assert len(response) == 2 + for file_repr, file_id in zip(response, uploaded_ids, strict=True): + assert isinstance(file_repr, PdfRestFile) + assert file_repr.id == file_id + + +def test_live_file_create(pdfrest_api_key: str, pdfrest_live_base_url: str) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + report_pdf = get_test_resource_path("report.pdf") + with report_pdf.open("rb") as pdf_file: + response = client.files.create([pdf_file]) + assert isinstance(response, list) + assert len(response) == 1 + assert isinstance(response[0], PdfRestFile) + + +def test_live_file_create_two_files( + pdfrest_api_key: str, pdfrest_live_base_url: str +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + report_pdf = get_test_resource_path("report.pdf") + report_docx = get_test_resource_path("report.docx") + with report_pdf.open("rb") as pdf_file, report_docx.open("rb") as docx_file: + response = client.files.create([pdf_file, docx_file]) + assert isinstance(response, list) + assert len(response) == 2 + assert isinstance(response[0], PdfRestFile) + assert isinstance(response[1], PdfRestFile) From a2cd7818614ed67baaf0eae202d3777c9ee1ad4e Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 27 Oct 2025 18:15:59 -0500 Subject: [PATCH 23/51] Refactor file upload handling in clients and tests - Replaced `Iterable[IO[bytes]]` file input normalization with comprehensive `UploadFiles` support for raw content and multipart tuples. - Added `create_from_paths` methods for the client, supporting file uploads by paths with optional content type and headers. - Updated synchronous and asynchronous file creation flows to align with the new normalization approach. - Extended test coverage for new client methods and file upload functionality. Assisted-by: Codex --- src/pdfrest/client.py | 247 +++++++++++++++++++++++++++++++++++------ tests/test_files.py | 249 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 452 insertions(+), 44 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index eb9f9f19..49ef63f4 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -5,9 +5,11 @@ import asyncio import os import uuid -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Mapping, Sequence +from contextlib import ExitStack +from os import PathLike from pathlib import Path -from typing import IO, Any, Generic, Literal, TypeVar, cast +from typing import IO, Any, Generic, Literal, TypeAlias, TypeVar, cast import httpx from httpx import URL @@ -37,28 +39,18 @@ Query = Mapping[str, QueryParamValue] Body = Mapping[str, Any] +FileContent = IO[bytes] | bytes | str +FileTuple2 = tuple[str | None, FileContent] +FileTuple3 = tuple[str | None, FileContent, str | None] +FileTuple4 = tuple[str | None, FileContent, str | None, Mapping[str, str]] +FileTypes = FileContent | FileTuple2 | FileTuple3 | FileTuple4 +UploadFiles = Mapping[str, FileTypes] | Sequence[tuple[str, FileTypes]] -def _normalize_file_inputs(files: Iterable[IO[bytes]]) -> list[IO[bytes]]: - normalized = list(files) - if not normalized: - msg = "At least one file must be provided." - raise ValueError(msg) - for file_obj in normalized: - if not hasattr(file_obj, "read"): - msg = "files must be file-like objects opened in binary mode." - raise TypeError(msg) - return normalized - - -def _build_multipart_payload( - file_objects: Sequence[IO[bytes]], -) -> list[tuple[str, tuple[str, IO[bytes], str | None]]]: - multipart: list[tuple[str, tuple[str, IO[bytes], str | None]]] = [] - for file_obj in file_objects: - name_attr = getattr(file_obj, "name", None) - filename = Path(str(name_attr)).name if name_attr else FILE_UPLOAD_FIELD_NAME - multipart.append((FILE_UPLOAD_FIELD_NAME, (filename, file_obj, None))) - return multipart +FilePath = str | PathLike[str] +FilePathTuple2 = tuple[FilePath, str | None] +FilePathTuple3 = tuple[FilePath, str | None, Mapping[str, str]] +FilePathTypes = FilePath | FilePathTuple2 | FilePathTuple3 +NormalizedFileTypes: TypeAlias = FileContent | FileTuple2 | FileTuple3 | FileTuple4 def _extract_uploaded_file_ids(payload: Any) -> list[str]: @@ -81,6 +73,108 @@ def _extract_uploaded_file_ids(payload: Any) -> list[str]: return file_ids +def _normalize_headers(headers: Mapping[str, str]) -> Mapping[str, str]: + return {str(key): str(value) for key, value in headers.items()} + + +def _ensure_file_content(value: FileContent) -> FileContent: + if isinstance(value, (bytes, str)): + return value + if hasattr(value, "read"): + return value + msg = "File content must be a readable binary stream, bytes, or str." + raise TypeError(msg) + + +def _normalize_file_type(file_value: FileTypes) -> NormalizedFileTypes: + if isinstance(file_value, tuple): + length = len(file_value) + if length not in {2, 3, 4}: + msg = "File tuple inputs must contain 2, 3, or 4 items." + raise TypeError(msg) + if length == 2: + filename, content = cast(FileTuple2, file_value) + normalized_filename = str(filename) if filename is not None else None + normalized_content = _ensure_file_content(content) + return (normalized_filename, normalized_content) + if length == 3: + filename, content, content_type = cast(FileTuple3, file_value) + normalized_filename = str(filename) if filename is not None else None + normalized_content = _ensure_file_content(content) + normalized_content_type = ( + str(content_type) if content_type is not None else None + ) + return (normalized_filename, normalized_content, normalized_content_type) + + filename, content, content_type, headers = cast(FileTuple4, file_value) + normalized_filename = str(filename) if filename is not None else None + normalized_content = _ensure_file_content(content) + normalized_content_type = ( + str(content_type) if content_type is not None else None + ) + if not isinstance(headers, Mapping): + msg = "Headers must be provided as a mapping of str keys to str values." + raise TypeError(msg) + normalized_headers = _normalize_headers(headers) + return ( + normalized_filename, + normalized_content, + normalized_content_type, + normalized_headers, + ) + return _ensure_file_content(file_value) + + +def _normalize_upload_files( + files: UploadFiles, +) -> list[tuple[str, NormalizedFileTypes]]: + is_mapping = isinstance(files, Mapping) + if is_mapping: + mapping_files = cast(Mapping[str, FileTypes], files) + items: list[tuple[str, FileTypes]] = list(mapping_files.items()) + else: + sequence_files = cast(Sequence[tuple[str, FileTypes]], files) + items = list(sequence_files) + if not items: + msg = "At least one file must be provided." + raise ValueError(msg) + normalized_items: list[tuple[str, NormalizedFileTypes]] = [] + for entry in items: + if is_mapping: + field_name, file_value = entry + else: + if not isinstance(entry, tuple) or len(entry) != 2: + msg = "Files sequence entries must be (field_name, file_value) tuples." + raise TypeError(msg) + field_name, file_value = entry + normalized_items.append((str(field_name), _normalize_file_type(file_value))) + return normalized_items + + +def _parse_path_spec(spec: FilePathTypes) -> tuple[Path, str | None, Mapping[str, str]]: + if isinstance(spec, tuple): + length = len(spec) + if length == 2: + raw_path, content_type = cast(FilePathTuple2, spec) + headers: Mapping[str, str] = {} + elif length == 3: + raw_path, content_type, headers = cast(FilePathTuple3, spec) + if not isinstance(headers, Mapping): + msg = "Headers must be provided as a mapping of str keys to str values." + raise TypeError(msg) + else: + msg = "File path tuples must contain a path plus optional content type and headers." + raise TypeError(msg) + normalized_headers = _normalize_headers(headers) + normalized_content_type = ( + str(content_type) if content_type is not None else None + ) + path = Path(raw_path) + return path, normalized_content_type, normalized_headers + path = Path(spec) + return path, None, {} + + ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient) @@ -526,14 +620,60 @@ class _FilesClient: def __init__(self, client: _SyncApiClient) -> None: self._client = client - def create(self, files: Iterable[IO[bytes]]) -> list[PdfRestFile]: - file_objects = _normalize_file_inputs(files) - multipart = _build_multipart_payload(file_objects) - request = self._client.prepare_request("POST", "/upload", files=multipart) + def create(self, files: UploadFiles) -> list[PdfRestFile]: + """Upload one or more files by content, in the same style accepted by + the `files` parameter of `httpx.Client.post`. + + Provide either a mapping of field names to file specifications, or a + sequence of `(field_name, file_spec)` tuples. File specifications may be + raw file-like objects, bytes, str, or the tuple forms documented by + httpx. + """ + normalized_files = _normalize_upload_files(files) + request = self._client.prepare_request( + "POST", "/upload", files=normalized_files + ) payload = self._client.send_request(request) file_ids = _extract_uploaded_file_ids(payload) return [self._client.fetch_file_info(file_id) for file_id in file_ids] + def create_from_paths( + self, file_paths: Sequence[FilePathTypes] + ) -> list[PdfRestFile]: + """Upload one or more files by their path. + + Each entry may be a bare path-like object or a tuple of + `(path, content_type)` / `(path, content_type, headers)` where headers + mirrors the httpx multipart header mapping. All opened file handles are + closed once the request completes. + """ + if not file_paths: + msg = "At least one file path must be provided." + raise ValueError(msg) + + with ExitStack() as stack: + upload_entries: list[tuple[str, FileTypes]] = [] + for spec in file_paths: + path, content_type, headers = _parse_path_spec(spec) + file_obj = stack.enter_context(path.open("rb")) + filename = path.name + if headers: + upload_entries.append( + ( + FILE_UPLOAD_FIELD_NAME, + (filename, file_obj, content_type, headers), + ) + ) + elif content_type is not None: + upload_entries.append( + (FILE_UPLOAD_FIELD_NAME, (filename, file_obj, content_type)) + ) + else: + upload_entries.append( + (FILE_UPLOAD_FIELD_NAME, (filename, file_obj)) + ) + return self.create(upload_entries) + class _AsyncFilesClient: """Expose file-related operations for the asynchronous client.""" @@ -547,10 +687,18 @@ def __init__( self._client = client self._concurrency_limit = concurrency_limit - async def create(self, files: Iterable[IO[bytes]]) -> list[PdfRestFile]: - file_objects = _normalize_file_inputs(files) - multipart = _build_multipart_payload(file_objects) - request = self._client.prepare_request("POST", "/upload", files=multipart) + async def create(self, files: UploadFiles) -> list[PdfRestFile]: + """Upload one or more files by content, in the same style accepted by + the `files` parameter of `httpx.AsyncClient.post`. + + Provide either a mapping of field names to file specifications, or a + sequence of `(field_name, file_spec)` tuples. File specifications may be + raw file-like objects, bytes, str, or the tuple forms documented by + httpx.""" + normalized_files = _normalize_upload_files(files) + request = self._client.prepare_request( + "POST", "/upload", files=normalized_files + ) payload = await self._client.send_request(request) file_ids = _extract_uploaded_file_ids(payload) semaphore = asyncio.Semaphore(self._concurrency_limit) @@ -561,6 +709,43 @@ async def fetch(file_id: str) -> PdfRestFile: return await asyncio.gather(*(fetch(file_id) for file_id in file_ids)) + async def create_from_paths( + self, file_paths: Sequence[FilePathTypes] + ) -> list[PdfRestFile]: + """Upload one or more files by their path. + + Each entry may be a bare path-like object or a tuple of + `(path, content_type)` / `(path, content_type, headers)` where headers + mirrors the httpx multipart header mapping. All opened file handles are + closed once the request completes. + """ + if not file_paths: + msg = "At least one file path must be provided." + raise ValueError(msg) + + with ExitStack() as stack: + upload_entries: list[tuple[str, FileTypes]] = [] + for spec in file_paths: + path, content_type, headers = _parse_path_spec(spec) + file_obj = stack.enter_context(path.open("rb")) + filename = path.name + if headers: + upload_entries.append( + ( + FILE_UPLOAD_FIELD_NAME, + (filename, file_obj, content_type, headers), + ) + ) + elif content_type is not None: + upload_entries.append( + (FILE_UPLOAD_FIELD_NAME, (filename, file_obj, content_type)) + ) + else: + upload_entries.append( + (FILE_UPLOAD_FIELD_NAME, (filename, file_obj)) + ) + return await self.create(upload_entries) + class PdfRestClient(_SyncApiClient): """Synchronous client for interacting with the pdfrest API.""" diff --git a/tests/test_files.py b/tests/test_files.py index a6394ddd..bd84c21a 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -31,6 +31,23 @@ def _build_file_info_payload(file_id: str, name: str) -> dict[str, Any]: } +def _iso_to_datetime(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _assert_file_matches_payload( + file_repr: PdfRestFile, expected_payload: dict[str, Any] +) -> None: + assert isinstance(file_repr, PdfRestFile) + assert file_repr.id == expected_payload["id"] + assert file_repr.name == expected_payload["name"] + assert str(file_repr.url) == expected_payload["url"] + assert file_repr.type == expected_payload["type"] + assert file_repr.size == expected_payload["size"] + assert file_repr.modified == _iso_to_datetime(expected_payload["modified"]) + assert file_repr.scheduled_deletion_time_utc is None + + def test_files_create_uses_upload_and_info() -> None: uploaded_file_id = str(uuid.uuid4()) info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") @@ -59,7 +76,7 @@ def handler(request: httpx.Request) -> httpx.Response: report_pdf = get_test_resource_path("report.pdf") try: with report_pdf.open("rb") as pdf_file: - response = client.files.create([pdf_file]) + response = client.files.create({"file": ("report.pdf", pdf_file)}) finally: client.close() @@ -67,9 +84,115 @@ def handler(request: httpx.Request) -> httpx.Response: assert len(response) == 1 file_repr = response[0] assert isinstance(file_repr, PdfRestFile) - assert file_repr.id == uploaded_file_id - assert file_repr.name == "report.pdf" - assert str(file_repr.url).endswith(uploaded_file_id) + _assert_file_matches_payload(file_repr, info_payload) + + +def test_files_create_from_paths_uses_upload_and_info() -> None: + uploaded_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + info_payloads = { + uploaded_ids[0]: _build_file_info_payload(uploaded_ids[0], "report.pdf"), + uploaded_ids[1]: _build_file_info_payload(uploaded_ids[1], "report.docx"), + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + body = request.content + assert body.count(b'name="file"') == 2 + assert b'filename="report.pdf"' in body + assert b'filename="report.docx"' in body + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_ids[0]}, + {"name": "report.docx", "id": uploaded_ids[1]}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + file_id = request.url.path.split("/")[-1] + assert request.url.params["format"] == "info" + payload = info_payloads[file_id] + return httpx.Response(200, json=payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(api_key=VALID_API_KEY, transport=transport) + report_pdf = get_test_resource_path("report.pdf") + report_docx = get_test_resource_path("report.docx") + try: + response = client.files.create_from_paths([report_pdf, report_docx]) + finally: + client.close() + + assert isinstance(response, list) + assert len(response) == 2 + for file_repr in response: + payload = info_payloads[file_repr.id] + _assert_file_matches_payload(file_repr, payload) + + +def test_files_create_from_paths_supports_metadata() -> None: + uploaded_file_id = str(uuid.uuid4()) + info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + body = request.content + assert b'filename="report.pdf"' in body + assert b"Content-Type: application/test-pdf" in body + assert b"X-Custom: header" in body + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_file_id}, + ] + }, + ) + if request.method == "GET": + assert request.url.params["format"] == "info" + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(api_key=VALID_API_KEY, transport=transport) + report_pdf = get_test_resource_path("report.pdf") + try: + response = client.files.create_from_paths( + [ + ( + report_pdf, + "application/test-pdf", + {"X-Custom": "header"}, + ) + ] + ) + finally: + client.close() + + assert len(response) == 1 + _assert_file_matches_payload(response[0], info_payload) + + +def test_files_create_rejects_empty_input() -> None: + client = PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(lambda _: httpx.Response(200)), + ) + try: + with pytest.raises(ValueError, match=r"At least one file must be provided\."): + client.files.create({}) + with pytest.raises(ValueError, match=r"At least one file must be provided\."): + client.files.create([]) + with pytest.raises( + ValueError, match=r"At least one file path must be provided\." + ): + client.files.create_from_paths([]) + finally: + client.close() @pytest.mark.asyncio @@ -106,13 +229,63 @@ def handler(request: httpx.Request) -> httpx.Response: report_docx = get_test_resource_path("report.docx") async with client: with report_pdf.open("rb") as pdf_file, report_docx.open("rb") as docx_file: - response = await client.files.create([pdf_file, docx_file]) + response = await client.files.create( + [ + ("file", ("report.pdf", pdf_file)), + ("file", ("report.docx", docx_file)), + ] + ) + + assert isinstance(response, list) + assert len(response) == 2 + for file_repr in response: + payload = info_payloads[file_repr.id] + _assert_file_matches_payload(file_repr, payload) + + +@pytest.mark.asyncio +async def test_async_files_create_from_paths() -> None: + uploaded_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + info_payloads = { + uploaded_ids[0]: _build_file_info_payload(uploaded_ids[0], "report.pdf"), + uploaded_ids[1]: _build_file_info_payload(uploaded_ids[1], "report.docx"), + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + body = request.content + assert body.count(b'name="file"') == 2 + assert b'filename="report.pdf"' in body + assert b'filename="report.docx"' in body + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_ids[0]}, + {"name": "report.docx", "id": uploaded_ids[1]}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + file_id = request.url.path.split("/")[-1] + assert request.url.params["format"] == "info" + payload = info_payloads[file_id] + return httpx.Response(200, json=payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + client = AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) + report_pdf = get_test_resource_path("report.pdf") + report_docx = get_test_resource_path("report.docx") + async with client: + response = await client.files.create_from_paths([report_pdf, report_docx]) assert isinstance(response, list) assert len(response) == 2 - for file_repr, file_id in zip(response, uploaded_ids, strict=True): - assert isinstance(file_repr, PdfRestFile) - assert file_repr.id == file_id + for file_repr in response: + payload = info_payloads[file_repr.id] + _assert_file_matches_payload(file_repr, payload) def test_live_file_create(pdfrest_api_key: str, pdfrest_live_base_url: str) -> None: @@ -121,10 +294,13 @@ def test_live_file_create(pdfrest_api_key: str, pdfrest_live_base_url: str) -> N ) as client: report_pdf = get_test_resource_path("report.pdf") with report_pdf.open("rb") as pdf_file: - response = client.files.create([pdf_file]) + response = client.files.create({"file": pdf_file}) assert isinstance(response, list) assert len(response) == 1 - assert isinstance(response[0], PdfRestFile) + file_repr = response[0] + assert isinstance(file_repr, PdfRestFile) + assert file_repr.id + assert file_repr.name def test_live_file_create_two_files( @@ -136,8 +312,55 @@ def test_live_file_create_two_files( report_pdf = get_test_resource_path("report.pdf") report_docx = get_test_resource_path("report.docx") with report_pdf.open("rb") as pdf_file, report_docx.open("rb") as docx_file: - response = client.files.create([pdf_file, docx_file]) + response = client.files.create([("file", pdf_file), ("file", docx_file)]) assert isinstance(response, list) assert len(response) == 2 - assert isinstance(response[0], PdfRestFile) - assert isinstance(response[1], PdfRestFile) + names = {file_repr.name for file_repr in response} + assert {"report.pdf", "report.docx"} <= names + + +def test_live_file_create_from_paths( + pdfrest_api_key: str, pdfrest_live_base_url: str +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + report_pdf = get_test_resource_path("report.pdf") + report_docx = get_test_resource_path("report.docx") + response = client.files.create_from_paths([report_pdf, report_docx]) + assert isinstance(response, list) + assert len(response) == 2 + names = {file_repr.name for file_repr in response} + assert {"report.pdf", "report.docx"} <= names + + +@pytest.mark.asyncio +async def test_live_async_file_create( + pdfrest_api_key: str, pdfrest_live_base_url: str +) -> None: + client = AsyncPdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) + report_pdf = get_test_resource_path("report.pdf") + async with client: + with report_pdf.open("rb") as pdf_file: + response = await client.files.create({"file": pdf_file}) + assert isinstance(response, list) + assert len(response) == 1 + file_repr = response[0] + assert isinstance(file_repr, PdfRestFile) + assert file_repr.id + assert file_repr.name + + +@pytest.mark.asyncio +async def test_live_async_file_create_from_paths( + pdfrest_api_key: str, pdfrest_live_base_url: str +) -> None: + client = AsyncPdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) + report_pdf = get_test_resource_path("report.pdf") + report_docx = get_test_resource_path("report.docx") + async with client: + response = await client.files.create_from_paths([report_pdf, report_docx]) + assert isinstance(response, list) + assert len(response) == 2 + names = {file_repr.name for file_repr in response} + assert {"report.pdf", "report.docx"} <= names From 4adbe4e3a2effe388f29f9390c2d128ad9872f76 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 27 Oct 2025 18:32:41 -0500 Subject: [PATCH 24/51] Enhance file upload functionality in clients and tests - Simplified `UploadFiles` types and updated handling for single and sequence-based file specifications. - Replaced mapping-based file upload support with streamlined normalization logic, because the target field is always "file". - Introduced `_normalize_path_inputs` for consistent path handling in `create_from_paths`. - Updated file creation flows to use the refined file upload logic. - Added and extended test cases to cover updated behaviors and edge cases. Assisted-by: Codex --- src/pdfrest/client.py | 126 +++++++++++++++++++----------------------- tests/test_files.py | 90 +++++++++++++++++++++++++++--- 2 files changed, 137 insertions(+), 79 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 49ef63f4..d43d3d09 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -44,12 +44,13 @@ FileTuple3 = tuple[str | None, FileContent, str | None] FileTuple4 = tuple[str | None, FileContent, str | None, Mapping[str, str]] FileTypes = FileContent | FileTuple2 | FileTuple3 | FileTuple4 -UploadFiles = Mapping[str, FileTypes] | Sequence[tuple[str, FileTypes]] +UploadFiles = Sequence[FileTypes] | FileTypes FilePath = str | PathLike[str] FilePathTuple2 = tuple[FilePath, str | None] FilePathTuple3 = tuple[FilePath, str | None, Mapping[str, str]] FilePathTypes = FilePath | FilePathTuple2 | FilePathTuple3 +FilePathInput = FilePathTypes | Sequence[FilePathTypes] NormalizedFileTypes: TypeAlias = FileContent | FileTuple2 | FileTuple3 | FileTuple4 @@ -128,26 +129,24 @@ def _normalize_file_type(file_value: FileTypes) -> NormalizedFileTypes: def _normalize_upload_files( files: UploadFiles, ) -> list[tuple[str, NormalizedFileTypes]]: - is_mapping = isinstance(files, Mapping) - if is_mapping: - mapping_files = cast(Mapping[str, FileTypes], files) - items: list[tuple[str, FileTypes]] = list(mapping_files.items()) + if isinstance(files, Mapping): + msg = "Upload files must be provided as a sequence or a single file specification." + raise TypeError(msg) + + if isinstance(files, Sequence) and not isinstance(files, (str, bytes, bytearray)): + items = list(files) else: - sequence_files = cast(Sequence[tuple[str, FileTypes]], files) - items = list(sequence_files) + # Treat single file specification as a one-element sequence. + items = [cast(FileTypes, files)] + if not items: msg = "At least one file must be provided." raise ValueError(msg) normalized_items: list[tuple[str, NormalizedFileTypes]] = [] - for entry in items: - if is_mapping: - field_name, file_value = entry - else: - if not isinstance(entry, tuple) or len(entry) != 2: - msg = "Files sequence entries must be (field_name, file_value) tuples." - raise TypeError(msg) - field_name, file_value = entry - normalized_items.append((str(field_name), _normalize_file_type(file_value))) + for file_value in items: + normalized_items.append( + (FILE_UPLOAD_FIELD_NAME, _normalize_file_type(cast(FileTypes, file_value))) + ) return normalized_items @@ -175,6 +174,22 @@ def _parse_path_spec(spec: FilePathTypes) -> tuple[Path, str | None, Mapping[str return path, None, {} +def _normalize_path_inputs( + file_paths: FilePathInput, +) -> list[FilePathTypes]: + if isinstance(file_paths, Sequence) and not isinstance( + file_paths, (str, bytes, bytearray) + ): + sequence_paths = cast(Sequence[FilePathTypes], file_paths) + items: list[FilePathTypes] = list(sequence_paths) + else: + items = [cast(FilePathTypes, file_paths)] + if not items: + msg = "At least one file path must be provided." + raise ValueError(msg) + return items + + ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient) @@ -621,13 +636,11 @@ def __init__(self, client: _SyncApiClient) -> None: self._client = client def create(self, files: UploadFiles) -> list[PdfRestFile]: - """Upload one or more files by content, in the same style accepted by - the `files` parameter of `httpx.Client.post`. + """Upload one or more files by content. - Provide either a mapping of field names to file specifications, or a - sequence of `(field_name, file_spec)` tuples. File specifications may be - raw file-like objects, bytes, str, or the tuple forms documented by - httpx. + Provide either a single file specification or a sequence of file + specifications (each matching the shapes accepted by httpx). Every + uploaded part is sent using the field name ``file``. """ normalized_files = _normalize_upload_files(files) request = self._client.prepare_request( @@ -637,9 +650,7 @@ def create(self, files: UploadFiles) -> list[PdfRestFile]: file_ids = _extract_uploaded_file_ids(payload) return [self._client.fetch_file_info(file_id) for file_id in file_ids] - def create_from_paths( - self, file_paths: Sequence[FilePathTypes] - ) -> list[PdfRestFile]: + def create_from_paths(self, file_paths: FilePathInput) -> list[PdfRestFile]: """Upload one or more files by their path. Each entry may be a bare path-like object or a tuple of @@ -647,32 +658,21 @@ def create_from_paths( mirrors the httpx multipart header mapping. All opened file handles are closed once the request completes. """ - if not file_paths: - msg = "At least one file path must be provided." - raise ValueError(msg) + normalized_paths = _normalize_path_inputs(file_paths) with ExitStack() as stack: - upload_entries: list[tuple[str, FileTypes]] = [] - for spec in file_paths: + upload_specs: list[FileTypes] = [] + for spec in normalized_paths: path, content_type, headers = _parse_path_spec(spec) file_obj = stack.enter_context(path.open("rb")) filename = path.name if headers: - upload_entries.append( - ( - FILE_UPLOAD_FIELD_NAME, - (filename, file_obj, content_type, headers), - ) - ) + upload_specs.append((filename, file_obj, content_type, headers)) elif content_type is not None: - upload_entries.append( - (FILE_UPLOAD_FIELD_NAME, (filename, file_obj, content_type)) - ) + upload_specs.append((filename, file_obj, content_type)) else: - upload_entries.append( - (FILE_UPLOAD_FIELD_NAME, (filename, file_obj)) - ) - return self.create(upload_entries) + upload_specs.append((filename, file_obj)) + return self.create(upload_specs) class _AsyncFilesClient: @@ -688,13 +688,12 @@ def __init__( self._concurrency_limit = concurrency_limit async def create(self, files: UploadFiles) -> list[PdfRestFile]: - """Upload one or more files by content, in the same style accepted by - the `files` parameter of `httpx.AsyncClient.post`. + """Upload one or more files by content. - Provide either a mapping of field names to file specifications, or a - sequence of `(field_name, file_spec)` tuples. File specifications may be - raw file-like objects, bytes, str, or the tuple forms documented by - httpx.""" + Provide either a single file specification or a sequence of file + specifications (each matching the shapes accepted by httpx). Every + uploaded part is sent using the field name ``file``. + """ normalized_files = _normalize_upload_files(files) request = self._client.prepare_request( "POST", "/upload", files=normalized_files @@ -709,9 +708,7 @@ async def fetch(file_id: str) -> PdfRestFile: return await asyncio.gather(*(fetch(file_id) for file_id in file_ids)) - async def create_from_paths( - self, file_paths: Sequence[FilePathTypes] - ) -> list[PdfRestFile]: + async def create_from_paths(self, file_paths: FilePathInput) -> list[PdfRestFile]: """Upload one or more files by their path. Each entry may be a bare path-like object or a tuple of @@ -719,32 +716,21 @@ async def create_from_paths( mirrors the httpx multipart header mapping. All opened file handles are closed once the request completes. """ - if not file_paths: - msg = "At least one file path must be provided." - raise ValueError(msg) + normalized_paths = _normalize_path_inputs(file_paths) with ExitStack() as stack: - upload_entries: list[tuple[str, FileTypes]] = [] - for spec in file_paths: + upload_specs: list[FileTypes] = [] + for spec in normalized_paths: path, content_type, headers = _parse_path_spec(spec) file_obj = stack.enter_context(path.open("rb")) filename = path.name if headers: - upload_entries.append( - ( - FILE_UPLOAD_FIELD_NAME, - (filename, file_obj, content_type, headers), - ) - ) + upload_specs.append((filename, file_obj, content_type, headers)) elif content_type is not None: - upload_entries.append( - (FILE_UPLOAD_FIELD_NAME, (filename, file_obj, content_type)) - ) + upload_specs.append((filename, file_obj, content_type)) else: - upload_entries.append( - (FILE_UPLOAD_FIELD_NAME, (filename, file_obj)) - ) - return await self.create(upload_entries) + upload_specs.append((filename, file_obj)) + return await self.create(upload_specs) class PdfRestClient(_SyncApiClient): diff --git a/tests/test_files.py b/tests/test_files.py index bd84c21a..4a07db1e 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone -from typing import Any +from typing import Any, cast import httpx import pytest @@ -76,7 +76,7 @@ def handler(request: httpx.Request) -> httpx.Response: report_pdf = get_test_resource_path("report.pdf") try: with report_pdf.open("rb") as pdf_file: - response = client.files.create({"file": ("report.pdf", pdf_file)}) + response = client.files.create([("report.pdf", pdf_file)]) finally: client.close() @@ -133,6 +133,41 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(file_repr, payload) +def test_files_create_from_paths_single_path() -> None: + uploaded_file_id = str(uuid.uuid4()) + info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + body = request.content + assert body.count(b'name="file"') == 1 + assert b'filename="report.pdf"' in body + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_file_id}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + assert request.url.params["format"] == "info" + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + client = PdfRestClient(api_key=VALID_API_KEY, transport=transport) + report_pdf = get_test_resource_path("report.pdf") + try: + response = client.files.create_from_paths(report_pdf) + finally: + client.close() + + assert len(response) == 1 + _assert_file_matches_payload(response[0], info_payload) + + def test_files_create_from_paths_supports_metadata() -> None: uploaded_file_id = str(uuid.uuid4()) info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") @@ -183,8 +218,11 @@ def test_files_create_rejects_empty_input() -> None: transport=httpx.MockTransport(lambda _: httpx.Response(200)), ) try: - with pytest.raises(ValueError, match=r"At least one file must be provided\."): - client.files.create({}) + with pytest.raises( + TypeError, + match=r"Upload files must be provided as a sequence or a single file specification\.", + ): + client.files.create(cast(Any, {})) with pytest.raises(ValueError, match=r"At least one file must be provided\."): client.files.create([]) with pytest.raises( @@ -231,8 +269,8 @@ def handler(request: httpx.Request) -> httpx.Response: with report_pdf.open("rb") as pdf_file, report_docx.open("rb") as docx_file: response = await client.files.create( [ - ("file", ("report.pdf", pdf_file)), - ("file", ("report.docx", docx_file)), + ("report.pdf", pdf_file), + ("report.docx", docx_file), ] ) @@ -288,13 +326,47 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(file_repr, payload) +@pytest.mark.asyncio +async def test_async_files_create_from_paths_single_path() -> None: + uploaded_file_id = str(uuid.uuid4()) + info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + body = request.content + assert body.count(b'name="file"') == 1 + assert b'filename="report.pdf"' in body + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_file_id}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + assert request.url.params["format"] == "info" + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + client = AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) + report_pdf = get_test_resource_path("report.pdf") + async with client: + response = await client.files.create_from_paths(report_pdf) + + assert len(response) == 1 + _assert_file_matches_payload(response[0], info_payload) + + def test_live_file_create(pdfrest_api_key: str, pdfrest_live_base_url: str) -> None: with PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url ) as client: report_pdf = get_test_resource_path("report.pdf") with report_pdf.open("rb") as pdf_file: - response = client.files.create({"file": pdf_file}) + response = client.files.create([pdf_file]) assert isinstance(response, list) assert len(response) == 1 file_repr = response[0] @@ -312,7 +384,7 @@ def test_live_file_create_two_files( report_pdf = get_test_resource_path("report.pdf") report_docx = get_test_resource_path("report.docx") with report_pdf.open("rb") as pdf_file, report_docx.open("rb") as docx_file: - response = client.files.create([("file", pdf_file), ("file", docx_file)]) + response = client.files.create([pdf_file, docx_file]) assert isinstance(response, list) assert len(response) == 2 names = {file_repr.name for file_repr in response} @@ -342,7 +414,7 @@ async def test_live_async_file_create( report_pdf = get_test_resource_path("report.pdf") async with client: with report_pdf.open("rb") as pdf_file: - response = await client.files.create({"file": pdf_file}) + response = await client.files.create([pdf_file]) assert isinstance(response, list) assert len(response) == 1 file_repr = response[0] From adbb8ed3e6b3e26e91ba637f7c69b7be1b75e712 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 28 Oct 2025 15:17:39 -0500 Subject: [PATCH 25/51] Use context managers in tests to simplify resource handling - Refactored clients in tests to utilize context managers (`with` statements) for better resource management. - Removed explicit `.close()` calls across various test cases. Assisted-by: Codex --- tests/test_client.py | 92 ++++++++++++++++---------------------------- tests/test_files.py | 52 +++++++++---------------- 2 files changed, 53 insertions(+), 91 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 27cf7ab5..56fc0e51 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -39,11 +39,8 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = PdfRestClient(api_key=VALID_API_KEY, transport=transport) - try: + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.up() - finally: - client.close() assert isinstance(response, UpResponse) assert response.release_date == date(2025, 9, 25) @@ -58,11 +55,8 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = PdfRestClient(base_url="https://example.com", transport=transport) - try: + with PdfRestClient(base_url="https://example.com", transport=transport) as client: response = client.up() - finally: - client.close() assert response.product == "pdfRest API Toolkit" @@ -100,11 +94,10 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = PdfRestClient(base_url="https://internal.example", transport=transport) - try: + with PdfRestClient( + base_url="https://internal.example", transport=transport + ) as client: response = client.up() - finally: - client.close() assert response.status == "OK" @@ -118,11 +111,8 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = PdfRestClient(api_key=ANOTHER_VALID_API_KEY, transport=transport) - try: + with PdfRestClient(api_key=ANOTHER_VALID_API_KEY, transport=transport) as client: response = client.up(extra_headers={"X-Test-Header": "value"}) - finally: - client.close() assert response.version == "2.31.1" @@ -138,14 +128,11 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = PdfRestClient(transport=transport) - try: + with PdfRestClient(transport=transport) as client: response = client.up( extra_query={"view": "full", "unused": None}, timeout=0.5, ) - finally: - client.close() assert response.product == "pdfRest API Toolkit" timeout_value = captured_timeout["value"] @@ -165,24 +152,22 @@ def handler(_: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = PdfRestClient(transport=transport) - with pytest.raises(PdfRestConfigurationError): + with ( + pytest.raises(PdfRestConfigurationError), + PdfRestClient(transport=transport) as client, + ): client.up(extra_body={"unexpected": "value"}) - client.close() def test_prepare_request_merges_queries(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PDFREST_API_KEY", "key") - client = PdfRestClient(api_key=VALID_API_KEY) - try: + with PdfRestClient(api_key=VALID_API_KEY) as client: request = client.prepare_request( "GET", "/test", query={"base": "value", "skip": None}, extra_query={"base": "override", "extra": 42, "ignore": None}, ) - finally: - client.close() assert request.params == { "base": "override", @@ -201,12 +186,11 @@ def handler(_: httpx.Request) -> httpx.Response: return httpx.Response(401, json={"message": "The provided key is not valid."}) transport = httpx.MockTransport(handler) - client = PdfRestClient(transport=transport) - - with pytest.raises(PdfRestAuthenticationError) as exc_info: + with ( + pytest.raises(PdfRestAuthenticationError) as exc_info, + PdfRestClient(transport=transport) as client, + ): client.up() - - client.close() assert "The provided key is not valid." in str(exc_info.value) @@ -219,12 +203,11 @@ def handler(_: httpx.Request) -> httpx.Response: return httpx.Response(401, text="Unauthorized") transport = httpx.MockTransport(handler) - client = PdfRestClient(transport=transport) - - with pytest.raises(PdfRestAuthenticationError) as exc_info: + with ( + pytest.raises(PdfRestAuthenticationError) as exc_info, + PdfRestClient(transport=transport) as client, + ): client.up() - - client.close() assert "Authentication with pdfRest failed." in str(exc_info.value) assert exc_info.value.response_content == "Unauthorized" @@ -238,12 +221,11 @@ def handler(_: httpx.Request) -> httpx.Response: return httpx.Response(500, json={"message": "server error"}) transport = httpx.MockTransport(handler) - client = PdfRestClient(transport=transport) - - with pytest.raises(PdfRestApiError) as exc_info: + with ( + pytest.raises(PdfRestApiError) as exc_info, + PdfRestClient(transport=transport) as client, + ): client.up() - - client.close() assert exc_info.value.status_code == 500 @@ -256,8 +238,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = AsyncPdfRestClient(transport=transport) - async with client: + async with AsyncPdfRestClient(transport=transport) as client: response = await client.up() assert response.status == "OK" @@ -277,8 +258,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = AsyncPdfRestClient(transport=transport) - async with client: + async with AsyncPdfRestClient(transport=transport) as client: response = await client.up( extra_query={"mode": "ping"}, timeout=0.25, @@ -304,10 +284,8 @@ def handler(_: httpx.Request) -> httpx.Response: raise httpx.TimeoutException(message) transport = httpx.MockTransport(handler) - client = AsyncPdfRestClient(transport=transport) - with pytest.raises(PdfRestTimeoutError): - async with client: + async with AsyncPdfRestClient(transport=transport) as client: await client.up() @@ -321,19 +299,16 @@ def handler(_: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_build_up_response()) transport = httpx.MockTransport(handler) - client = AsyncPdfRestClient(transport=transport) - with pytest.raises(PdfRestConfigurationError): - async with client: + async with AsyncPdfRestClient(transport=transport) as client: await client.up(extra_body={"unexpected": "value"}) def test_live_client_up(pdfrest_api_key: str, pdfrest_live_base_url: str) -> None: - client = PdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) - try: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: response = client.up() - finally: - client.close() assert response.status.upper() == "OK" assert response.product @@ -342,7 +317,8 @@ def test_live_client_up(pdfrest_api_key: str, pdfrest_live_base_url: str) -> Non async def test_live_async_client_up( pdfrest_api_key: str, pdfrest_live_base_url: str ) -> None: - client = AsyncPdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) - async with client: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: response = await client.up() assert response.version diff --git a/tests/test_files.py b/tests/test_files.py index 4a07db1e..fa371295 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -72,13 +72,12 @@ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError(msg) transport = httpx.MockTransport(handler) - client = PdfRestClient(api_key=VALID_API_KEY, transport=transport) report_pdf = get_test_resource_path("report.pdf") - try: - with report_pdf.open("rb") as pdf_file: - response = client.files.create([("report.pdf", pdf_file)]) - finally: - client.close() + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + report_pdf.open("rb") as pdf_file, + ): + response = client.files.create([("report.pdf", pdf_file)]) assert isinstance(response, list) assert len(response) == 1 @@ -118,13 +117,10 @@ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError(msg) transport = httpx.MockTransport(handler) - client = PdfRestClient(api_key=VALID_API_KEY, transport=transport) report_pdf = get_test_resource_path("report.pdf") report_docx = get_test_resource_path("report.docx") - try: + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.files.create_from_paths([report_pdf, report_docx]) - finally: - client.close() assert isinstance(response, list) assert len(response) == 2 @@ -157,12 +153,9 @@ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError(msg) transport = httpx.MockTransport(handler) - client = PdfRestClient(api_key=VALID_API_KEY, transport=transport) report_pdf = get_test_resource_path("report.pdf") - try: + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.files.create_from_paths(report_pdf) - finally: - client.close() assert len(response) == 1 _assert_file_matches_payload(response[0], info_payload) @@ -193,9 +186,8 @@ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError(msg) transport = httpx.MockTransport(handler) - client = PdfRestClient(api_key=VALID_API_KEY, transport=transport) report_pdf = get_test_resource_path("report.pdf") - try: + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.files.create_from_paths( [ ( @@ -205,19 +197,16 @@ def handler(request: httpx.Request) -> httpx.Response: ) ] ) - finally: - client.close() assert len(response) == 1 _assert_file_matches_payload(response[0], info_payload) def test_files_create_rejects_empty_input() -> None: - client = PdfRestClient( + with PdfRestClient( api_key=VALID_API_KEY, transport=httpx.MockTransport(lambda _: httpx.Response(200)), - ) - try: + ) as client: with pytest.raises( TypeError, match=r"Upload files must be provided as a sequence or a single file specification\.", @@ -229,8 +218,6 @@ def test_files_create_rejects_empty_input() -> None: ValueError, match=r"At least one file path must be provided\." ): client.files.create_from_paths([]) - finally: - client.close() @pytest.mark.asyncio @@ -261,11 +248,10 @@ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError(msg) transport = httpx.MockTransport(handler) - client = AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) report_pdf = get_test_resource_path("report.pdf") report_docx = get_test_resource_path("report.docx") - async with client: + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: with report_pdf.open("rb") as pdf_file, report_docx.open("rb") as docx_file: response = await client.files.create( [ @@ -313,10 +299,9 @@ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError(msg) transport = httpx.MockTransport(handler) - client = AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) report_pdf = get_test_resource_path("report.pdf") report_docx = get_test_resource_path("report.docx") - async with client: + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = await client.files.create_from_paths([report_pdf, report_docx]) assert isinstance(response, list) @@ -351,9 +336,8 @@ def handler(request: httpx.Request) -> httpx.Response: raise AssertionError(msg) transport = httpx.MockTransport(handler) - client = AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) report_pdf = get_test_resource_path("report.pdf") - async with client: + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = await client.files.create_from_paths(report_pdf) assert len(response) == 1 @@ -410,9 +394,10 @@ def test_live_file_create_from_paths( async def test_live_async_file_create( pdfrest_api_key: str, pdfrest_live_base_url: str ) -> None: - client = AsyncPdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) report_pdf = get_test_resource_path("report.pdf") - async with client: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: with report_pdf.open("rb") as pdf_file: response = await client.files.create([pdf_file]) assert isinstance(response, list) @@ -427,10 +412,11 @@ async def test_live_async_file_create( async def test_live_async_file_create_from_paths( pdfrest_api_key: str, pdfrest_live_base_url: str ) -> None: - client = AsyncPdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) report_pdf = get_test_resource_path("report.pdf") report_docx = get_test_resource_path("report.docx") - async with client: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: response = await client.files.create_from_paths([report_pdf, report_docx]) assert isinstance(response, list) assert len(response) == 2 From 08294595197046c5b77788038ff81dd48ed36105 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 28 Oct 2025 15:42:34 -0500 Subject: [PATCH 26/51] Add `create_from_urls` functionality with tests - Introduced `create_from_urls` methods for synchronous and asynchronous clients to support file uploads via URLs. - Added `_normalize_url_inputs` utility to validate and process URLs with `http` or `https` schemes. - Implemented comprehensive test coverage for URL-based file uploads, including edge cases for invalid inputs and live integration tests. --- src/pdfrest/client.py | 54 +++++++++++ tests/test_files.py | 215 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index d43d3d09..0df55f49 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -51,6 +51,8 @@ FilePathTuple3 = tuple[FilePath, str | None, Mapping[str, str]] FilePathTypes = FilePath | FilePathTuple2 | FilePathTuple3 FilePathInput = FilePathTypes | Sequence[FilePathTypes] +UrlValue = str | URL +UrlInput = UrlValue | Sequence[UrlValue] NormalizedFileTypes: TypeAlias = FileContent | FileTuple2 | FileTuple3 | FileTuple4 @@ -190,6 +192,26 @@ def _normalize_path_inputs( return items +def _normalize_url_inputs(urls: UrlInput) -> list[str]: + if isinstance(urls, Sequence) and not isinstance(urls, (str, bytes, bytearray)): + sequence_urls: Sequence[UrlValue] = urls + items = list(sequence_urls) + else: + single_url: UrlValue = urls + items = [single_url] + if not items: + msg = "At least one URL must be provided." + raise ValueError(msg) + normalized: list[str] = [] + for item in items: + parsed = URL(str(item)) + if parsed.scheme not in {"http", "https"}: + msg = "URL uploads require http or https scheme." + raise ValueError(msg) + normalized.append(str(parsed)) + return normalized + + ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient) @@ -674,6 +696,19 @@ def create_from_paths(self, file_paths: FilePathInput) -> list[PdfRestFile]: upload_specs.append((filename, file_obj)) return self.create(upload_specs) + def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]: + """Upload one or more files by providing remote URLs.""" + + normalized_urls = _normalize_url_inputs(urls) + request = self._client.prepare_request( + "POST", + "/upload", + json_body={"url": normalized_urls}, + ) + payload = self._client.send_request(request) + file_ids = _extract_uploaded_file_ids(payload) + return [self._client.fetch_file_info(file_id) for file_id in file_ids] + class _AsyncFilesClient: """Expose file-related operations for the asynchronous client.""" @@ -732,6 +767,25 @@ async def create_from_paths(self, file_paths: FilePathInput) -> list[PdfRestFile upload_specs.append((filename, file_obj)) return await self.create(upload_specs) + async def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]: + """Upload one or more files by providing remote URLs.""" + + normalized_urls = _normalize_url_inputs(urls) + request = self._client.prepare_request( + "POST", + "/upload", + json_body={"url": normalized_urls}, + ) + payload = await self._client.send_request(request) + file_ids = _extract_uploaded_file_ids(payload) + semaphore = asyncio.Semaphore(self._concurrency_limit) + + async def fetch(file_id: str) -> PdfRestFile: + async with semaphore: + return await self._client.fetch_file_info(file_id) + + return await asyncio.gather(*(fetch(file_id) for file_id in file_ids)) + class PdfRestClient(_SyncApiClient): """Synchronous client for interacting with the pdfrest API.""" diff --git a/tests/test_files.py b/tests/test_files.py index fa371295..a064c262 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import uuid from datetime import datetime, timezone from typing import Any, cast @@ -161,6 +162,81 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(response[0], info_payload) +def test_files_create_from_urls_uses_upload_and_info() -> None: + uploaded_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + info_payloads = { + uploaded_ids[0]: _build_file_info_payload(uploaded_ids[0], "report.pdf"), + uploaded_ids[1]: _build_file_info_payload(uploaded_ids[1], "report.docx"), + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + payload = json.loads(request.content.decode("utf-8")) + assert payload["url"] == [ + "https://example.com/report.pdf", + "https://example.com/report.docx", + ] + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_ids[0]}, + {"name": "report.docx", "id": uploaded_ids[1]}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + file_id = request.url.path.split("/")[-1] + assert request.url.params["format"] == "info" + return httpx.Response(200, json=info_payloads[file_id]) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.files.create_from_urls( + [ + "https://example.com/report.pdf", + httpx.URL("https://example.com/report.docx"), + ] + ) + + assert len(response) == 2 + for file_repr in response: + payload = info_payloads[file_repr.id] + _assert_file_matches_payload(file_repr, payload) + + +def test_files_create_from_urls_single_url() -> None: + uploaded_file_id = str(uuid.uuid4()) + info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + payload = json.loads(request.content.decode("utf-8")) + assert payload["url"] == ["https://example.com/report.pdf"] + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_file_id}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + assert request.url.params["format"] == "info" + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.files.create_from_urls("https://example.com/report.pdf") + + assert len(response) == 1 + _assert_file_matches_payload(response[0], info_payload) + + def test_files_create_from_paths_supports_metadata() -> None: uploaded_file_id = str(uuid.uuid4()) info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") @@ -202,6 +278,25 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(response[0], info_payload) +def test_files_create_from_urls_invalid_scheme() -> None: + transport = httpx.MockTransport(lambda request: httpx.Response(400)) + with ( + pytest.raises(ValueError, match=r"URL uploads require http or https scheme\."), + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + ): + client.files.create_from_urls("ftp://example.com/file.pdf") + + +@pytest.mark.asyncio +async def test_async_files_create_from_urls_invalid_scheme() -> None: + transport = httpx.MockTransport(lambda request: httpx.Response(400)) + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + with pytest.raises( + ValueError, match=r"URL uploads require http or https scheme\." + ): + await client.files.create_from_urls("ftp://example.com/file.pdf") + + def test_files_create_rejects_empty_input() -> None: with PdfRestClient( api_key=VALID_API_KEY, @@ -218,6 +313,8 @@ def test_files_create_rejects_empty_input() -> None: ValueError, match=r"At least one file path must be provided\." ): client.files.create_from_paths([]) + with pytest.raises(ValueError, match=r"At least one URL must be provided\."): + client.files.create_from_urls([]) @pytest.mark.asyncio @@ -267,6 +364,83 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(file_repr, payload) +@pytest.mark.asyncio +async def test_async_files_create_from_urls() -> None: + uploaded_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + info_payloads = { + uploaded_ids[0]: _build_file_info_payload(uploaded_ids[0], "report.pdf"), + uploaded_ids[1]: _build_file_info_payload(uploaded_ids[1], "report.docx"), + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + payload = json.loads(request.content.decode("utf-8")) + assert payload["url"] == [ + "https://example.com/report.pdf", + "https://example.com/report.docx", + ] + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_ids[0]}, + {"name": "report.docx", "id": uploaded_ids[1]}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + file_id = request.url.path.split("/")[-1] + assert request.url.params["format"] == "info" + return httpx.Response(200, json=info_payloads[file_id]) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = await client.files.create_from_urls( + [ + "https://example.com/report.pdf", + httpx.URL("https://example.com/report.docx"), + ] + ) + + assert len(response) == 2 + for file_repr in response: + payload = info_payloads[file_repr.id] + _assert_file_matches_payload(file_repr, payload) + + +@pytest.mark.asyncio +async def test_async_files_create_from_urls_single_url() -> None: + uploaded_file_id = str(uuid.uuid4()) + info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + payload = json.loads(request.content.decode("utf-8")) + assert payload["url"] == ["https://example.com/report.pdf"] + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_file_id}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + assert request.url.params["format"] == "info" + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = await client.files.create_from_urls("https://example.com/report.pdf") + + assert len(response) == 1 + _assert_file_matches_payload(response[0], info_payload) + + @pytest.mark.asyncio async def test_async_files_create_from_paths() -> None: uploaded_ids = [str(uuid.uuid4()), str(uuid.uuid4())] @@ -387,6 +561,26 @@ def test_live_file_create_from_paths( assert isinstance(response, list) assert len(response) == 2 names = {file_repr.name for file_repr in response} + assert { + "report.pdf", + "report.docx", + } <= names + + +def test_live_file_create_from_urls( + pdfrest_api_key: str, pdfrest_live_base_url: str +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + report_pdf = get_test_resource_path("report.pdf") + report_docx = get_test_resource_path("report.docx") + base_files = client.files.create_from_paths([report_pdf, report_docx]) + source_urls = [str(file_repr.url) for file_repr in base_files] + response = client.files.create_from_urls(source_urls) + assert isinstance(response, list) + assert len(response) == 2 + names = {file_repr.name for file_repr in response} assert {"report.pdf", "report.docx"} <= names @@ -421,4 +615,25 @@ async def test_live_async_file_create_from_paths( assert isinstance(response, list) assert len(response) == 2 names = {file_repr.name for file_repr in response} + assert { + "report.pdf", + "report.docx", + } <= names + + +@pytest.mark.asyncio +async def test_live_async_file_create_from_urls( + pdfrest_api_key: str, pdfrest_live_base_url: str +) -> None: + report_pdf = get_test_resource_path("report.pdf") + report_docx = get_test_resource_path("report.docx") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + base_files = await client.files.create_from_paths([report_pdf, report_docx]) + source_urls = [str(file_repr.url) for file_repr in base_files] + response = await client.files.create_from_urls(source_urls) + assert isinstance(response, list) + assert len(response) == 2 + names = {file_repr.name for file_repr in response} assert {"report.pdf", "report.docx"} <= names From 0f19496e5479e392e3b26613f3909dcc859f08af Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 29 Oct 2025 15:21:19 -0500 Subject: [PATCH 27/51] Add download and streaming support with comprehensive tests - Implemented helper methods for reading bytes, text, and JSON from downloads. - Added streaming utilities with both synchronous and asynchronous implementations. - Updated tests with static and live integration cases to validate file download and streaming functionalities. Assisted-by: Codex --- src/pdfrest/client.py | 194 ++++++++++++++++- tests/test_files.py | 495 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 681 insertions(+), 8 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 0df55f49..fb82e286 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio +import json import os import uuid -from collections.abc import Mapping, Sequence +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence from contextlib import ExitStack from os import PathLike from pathlib import Path @@ -54,6 +55,7 @@ UrlValue = str | URL UrlInput = UrlValue | Sequence[UrlValue] NormalizedFileTypes: TypeAlias = FileContent | FileTuple2 | FileTuple3 | FileTuple4 +DestinationPath = str | PathLike[str] def _extract_uploaded_file_ids(payload: Any) -> list[str]: @@ -212,6 +214,10 @@ def _normalize_url_inputs(urls: UrlInput) -> list[str]: return normalized +def _resolve_file_id(file_ref: PdfRestFile | str) -> str: + return file_ref.id if isinstance(file_ref, PdfRestFile) else str(file_ref) + + ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient) @@ -572,6 +578,19 @@ def _send_request(self, request: _RequestModel) -> Any: def send_request(self, request: _RequestModel) -> Any: return self._send_request(request) + def download_file(self, file_id: str) -> httpx.Response: + request = self._client.build_request("GET", f"/resource/{file_id}") + try: + response = self._client.send(request, stream=True) + except httpx.HTTPError as exc: + raise translate_httpx_error(exc) from exc + if not response.is_success: + try: + self._handle_response(response) + finally: + response.close() + return response + def fetch_file_info(self, file_id: str) -> PdfRestFile: request = self.prepare_request( "GET", @@ -641,6 +660,19 @@ async def _send_request(self, request: _RequestModel) -> Any: async def send_request(self, request: _RequestModel) -> Any: return await self._send_request(request) + async def download_file(self, file_id: str) -> httpx.Response: + request = self._client.build_request("GET", f"/resource/{file_id}") + try: + response = await self._client.send(request, stream=True) + except httpx.HTTPError as exc: + raise translate_httpx_error(exc) from exc + if not response.is_success: + try: + self._handle_response(response) + finally: + await response.aclose() + return response + async def fetch_file_info(self, file_id: str) -> PdfRestFile: request = self.prepare_request( "GET", @@ -651,6 +683,66 @@ async def fetch_file_info(self, file_id: str) -> PdfRestFile: return PdfRestFile.model_validate(payload) +class PdfRestFileStream: + """Streaming wrapper for synchronously downloading files from pdfRest.""" + + def __init__(self, response: httpx.Response) -> None: + self._response = response + + def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]: + yield from self._response.iter_bytes(chunk_size) + + def iter_text(self, chunk_size: int | None = None) -> Iterator[str]: + yield from self._response.iter_text(chunk_size) + + def iter_lines(self) -> Iterator[str]: + yield from self._response.iter_lines() + + def iter_raw(self, chunk_size: int | None = None) -> Iterator[bytes]: + yield from self._response.iter_raw(chunk_size) + + def close(self) -> None: + self._response.close() + + def __enter__(self) -> PdfRestFileStream: + return self + + def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + self.close() + + +class AsyncPdfRestFileStream: + """Streaming wrapper for asynchronously downloading files from pdfRest.""" + + def __init__(self, response: httpx.Response) -> None: + self._response = response + + async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: + async for chunk in self._response.aiter_bytes(chunk_size): + yield chunk + + async def iter_text(self, chunk_size: int | None = None) -> AsyncIterator[str]: + async for chunk in self._response.aiter_text(chunk_size): + yield chunk + + async def iter_lines(self) -> AsyncIterator[str]: + async for line in self._response.aiter_lines(): + yield line + + async def iter_raw(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: + async for chunk in self._response.aiter_raw(chunk_size): + yield chunk + + async def close(self) -> None: + await self._response.aclose() + + async def __aenter__(self) -> AsyncPdfRestFileStream: + return self + + async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + await self.close() + + class _FilesClient: """Expose file-related operations for the synchronous client.""" @@ -709,6 +801,56 @@ def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]: file_ids = _extract_uploaded_file_ids(payload) return [self._client.fetch_file_info(file_id) for file_id in file_ids] + def read_bytes(self, file_ref: PdfRestFile | str) -> bytes: + response = self._client.download_file(_resolve_file_id(file_ref)) + try: + return response.read() + finally: + response.close() + + def read_text( + self, + file_ref: PdfRestFile | str, + *, + encoding: str = "utf-8", + ) -> str: + response = self._client.download_file(_resolve_file_id(file_ref)) + try: + response.encoding = encoding + data = response.read() + codec = response.encoding or encoding or "utf-8" + return data.decode(codec) + finally: + response.close() + + def read_json(self, file_ref: PdfRestFile | str) -> Any: + response = self._client.download_file(_resolve_file_id(file_ref)) + try: + data = response.read() + codec = response.encoding or "utf-8" + return json.loads(data.decode(codec)) + finally: + response.close() + + def write_bytes( + self, + file_ref: PdfRestFile | str, + destination: DestinationPath, + ) -> Path: + response = self._client.download_file(_resolve_file_id(file_ref)) + path = Path(destination) + try: + with path.open("wb") as file_handle: + for chunk in response.iter_bytes(): + file_handle.write(chunk) + finally: + response.close() + return path + + def stream(self, file_ref: PdfRestFile | str) -> PdfRestFileStream: + response = self._client.download_file(_resolve_file_id(file_ref)) + return PdfRestFileStream(response) + class _AsyncFilesClient: """Expose file-related operations for the asynchronous client.""" @@ -786,6 +928,56 @@ async def fetch(file_id: str) -> PdfRestFile: return await asyncio.gather(*(fetch(file_id) for file_id in file_ids)) + async def read_bytes(self, file_ref: PdfRestFile | str) -> bytes: + response = await self._client.download_file(_resolve_file_id(file_ref)) + try: + return await response.aread() + finally: + await response.aclose() + + async def read_text( + self, + file_ref: PdfRestFile | str, + *, + encoding: str = "utf-8", + ) -> str: + response = await self._client.download_file(_resolve_file_id(file_ref)) + try: + response.encoding = encoding + data = await response.aread() + codec = response.encoding or encoding or "utf-8" + return data.decode(codec) + finally: + await response.aclose() + + async def read_json(self, file_ref: PdfRestFile | str) -> Any: + response = await self._client.download_file(_resolve_file_id(file_ref)) + try: + data = await response.aread() + codec = response.encoding or "utf-8" + return json.loads(data.decode(codec)) + finally: + await response.aclose() + + async def write_bytes( + self, + file_ref: PdfRestFile | str, + destination: DestinationPath, + ) -> Path: + response = await self._client.download_file(_resolve_file_id(file_ref)) + path = Path(destination) + try: + with path.open("wb") as file_handle: + async for chunk in response.aiter_bytes(): + file_handle.write(chunk) + finally: + await response.aclose() + return path + + async def stream(self, file_ref: PdfRestFile | str) -> AsyncPdfRestFileStream: + response = await self._client.download_file(_resolve_file_id(file_ref)) + return AsyncPdfRestFileStream(response) + class PdfRestClient(_SyncApiClient): """Synchronous client for interacting with the pdfrest API.""" diff --git a/tests/test_files.py b/tests/test_files.py index a064c262..3f9a784e 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -2,11 +2,16 @@ import json import uuid +from collections.abc import AsyncIterator, Iterator +from contextlib import AsyncExitStack, ExitStack +from dataclasses import dataclass from datetime import datetime, timezone +from pathlib import Path from typing import Any, cast import httpx import pytest +import pytest_asyncio from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models import PdfRestFile @@ -16,6 +21,35 @@ VALID_API_KEY = "12345678-1234-1234-1234-123456789abc" +class _StaticStream(httpx.SyncByteStream): + def __init__(self, payload: bytes) -> None: + self._payload = payload + self._consumed = False + + def __iter__(self) -> Iterator[bytes]: + if self._consumed: + return iter(()) + self._consumed = True + return iter((self._payload,)) + + def close(self) -> None: # pragma: no cover - trivial + ... + + +class _StaticAsyncStream(httpx.AsyncByteStream): + def __init__(self, payload: bytes) -> None: + self._payload = payload + self._consumed = False + + async def __aiter__(self): + if not self._consumed: + self._consumed = True + yield self._payload + + async def aclose(self) -> None: # pragma: no cover - trivial + ... + + def _build_file_info_payload(file_id: str, name: str) -> dict[str, Any]: return { "id": file_id, @@ -49,6 +83,68 @@ def _assert_file_matches_payload( assert file_repr.scheduled_deletion_time_utc is None +def _create_temp_text_file(tmp_path: Path, prefix: str) -> tuple[Path, str, bytes]: + filename = f"{prefix}.txt" + source_path = tmp_path / filename + source_content = f"{prefix}-line1\n{prefix}-line2\n" + source_path.write_text(source_content, encoding="utf-8") + return source_path, source_content, source_path.read_bytes() + + +@dataclass +class LiveFileData: + prefix: str + file: PdfRestFile + original_bytes: bytes + source_text: str + + +@pytest.fixture(scope="class") +def live_sync_file( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + tmp_path_factory: pytest.TempPathFactory, +) -> LiveFileData: + prefix = f"sync-live-{uuid.uuid4().hex}" + temp_dir = tmp_path_factory.mktemp(prefix) + source_path, source_text, source_bytes = _create_temp_text_file(temp_dir, prefix) + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + with source_path.open("rb") as source_file: + uploaded_files = client.files.create([source_file]) + file_repr = uploaded_files[0] + return LiveFileData( + prefix=prefix, + file=file_repr, + original_bytes=source_bytes, + source_text=source_text, + ) + + +@pytest.fixture(scope="class") +def live_async_file( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + tmp_path_factory: pytest.TempPathFactory, +) -> LiveFileData: + prefix = f"async-live-{uuid.uuid4().hex}" + temp_dir = tmp_path_factory.mktemp(prefix) + source_path, source_text, source_bytes = _create_temp_text_file(temp_dir, prefix) + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + with source_path.open("rb") as source_file: + uploaded_files = client.files.create([source_file]) + file_repr = uploaded_files[0] + return LiveFileData( + prefix=prefix, + file=file_repr, + original_bytes=source_bytes, + source_text=source_text, + ) + + def test_files_create_uses_upload_and_info() -> None: uploaded_file_id = str(uuid.uuid4()) info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") @@ -278,13 +374,173 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(response[0], info_payload) -def test_files_create_from_urls_invalid_scheme() -> None: - transport = httpx.MockTransport(lambda request: httpx.Response(400)) - with ( - pytest.raises(ValueError, match=r"URL uploads require http or https scheme\."), - PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - ): - client.files.create_from_urls("ftp://example.com/file.pdf") +class TestDownloadHelpers: + @pytest.fixture + def client(self) -> Iterator[tuple[PdfRestClient, bytes, dict[str, Any]]]: + binary_content = b"line1\nline2\n" + json_payload: dict[str, Any] = {"message": "hi"} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path == "/resource/file-id": + return httpx.Response(200, stream=_StaticStream(binary_content)) + if request.method == "GET" and request.url.path == "/resource/file-id-json": + payload = json.dumps(json_payload).encode("utf-8") + return httpx.Response(200, stream=_StaticStream(payload)) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient( + api_key=VALID_API_KEY, transport=transport + ) as pdfrest_client: + yield pdfrest_client, binary_content, json_payload + + def test_read_bytes( + self, client: tuple[PdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + assert pdfrest_client.files.read_bytes("file-id") == binary_content + + def test_read_text( + self, client: tuple[PdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + assert pdfrest_client.files.read_text("file-id") == binary_content.decode() + + def test_read_json( + self, client: tuple[PdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, _, json_payload = client + assert pdfrest_client.files.read_json("file-id-json") == json_payload + + def test_write_bytes( + self, + client: tuple[PdfRestClient, bytes, dict[str, Any]], + tmp_path: Path, + ) -> None: + pdfrest_client, binary_content, _ = client + destination = tmp_path / "download.bin" + written_path = pdfrest_client.files.write_bytes("file-id", destination) + assert written_path.read_bytes() == binary_content + + def test_stream_iter_raw( + self, client: tuple[PdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + with pdfrest_client.files.stream("file-id") as stream: + raw_chunks = list(stream.iter_raw()) + assert b"".join(raw_chunks) == binary_content + + def test_stream_iter_bytes( + self, client: tuple[PdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + with pdfrest_client.files.stream("file-id") as stream: + chunks = list(stream.iter_bytes()) + assert b"".join(chunks) == binary_content + + def test_stream_iter_text( + self, client: tuple[PdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + with pdfrest_client.files.stream("file-id") as stream: + text_chunks = list(stream.iter_text()) + assert "".join(text_chunks) == binary_content.decode() + + def test_stream_iter_lines( + self, client: tuple[PdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + with pdfrest_client.files.stream("file-id") as stream: + lines = list(stream.iter_lines()) + assert lines == binary_content.decode().splitlines() + + +@pytest.mark.asyncio +class TestAsyncDownloadHelpers: + @pytest_asyncio.fixture + async def client( + self, + ) -> AsyncIterator[tuple[AsyncPdfRestClient, bytes, dict[str, Any]]]: + binary_content = b"line1\nline2\n" + json_payload: dict[str, Any] = {"message": "hi"} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path == "/resource/file-id": + return httpx.Response(200, stream=_StaticAsyncStream(binary_content)) + if request.method == "GET" and request.url.path == "/resource/file-id-json": + payload = json.dumps(json_payload).encode("utf-8") + return httpx.Response(200, stream=_StaticAsyncStream(payload)) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient( + api_key=VALID_API_KEY, transport=transport + ) as pdfrest_client: + yield pdfrest_client, binary_content, json_payload + + async def test_read_bytes( + self, client: tuple[AsyncPdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + assert await pdfrest_client.files.read_bytes("file-id") == binary_content + + async def test_read_text( + self, client: tuple[AsyncPdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + assert ( + await pdfrest_client.files.read_text("file-id") == binary_content.decode() + ) + + async def test_read_json( + self, client: tuple[AsyncPdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, _, json_payload = client + assert await pdfrest_client.files.read_json("file-id-json") == json_payload + + async def test_write_bytes( + self, + client: tuple[AsyncPdfRestClient, bytes, dict[str, Any]], + tmp_path: Path, + ) -> None: + pdfrest_client, binary_content, _ = client + destination = tmp_path / "async-download.bin" + written_path = await pdfrest_client.files.write_bytes("file-id", destination) + assert written_path.read_bytes() == binary_content + + async def test_stream_iter_raw( + self, client: tuple[AsyncPdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + async with await pdfrest_client.files.stream("file-id") as stream: + raw_chunks = [chunk async for chunk in stream.iter_raw()] + assert b"".join(raw_chunks) == binary_content + + async def test_stream_iter_bytes( + self, client: tuple[AsyncPdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + async with await pdfrest_client.files.stream("file-id") as stream: + chunks = [chunk async for chunk in stream.iter_bytes()] + assert b"".join(chunks) == binary_content + + async def test_stream_iter_text( + self, client: tuple[AsyncPdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + async with await pdfrest_client.files.stream("file-id") as stream: + text_chunks = [chunk async for chunk in stream.iter_text()] + assert "".join(text_chunks) == binary_content.decode() + + async def test_stream_iter_lines( + self, client: tuple[AsyncPdfRestClient, bytes, dict[str, Any]] + ) -> None: + pdfrest_client, binary_content, _ = client + async with await pdfrest_client.files.stream("file-id") as stream: + lines = [line async for line in stream.iter_lines()] + assert lines == binary_content.decode().splitlines() @pytest.mark.asyncio @@ -584,6 +840,109 @@ def test_live_file_create_from_urls( assert {"report.pdf", "report.docx"} <= names +class TestLiveFileDownloads: + def test_read_bytes( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_sync_file: LiveFileData, + ) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + assert ( + client.files.read_bytes(live_sync_file.file.id) + == live_sync_file.original_bytes + ) + + def test_read_text( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_sync_file: LiveFileData, + ) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + assert ( + client.files.read_text(live_sync_file.file.id) + == live_sync_file.source_text + ) + + def test_write_bytes( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + tmp_path: Path, + live_sync_file: LiveFileData, + ) -> None: + destination = tmp_path / f"{live_sync_file.prefix}-download.bin" + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + written_path = client.files.write_bytes( + live_sync_file.file.id, str(destination) + ) + assert written_path == destination + assert written_path.read_bytes() == live_sync_file.original_bytes + + def test_stream_iter_raw( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_sync_file: LiveFileData, + ) -> None: + with ExitStack() as stack: + client = stack.enter_context( + PdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) + ) + stream = stack.enter_context(client.files.stream(live_sync_file.file.id)) + raw_chunks = list(stream.iter_raw()) + assert b"".join(raw_chunks) == live_sync_file.original_bytes + + def test_stream_iter_bytes( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_sync_file: LiveFileData, + ) -> None: + with ExitStack() as stack: + client = stack.enter_context( + PdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) + ) + stream = stack.enter_context(client.files.stream(live_sync_file.file.id)) + chunks = list(stream.iter_bytes(chunk_size=None)) + assert b"".join(chunks) == live_sync_file.original_bytes + + def test_stream_iter_text( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_sync_file: LiveFileData, + ) -> None: + with ExitStack() as stack: + client = stack.enter_context( + PdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) + ) + stream = stack.enter_context(client.files.stream(live_sync_file.file.id)) + text_chunks = list(stream.iter_text(chunk_size=None)) + assert "".join(text_chunks) == live_sync_file.source_text + + def test_stream_iter_lines( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_sync_file: LiveFileData, + ) -> None: + with ExitStack() as stack: + client = stack.enter_context( + PdfRestClient(api_key=pdfrest_api_key, base_url=pdfrest_live_base_url) + ) + stream = stack.enter_context(client.files.stream(live_sync_file.file.id)) + lines = list(stream.iter_lines()) + assert lines == live_sync_file.source_text.splitlines() + + @pytest.mark.asyncio async def test_live_async_file_create( pdfrest_api_key: str, pdfrest_live_base_url: str @@ -621,6 +980,128 @@ async def test_live_async_file_create_from_paths( } <= names +class TestLiveAsyncFileDownloads: + @pytest.mark.asyncio + async def test_read_bytes( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_async_file: LiveFileData, + ) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + assert ( + await client.files.read_bytes(live_async_file.file.id) + == live_async_file.original_bytes + ) + + @pytest.mark.asyncio + async def test_read_text( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_async_file: LiveFileData, + ) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + assert ( + await client.files.read_text(live_async_file.file.id) + == live_async_file.source_text + ) + + @pytest.mark.asyncio + async def test_write_bytes( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + tmp_path: Path, + live_async_file: LiveFileData, + ) -> None: + destination = tmp_path / f"{live_async_file.prefix}-download.bin" + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + written_path = await client.files.write_bytes( + live_async_file.file, destination + ) + assert written_path == destination + assert written_path.read_bytes() == live_async_file.original_bytes + + @pytest.mark.asyncio + async def test_stream_iter_raw( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_async_file: LiveFileData, + ) -> None: + async with AsyncExitStack() as stack: + client = await stack.enter_async_context( + AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) + ) + stream_cm = await client.files.stream(live_async_file.file.id) + stream = await stack.enter_async_context(stream_cm) + raw_chunks = [chunk async for chunk in stream.iter_raw()] + assert b"".join(raw_chunks) == live_async_file.original_bytes + + @pytest.mark.asyncio + async def test_stream_iter_bytes( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_async_file: LiveFileData, + ) -> None: + async with AsyncExitStack() as stack: + client = await stack.enter_async_context( + AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) + ) + stream_cm = await client.files.stream(live_async_file.file.id) + stream = await stack.enter_async_context(stream_cm) + chunks = [chunk async for chunk in stream.iter_bytes(chunk_size=None)] + assert b"".join(chunks) == live_async_file.original_bytes + + @pytest.mark.asyncio + async def test_stream_iter_text( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_async_file: LiveFileData, + ) -> None: + async with AsyncExitStack() as stack: + client = await stack.enter_async_context( + AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) + ) + stream_cm = await client.files.stream(live_async_file.file.id) + stream = await stack.enter_async_context(stream_cm) + text_chunks = [chunk async for chunk in stream.iter_text(chunk_size=None)] + assert "".join(text_chunks) == live_async_file.source_text + + @pytest.mark.asyncio + async def test_stream_iter_lines( + self, + pdfrest_api_key: str, + pdfrest_live_base_url: str, + live_async_file: LiveFileData, + ) -> None: + async with AsyncExitStack() as stack: + client = await stack.enter_async_context( + AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) + ) + stream_cm = await client.files.stream(live_async_file.file.id) + stream = await stack.enter_async_context(stream_cm) + lines = [line async for line in stream.iter_lines()] + assert lines == live_async_file.source_text.splitlines() + + @pytest.mark.asyncio async def test_live_async_file_create_from_urls( pdfrest_api_key: str, pdfrest_live_base_url: str From 0d73bfe20065e1da47ebe340fcfc61919d9f64d9 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 29 Oct 2025 15:33:55 -0500 Subject: [PATCH 28/51] Add SDK version and headers to client requests - Injected `wsn` and `User-Agent` headers into all client requests. - Dynamically set SDK version using `importlib.metadata.version`. - Added test cases to validate the headers in both synchronous and asynchronous clients. Assisted-by: Codex --- src/pdfrest/client.py | 8 +++++++- tests/test_client.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index fb82e286..e1ccc10b 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import importlib.metadata import json import os import uuid @@ -337,7 +338,12 @@ def __init__( if resolved_api_key is not None: self._validate_pdfrest_api_key(resolved_api_key, resolved_base_url) - default_headers: dict[str, str] = {"Accept": "application/json"} + version = importlib.metadata.version("pdfrest") + default_headers: dict[str, str] = { + "Accept": "application/json", + "wsn": "pdfrest-python", + "User-Agent": f"pdfrest-python-sdk/{version}", + } if resolved_api_key is not None: default_headers[API_KEY_HEADER_NAME] = resolved_api_key if headers: diff --git a/tests/test_client.py b/tests/test_client.py index 56fc0e51..e9fd7f54 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -14,6 +14,7 @@ PdfRestConfigurationError, PdfRestTimeoutError, UpResponse, + client as client_module, ) VALID_API_KEY = "12345678-1234-1234-1234-123456789abc" @@ -61,6 +62,39 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.product == "pdfRest API Toolkit" +def test_client_sets_sdk_headers(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY) + monkeypatch.setattr(client_module.importlib.metadata, "version", lambda _: "1.2.3") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["wsn"] == "pdfrest-python" + assert request.headers["User-Agent"] == "pdfrest-python-sdk/1.2.3" + assert request.headers["Accept"] == "application/json" + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + client.up() + + +@pytest.mark.asyncio +async def test_async_client_sets_sdk_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PDFREST_API_KEY", ASYNC_API_KEY) + monkeypatch.setattr(client_module.importlib.metadata, "version", lambda _: "4.5.6") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["wsn"] == "pdfrest-python" + assert request.headers["User-Agent"] == "pdfrest-python-sdk/4.5.6" + assert request.headers["Accept"] == "application/json" + return httpx.Response(200, json=_build_up_response()) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + await client.up() + + def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) From 11cce9ecf539952d66e85863c20dcf02ce811902 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 30 Oct 2025 14:23:14 -0500 Subject: [PATCH 29/51] pdfrest.models: Convert module to a package - Preparation for making a module for internal models. --- src/pdfrest/models/__init__.py | 3 +++ src/pdfrest/{models.py => models/public.py} | 0 2 files changed, 3 insertions(+) create mode 100644 src/pdfrest/models/__init__.py rename src/pdfrest/{models.py => models/public.py} (100%) diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py new file mode 100644 index 00000000..bcabb3d7 --- /dev/null +++ b/src/pdfrest/models/__init__.py @@ -0,0 +1,3 @@ +from .public import PdfRestErrorResponse, PdfRestFile, UpResponse + +__all__ = ("PdfRestErrorResponse", "PdfRestFile", "UpResponse") diff --git a/src/pdfrest/models.py b/src/pdfrest/models/public.py similarity index 100% rename from src/pdfrest/models.py rename to src/pdfrest/models/public.py From 1290a46af89aa563e48f2087916430a7327e4a1a Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 30 Oct 2025 14:28:43 -0500 Subject: [PATCH 30/51] Refactor URL upload handling in clients - Replaced `_normalize_url_inputs` utility with `UploadURLs` Pydantic model for validation and normalization. - Simplified input processing in `create_from_urls`. - Updated tests to reflect new URL validation logic and error messages. Assisted-by: Codex --- src/pdfrest/client.py | 30 ++++++------------------------ src/pdfrest/models/_internal.py | 31 +++++++++++++++++++++++++++++++ tests/test_files.py | 9 +++++---- 3 files changed, 42 insertions(+), 28 deletions(-) create mode 100644 src/pdfrest/models/_internal.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index e1ccc10b..7287fa18 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -27,6 +27,8 @@ __all__ = ("AsyncPdfRestClient", "PdfRestClient") +from .models._internal import UploadURLs + DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" API_KEY_HEADER_NAME = "Api-Key" @@ -195,26 +197,6 @@ def _normalize_path_inputs( return items -def _normalize_url_inputs(urls: UrlInput) -> list[str]: - if isinstance(urls, Sequence) and not isinstance(urls, (str, bytes, bytearray)): - sequence_urls: Sequence[UrlValue] = urls - items = list(sequence_urls) - else: - single_url: UrlValue = urls - items = [single_url] - if not items: - msg = "At least one URL must be provided." - raise ValueError(msg) - normalized: list[str] = [] - for item in items: - parsed = URL(str(item)) - if parsed.scheme not in {"http", "https"}: - msg = "URL uploads require http or https scheme." - raise ValueError(msg) - normalized.append(str(parsed)) - return normalized - - def _resolve_file_id(file_ref: PdfRestFile | str) -> str: return file_ref.id if isinstance(file_ref, PdfRestFile) else str(file_ref) @@ -797,11 +779,11 @@ def create_from_paths(self, file_paths: FilePathInput) -> list[PdfRestFile]: def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]: """Upload one or more files by providing remote URLs.""" - normalized_urls = _normalize_url_inputs(urls) + normalized_urls = UploadURLs.model_validate({"url": urls}) # pyright: ignore[reportPrivateUsage] request = self._client.prepare_request( "POST", "/upload", - json_body={"url": normalized_urls}, + json_body=normalized_urls.model_dump(mode="json"), ) payload = self._client.send_request(request) file_ids = _extract_uploaded_file_ids(payload) @@ -918,11 +900,11 @@ async def create_from_paths(self, file_paths: FilePathInput) -> list[PdfRestFile async def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]: """Upload one or more files by providing remote URLs.""" - normalized_urls = _normalize_url_inputs(urls) + normalized_urls = UploadURLs.model_validate({"url": urls}) request = self._client.prepare_request( "POST", "/upload", - json_body={"url": normalized_urls}, + json_body=normalized_urls.model_dump(mode="json"), ) payload = await self._client.send_request(request) file_ids = _extract_uploaded_file_ids(payload) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py new file mode 100644 index 00000000..161834a7 --- /dev/null +++ b/src/pdfrest/models/_internal.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Annotated, Any + +from pydantic import ( + BaseModel, + BeforeValidator, + Field, + HttpUrl, +) + + +def _ensure_list(value: Any) -> Any: + if value is None: + return None + if not isinstance(value, list): + return [value] + return value + + +def _list_of_strings(value: list[Any]) -> list[str]: + return [str(e) for e in value] + + +class UploadURLs(BaseModel): + url: Annotated[ + list[HttpUrl] | HttpUrl, + Field(min_length=1), + BeforeValidator(_list_of_strings), + BeforeValidator(_ensure_list), + ] diff --git a/tests/test_files.py b/tests/test_files.py index 3f9a784e..736ea369 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -547,9 +547,7 @@ async def test_stream_iter_lines( async def test_async_files_create_from_urls_invalid_scheme() -> None: transport = httpx.MockTransport(lambda request: httpx.Response(400)) async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: - with pytest.raises( - ValueError, match=r"URL uploads require http or https scheme\." - ): + with pytest.raises(ValueError, match=r"URL scheme should be 'http' or 'https'"): await client.files.create_from_urls("ftp://example.com/file.pdf") @@ -569,7 +567,10 @@ def test_files_create_rejects_empty_input() -> None: ValueError, match=r"At least one file path must be provided\." ): client.files.create_from_paths([]) - with pytest.raises(ValueError, match=r"At least one URL must be provided\."): + with pytest.raises( + ValueError, + match=r"Value should have at least 1 item after validation, not 0", + ): client.files.create_from_urls([]) From 460a6ee8b03071ca38ca3fef214504f5e554f70e Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 31 Oct 2025 13:17:13 -0500 Subject: [PATCH 31/51] models: Extend with validation utilities and new response types - Added robust validation utilities (`_validate_output_prefix`, `_require_positive_page`, `_validate_page_range_entry`, etc.) to enhance input checks for models. - Introduced new models (`PdfRestFileBasedResponse`, `PdfRestRawFileResponse`, `PdfRestRawUploadedFile`, `ConvertToGraphic`) to support pdfRest API operations. - Enhanced `PdfRestFileID` with detailed validation, prefix handling, and Pydantic v2 integration, including JSON schema support. - Updated module imports, `_internal.py`, and `public.py` to include new definitions and align with the latest changes. Assisted-by: Codex --- src/pdfrest/models/__init__.py | 16 +- src/pdfrest/models/_internal.py | 251 +++++++++++++++++++++++++++++++- src/pdfrest/models/public.py | 231 ++++++++++++++++++++++++++++- 3 files changed, 492 insertions(+), 6 deletions(-) diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index bcabb3d7..34e3fea0 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -1,3 +1,15 @@ -from .public import PdfRestErrorResponse, PdfRestFile, UpResponse +from .public import ( + PdfRestErrorResponse, + PdfRestFile, + PdfRestFileBasedResponse, + PdfRestFileID, + UpResponse, +) -__all__ = ("PdfRestErrorResponse", "PdfRestFile", "UpResponse") +__all__ = [ + "PdfRestErrorResponse", + "PdfRestFile", + "PdfRestFileBasedResponse", + "PdfRestFileID", + "UpResponse", +] diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 161834a7..26a838dc 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1,14 +1,25 @@ from __future__ import annotations -from typing import Annotated, Any +import re +from collections.abc import Callable +from pathlib import PurePath +from typing import Annotated, Any, Literal from pydantic import ( + AfterValidator, + AliasChoices, BaseModel, BeforeValidator, + ConfigDict, Field, HttpUrl, + PlainSerializer, + model_validator, ) +from . import PdfRestFile +from .public import PdfRestFileID + def _ensure_list(value: Any) -> Any: if value is None: @@ -22,6 +33,142 @@ def _list_of_strings(value: list[Any]) -> list[str]: return [str(e) for e in value] +def _validate_output_prefix(value: str | None) -> str | None: + """Validate output prefix to prevent directory traversal and reserved or unsafe names.""" + if value is None: + return None + if "/" in value or "\\" in value or ":" in value: + msg = "The output prefix must not contain a directory separator." + raise ValueError(msg) + if value.startswith("."): + msg = "The output prefix must not start with a `.`." + raise ValueError(msg) + if ".." in value: + msg = "The output prefix must not contain `..`." + raise ValueError(msg) + basename = PurePath(value).name + if value != basename: + msg = "The output prefix must not include directory components." + raise ValueError(msg) + if basename in {"profile.json", "metadata.json"}: + msg = "The output prefix is a reserved name." + raise ValueError(msg) + special_chars_pattern = r"[`!@#$%^&*()+=\[\]{};':\"\\|,<>?~]" + matches = re.findall(special_chars_pattern, value) + if matches: + violations: list[str] = [] + for char in matches: + if char not in violations: + violations.append(char) + msg = ( + "The output prefix must not contain special characters: " + + ", ".join(repr(char) for char in violations) + + "." + ) + raise ValueError(msg) + return value + + +def _require_positive_page( + text: str, *, description: str, require_page_word: bool = False +) -> str: + if not text.isdigit() or int(text) < 1: + message = ( + f"{description} must be a page number greater than or equal to 1." + if require_page_word + else f"{description} must be greater than or equal to 1." + ) + raise ValueError(message) + return text + + +def _validate_page_range_entry(value: str) -> str: + """Normalize and validate a single page range entry.""" + if not isinstance(value, str): + msg = "Each page range entry must be a string." + raise TypeError(msg) + entry = value.strip() + if entry == "": + msg = "Each page range entry must be a non-empty string." + raise ValueError(msg) + if entry == "last": + return entry + if entry.isdigit(): + return _require_positive_page(entry, description="Page numbers") + if "-" in entry: + start_raw, end_raw = (part.strip() for part in entry.split("-", maxsplit=1)) + start = _require_positive_page( + start_raw, description="Page range start", require_page_word=True + ) + if end_raw == "last": + return f"{start}-last" + end = _require_positive_page( + end_raw, description="Page range end", require_page_word=True + ) + if int(end) < int(start): + msg = "Page range end must be greater than or equal to the start." + raise ValueError(msg) + return f"{start}-{end}" + msg = "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'." + raise ValueError(msg) + + +def _split_comma_list(value: Any) -> Any: + if isinstance(value, str): + return value.split(",") + if isinstance(value, list): + return value + msg = "Must be a comma separated string or a list of strings." + raise ValueError(msg) + + +def _pdfrest_file_to_id(value: Any) -> Any: + if isinstance(value, PdfRestFile): + return value.id + return value + + +def _serialize_as_first_file_id(value: list[PdfRestFile]) -> str: + return str(value[0].id) + + +def _serialize_as_comma_separated_string(value: list[Any] | None) -> str | None: + if value is None: + return None + return ",".join(value) + + +PageRangeEntry = Annotated[str, AfterValidator(_validate_page_range_entry)] + + +def _allowed_mime_types( + allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None +) -> Callable[[Any], Any]: + combined_allowed_mime_types = [allowed_mime_types, *more_allowed_mime_types] + + def allowed_mime_types_validator( + value: PdfRestFile | list[PdfRestFile], + ) -> PdfRestFile | list[PdfRestFile]: + if isinstance(value, list): + for item in value: + allowed_mime_types_validator(item) + return value + if value.type not in combined_allowed_mime_types: + msg = error_msg or f"The file type must be one of: {allowed_mime_types}" + raise ValueError(msg) + return value + + return allowed_mime_types_validator + + +def _int_to_string(value: Any) -> Any: + if isinstance(value, int): + return str(value) + if isinstance(value, list): + return [_int_to_string(item) for item in value] + return value + + class UploadURLs(BaseModel): url: Annotated[ list[HttpUrl] | HttpUrl, @@ -29,3 +176,105 @@ class UploadURLs(BaseModel): BeforeValidator(_list_of_strings), BeforeValidator(_ensure_list), ] + + +class ConvertToGraphic(BaseModel): + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_as_first_file_id), + ] + output_prefix: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] + page_range: Annotated[ + list[PageRangeEntry] | None, + Field(serialization_alias="pages", min_length=1, default=None), + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_list), + BeforeValidator(_int_to_string), + PlainSerializer(_serialize_as_comma_separated_string), + ] + resolution: Annotated[int, Field(ge=12, le=2400, default=300)] + color_model: Annotated[Literal["rgb", "rgba", "gray"], Field(default="rgb")] + smoothing: Annotated[ + list[Literal["none", "all", "text", "line", "image"]], + Field(default="none"), + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_list), + PlainSerializer(_serialize_as_comma_separated_string), + ] + + +class PdfRestRawUploadedFile(BaseModel): + """The response sent by /upload is a list of these. /unzip returns files like this + with outputUrl""" + + name: Annotated[str, Field(description="The name of the file")] + id: Annotated[PdfRestFileID, Field(description="The id of the file")] + output_url: Annotated[ + str | None, + Field(description="The url of the unzipped file", alias="outputUrl"), + BeforeValidator(_ensure_list), + ] = None + + +class PdfRestRawFileResponse(BaseModel): + """The raw response from file-based pdfRest calls.""" + + # Allow all extra fields to be stored and serialized + # See: https://docs.pydantic.dev/latest/concepts/models/#extra-fields + model_config = ConfigDict(extra="allow") + + input_id: Annotated[ + list[PdfRestFileID], + Field(alias="inputId", description="The id of the input file"), + BeforeValidator(_ensure_list), + ] + output_urls: Annotated[ + list[HttpUrl] | None, + Field(alias="outputUrl", description="The url of the file"), + BeforeValidator(_ensure_list), + ] = None + output_ids: Annotated[ + list[PdfRestFileID] | None, + Field(alias="outputId", description="The id of the file"), + BeforeValidator(_ensure_list), + ] = None + files: Annotated[ + list[PdfRestRawUploadedFile] | None, + Field(description="The file(s) returned from the /unzip operation"), + BeforeValidator(_ensure_list), + ] = None + warning: Annotated[ + str | None, + Field( + description="A warning that was generated during the pdfRest operation", + ), + ] = None + + @model_validator(mode="after") + def _check_output_id_or_files(self) -> Any: + if self.output_ids is None and self.files is None: + msg = "output_id or files must be specified" + raise ValueError(msg) + return self + + @property + def ids(self) -> list[PdfRestFileID] | None: + if self.output_ids is not None: + return self.output_ids + if self.files is not None: + return [f.id for f in self.files] + return None diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 37fcdbe7..57de80c4 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -2,11 +2,179 @@ from __future__ import annotations +import re +import uuid as _uuid from datetime import date +from typing import Annotated, Any, ClassVar -from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, HttpUrl +from pydantic import ( + AliasChoices, + AwareDatetime, + BaseModel, + ConfigDict, + Field, + HttpUrl, +) +from pydantic_core import CoreSchema -__all__ = ("PdfRestErrorResponse", "PdfRestFile", "UpResponse") +__all__ = ("PdfRestErrorResponse", "PdfRestFile", "PdfRestFileID", "UpResponse") + + +class PdfRestFileID(str): + """ + A str-like type representing: + [optional '1' or '2' prefix] + [UUIDv4 with hyphens] + + Examples: + - "de305d2-b6a0-4b5d-9a55-4e4e6d8c2d39" # no prefix + - "1de305d2-b6a0-4b5d-9a55-4e4e6d8c2d39" # prefix '1' + - "2DE305D2-B6A0-4B5D-9A55-4E4E6D8C2D39" # prefix '2' (upper-case input accepted) + + Canonical representation is lowercase. + """ + + __slots__ = () + + # For Python validation (case-insensitive) + _PY_PATTERN: ClassVar[re.Pattern[str]] = re.compile( + r"^(?:[12])?(?:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$", + re.IGNORECASE, + ) + + # For JSON Schema (no inline flags; must include both cases) + _PATTERN_STR: ClassVar[str] = ( + r"^(?:[12])?(?:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12})$" + ) + + def __new__(cls, value: str) -> PdfRestFileID: + if not isinstance(value, str): + msg = "PdfRestPrefixedUUID4 requires a str" + raise TypeError(msg) + if not cls._PY_PATTERN.fullmatch(value): + msg = ( + "Invalid PdfRestPrefixedUUID4. Expected: " + "optional '1' or '2' prefix + UUIDv4 with hyphens and RFC 4122 variant" + ) + raise ValueError(msg) + return str.__new__(cls, value.lower()) + + def __str__(self) -> str: + return str.__str__(self) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({super().__repr__()})" + + @property + def prefix(self) -> str | None: + """ + The leading prefix digit ('1' or '2') if present, else None. + + Note: Presence is unambiguous by length: + - 36 chars => no prefix + - 37 chars => prefix present + """ + return self[0] if len(self) == 37 else None + + @property + def uuid(self) -> str: + """The UUID part (without the optional prefix).""" + return self[1:] if len(self) == 37 else self + + @property + def uuid_obj(self) -> _uuid.UUID: + """The UUID object for the UUID part.""" + return _uuid.UUID(self.uuid) + + @classmethod + def is_valid(cls, value: str) -> bool: + """Quick validity check without constructing the object.""" + return isinstance(value, str) and bool(cls._PY_PATTERN.fullmatch(value)) + + @classmethod + def from_parts( + cls, u: str | _uuid.UUID, prefix: int | str | None = None + ) -> PdfRestFileID: + """ + Build from a UUIDv4 (str or uuid.UUID) and an optional prefix (1 or 2). + Raises ValueError if not a v4 UUID or bad prefix. + """ + if isinstance(prefix, int): + prefix = str(prefix) # allow 1/2 as int + if prefix not in (None, "1", "2"): + msg = "prefix must be None, '1', or '2'" + raise ValueError(msg) + + if isinstance(u, _uuid.UUID): + if u.version != 4: + msg = "UUID must be version 4" + raise ValueError(msg) + u_text = str(u) + else: + u_text = str(u) + if not re.fullmatch( + r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}", + u_text, + ): + msg = "UUID text must be version 4 with RFC 4122 variant" + raise ValueError(msg) + + return cls((prefix or "") + u_text) + + @classmethod + def generate(cls, prefix: int | str | None = None) -> PdfRestFileID: + """Generate a new value with an optional prefix (1 or 2).""" + if isinstance(prefix, int): + prefix = str(prefix) + if prefix not in (None, "1", "2"): + msg = "prefix must be None, '1', or '2'" + raise ValueError(msg) + return cls.from_parts(_uuid.uuid4(), prefix=prefix) + + # ------------------------- + # Pydantic v2 integration + # ------------------------- + @classmethod + def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> CoreSchema: + """ + Build a Pydantic v2 core schema that accepts: + - a UUID (validated as v4) -> converted to this type (no prefix) + - a string matching our pattern + """ + from pydantic_core import core_schema + + str_schema = core_schema.str_schema(pattern=cls._PATTERN_STR) + uuid_schema = core_schema.uuid_schema(version=4) + union = core_schema.union_schema([uuid_schema, str_schema]) + + def to_class(v: Any) -> PdfRestFileID: + if isinstance(v, _uuid.UUID): + # UUID input: no prefix + return cls(str(v)) + # string already pattern-validated by inner schema + return cls(v) + + return core_schema.no_info_after_validator_function( + to_class, + union, + serialization=core_schema.to_string_ser_schema(), + ) + + @classmethod + def __get_pydantic_json_schema__(cls, core_schema: Any, handler: Any) -> dict: + """ + Provide a clean JSON Schema for OpenAPI/JSON Schema generators. + """ + # Prefer a single-string schema with pattern and examples + return { + "type": "string", + "title": cls.__name__, + "description": "UUIDv4 with hyphens, optionally prefixed by '1' or '2'.", + "pattern": cls._PATTERN_STR, + "examples": [ + "de305d2-b6a0-4b5d-9a55-4e4e6d8c2d39", + "1de305d2-b6a0-4b5d-9a55-4e4e6d8c2d39", + ], + } class UpResponse(BaseModel): @@ -30,7 +198,7 @@ class PdfRestErrorResponse(BaseModel): class PdfRestFile(BaseModel): """Represents a file on the pdfRest server.""" - id: str = Field( + id: PdfRestFileID = Field( min_length=1, description="Identifier of the file on the pdfRest server", ) @@ -59,3 +227,60 @@ class PdfRestFile(BaseModel): ) model_config = ConfigDict(frozen=True) + + +class PdfRestFileBasedResponse(BaseModel): + """ + Represents a response from a pdfRest API operation that is file-based, allowing + handling of input and output files along with additional warnings. + """ + + # Allow all extra fields to be stored and serialized + # See: https://docs.pydantic.dev/latest/concepts/models/#extra-fields + model_config = ConfigDict(extra="allow") + + input_ids: Annotated[ + list[PdfRestFileID], + Field( + description="The ids of the files that were input to the pdfRest operation", + min_length=1, + validation_alias=AliasChoices("input_id", "inputId"), + ), + ] + + # Optional because some endpoints may not make output + output_files: Annotated[ + list[PdfRestFile], + Field( + description="The list of files returned by the pdfRest operation", + min_length=1, + validation_alias=AliasChoices("output_file", "outputFile"), + ), + ] + + warning: Annotated[ + str | None, + Field( + description="A warning that was generated during the pdfRest operation", + ), + ] = None + + @property + def input_id(self) -> PdfRestFileID: + if len(self.input_ids) == 1: + return self.input_ids[0] + if len(self.input_ids) == 0: + msg = "no input id was specified" + else: + msg = "multiple input ids were specified" + raise ValueError(msg) + + @property + def output_file(self) -> PdfRestFile: + if len(self.output_files) == 1: + return self.output_files[0] + if len(self.output_files) == 0: + msg = "no output file was returned by the pdfRest operation" + else: + msg = "multiple output files were returned by the pdfRest operation" + raise ValueError(msg) From efab66ab747fd4505f8029f8167b8e9643879df7 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 31 Oct 2025 13:23:00 -0500 Subject: [PATCH 32/51] Add PNG conversion support to clients and tests - Implemented `convert_to_png` method in both synchronous and asynchronous clients to allow PDF-to-PNG conversion. - Added `ConvertToGraphic` and `PdfRestFileBasedResponse` models to support conversion operations. - Introduced validation checks for parameters like page range, resolution, color model, and output prefix. - Created comprehensive test cases to ensure functionality and handle edge cases in both sync and async clients. Assisted-by: Codex --- src/pdfrest/client.py | 115 ++++++- tests/test_convert_to_png.py | 586 +++++++++++++++++++++++++++++++++++ 2 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 tests/test_convert_to_png.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 7287fa18..95f45b31 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -23,11 +23,16 @@ PdfRestConfigurationError, translate_httpx_error, ) -from .models import PdfRestErrorResponse, PdfRestFile, UpResponse +from .models import ( + PdfRestErrorResponse, + PdfRestFile, + PdfRestFileBasedResponse, + UpResponse, +) __all__ = ("AsyncPdfRestClient", "PdfRestClient") -from .models._internal import UploadURLs +from .models._internal import ConvertToGraphic, PdfRestRawFileResponse, UploadURLs DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" @@ -1024,6 +1029,56 @@ def up( payload = self._send_request(request) return UpResponse.model_validate(payload) + def convert_to_png( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "rgba", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + ) -> PdfRestFileBasedResponse: + """Convert one or more pdfRest files to PNG images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + + conversion_options = ConvertToGraphic.model_validate(payload) + request = self.prepare_request( + "POST", + "/png", + json_body=conversion_options.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_defaults=True + ), + ) + raw_payload = self._send_request(request) + raw_response = PdfRestRawFileResponse.model_validate(raw_payload) + + output_ids = raw_response.ids or [] + output_files = [self.fetch_file_info(str(file_id)) for file_id in output_ids] + + return PdfRestFileBasedResponse.model_validate( + { + "input_id": [str(file_id) for file_id in raw_response.input_id], + "output_file": [ + file.model_dump(mode="json", by_alias=True) for file in output_files + ], + "warning": raw_response.warning, + } + ) + class AsyncPdfRestClient(_AsyncApiClient): """Asynchronous client for interacting with the pdfrest API.""" @@ -1081,3 +1136,59 @@ async def up( ) payload = await self._send_request(request) return UpResponse.model_validate(payload) + + async def convert_to_png( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "rgba", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously convert one or more pdfRest files to PNG images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + + conversion_options = ConvertToGraphic.model_validate(payload) + request = self.prepare_request( + "POST", + "/png", + json_body=conversion_options.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + ) + raw_payload = await self._send_request(request) + raw_response = PdfRestRawFileResponse.model_validate(raw_payload) + + output_ids = raw_response.ids or [] + output_files: list[PdfRestFile] = [] + if output_ids: + output_files = list( + await asyncio.gather( + *(self.fetch_file_info(str(file_id)) for file_id in output_ids) + ) + ) + + return PdfRestFileBasedResponse.model_validate( + { + "input_id": [str(file_id) for file_id in raw_response.input_id], + "output_file": [ + file.model_dump(mode="json", by_alias=True) for file in output_files + ], + "warning": raw_response.warning, + } + ) diff --git a/tests/test_convert_to_png.py b/tests/test_convert_to_png.py new file mode 100644 index 00000000..f2eaad12 --- /dev/null +++ b/tests/test_convert_to_png.py @@ -0,0 +1,586 @@ +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from typing import Any + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import ConvertToGraphic + +from .resources import get_test_resource_path + +VALID_API_KEY = "12345678-1234-1234-1234-123456789abc" +ASYNC_API_KEY = "fedcba98-7654-3210-fedc-ba9876543210" + + +def _build_file_info_payload(file_id: str, name: str) -> dict[str, Any]: + return { + "id": file_id, + "name": name, + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "image/png", + "size": 256, + "modified": datetime(2024, 1, 1, tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + "scheduledDeletionTimeUtc": None, + } + + +def _make_pdf_file(file_id: str, name: str = "example.pdf") -> PdfRestFile: + return PdfRestFile.model_validate( + { + "id": file_id, + "name": name, + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "application/pdf", + "size": 1024, + "modified": datetime(2024, 1, 1, tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + "scheduledDeletionTimeUtc": None, + } + ) + + +def test_convert_to_png_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_pdf_file(PdfRestFileID.generate(1)) + output_id = "1de305d2-b6a0-4b5d-9a55-4e4e6d8c2d39" + + request_payload = ConvertToGraphic.model_validate( + { + "files": [input_file], + "output_prefix": "converted", + "page_range": ["1", "2-3"], + "resolution": 600, + "color_model": "rgba", + "smoothing": ["text", "image"], + } + ).model_dump(mode="json", by_alias=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/png": + seen["post"] += 1 + assert request.headers["wsn"] == "pdfrest-python" + assert json.loads(request.content.decode("utf-8")) == request_payload + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, json=_build_file_info_payload(output_id, "converted-001.png") + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_png( + input_file, + output_prefix="converted", + page_range=["1", "2-3"], + resolution=600, + color_model="rgba", + smoothing=["text", "image"], + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.output_files) == 1 + output_file = response.output_files[0] + assert output_file.name == "converted-001.png" + assert output_file.name.startswith("converted") + assert output_file.type == "image/png" + assert output_file.size == 256 + assert str(output_file.url).endswith(output_id) + assert str(response.input_id) == str(input_file.id) + assert response.warning is None + + +def test_convert_to_png_validation_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="greater than or equal to 12"), + ): + client.convert_to_png( + _make_pdf_file(PdfRestFileID.generate(1)), + resolution=5, + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_png_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_pdf_file(PdfRestFileID.generate(1)) + output_id = "2c134412-aaaa-4bbb-8ccc-dddddddddddd" + + request_payload = ConvertToGraphic.model_validate( + { + "files": [input_file], + "output_prefix": "async-output", + "page_range": "1-2", + "resolution": 450, + "color_model": "rgb", + "smoothing": ["all"], + } + ).model_dump(mode="json", by_alias=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/png": + seen["post"] += 1 + assert json.loads(request.content.decode("utf-8")) == request_payload + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, json=_build_file_info_payload(output_id, "async-output-001.png") + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_png( + [input_file], + output_prefix="async-output", + page_range="1-2", + resolution=450, + color_model="rgb", + smoothing=["all"], + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_files[0] + assert output_file.name == "async-output-001.png" + assert output_file.name.startswith("async-output") + assert output_file.type == "image/png" + assert output_file.size == 256 + assert str(output_file.url).endswith(output_id) + assert str(response.input_id) == str(input_file.id) + assert response.warning is None + + +@pytest.mark.asyncio +async def test_async_convert_to_png_validation_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="less than or equal to 2400"): + await client.convert_to_png( + _make_pdf_file(PdfRestFileID.generate(1)), + resolution=9000, + ) + + +def test_convert_to_png_sequence_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_pdf_file(PdfRestFileID.generate(1)) + output_id = "1f9c6d0a-5ec4-4f6c-b1f2-bbbbbbbbbbbb" + + request_payload = ConvertToGraphic.model_validate( + { + "files": [input_file], + "page_range": "1, 3", + "smoothing": "text", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/png": + seen["post"] += 1 + assert json.loads(request.content.decode("utf-8")) == request_payload + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, json=_build_file_info_payload(output_id, "example-001.png") + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_png( + [input_file], + page_range="1, 3", + smoothing="text", + ) + + assert seen == {"post": 1, "get": 1} + output_file = response.output_files[0] + assert output_file.name == "example-001.png" + assert output_file.name.startswith("example") + assert output_file.type == "image/png" + assert output_file.size == 256 + assert str(output_file.url).endswith(output_id) + assert response.warning is None + + +def test_convert_to_png_page_range_variants(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_pdf_file(PdfRestFileID.generate(1)) + output_id = "1c2d3e4f-5061-4728-b93a-aaaaaaaa2222" + + request_payload = ConvertToGraphic.model_validate( + { + "files": [input_file], + "page_range": [1, "last", "6-last"], + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/png": + seen["post"] += 1 + assert json.loads(request.content.decode("utf-8")) == request_payload + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, json=_build_file_info_payload(output_id, "example-002.png") + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_png( + input_file, + # Deliberately using an integer here to test automatic conversion. + page_range=[1, "last", "6-last"], # type: ignore[list-item] + ) + + assert seen == {"post": 1, "get": 1} + output_file = response.output_files[0] + assert output_file.name == "example-002.png" + assert output_file.name.startswith("example") + assert output_file.type == "image/png" + assert output_file.size == 256 + assert str(output_file.url).endswith(output_id) + + +def test_convert_to_png_defaults_excluded(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_pdf_file(PdfRestFileID.generate(1)) + output_id = "2ab0c1d2-3e4f-4a5b-8c9d-dddddddddddd" + + request_payload = ConvertToGraphic.model_validate( + { + "files": input_file, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/png": + seen["post"] += 1 + assert json.loads(request.content.decode("utf-8")) == request_payload + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, json=_build_file_info_payload(output_id, "example-001.png") + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_png(input_file) + + assert seen == {"post": 1, "get": 1} + output_file = response.output_files[0] + assert output_file.name == "example-001.png" + assert output_file.name.startswith("example") + assert output_file.type == "image/png" + assert output_file.size == 256 + assert str(output_file.url).endswith(output_id) + assert response.warning is None + + +@pytest.mark.parametrize( + ("bad_prefix", "expected"), + [ + pytest.param( + ".hidden", + "The output prefix must not start with a `.`.", + id="leading-dot", + ), + pytest.param( + "profile.json", + "The output prefix is a reserved name.", + id="reserved-profile", + ), + pytest.param( + "metadata.json", + "The output prefix is a reserved name.", + id="reserved-metadata", + ), + pytest.param( + "invalid!name", + "The output prefix must not contain special characters: '!'.", + id="special-char", + ), + pytest.param( + "nested/path", + "The output prefix must not contain a directory separator.", + id="directory-separator", + ), + ], +) +def test_convert_to_png_invalid_output_prefix( + monkeypatch: pytest.MonkeyPatch, bad_prefix: str, expected: str +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match=re.escape(expected)), + ): + client.convert_to_png( + _make_pdf_file(PdfRestFileID.generate(1)), + output_prefix=bad_prefix, + ) + + +@pytest.mark.parametrize( + ("bad_page_range", "expected"), + [ + pytest.param( + "0", + "Page numbers must be greater than or equal to 1.", + id="scalar-zero", + ), + pytest.param( + ["0"], + "Page numbers must be greater than or equal to 1.", + id="list-zero-string", + ), + pytest.param( + [0], + "Page numbers must be greater than or equal to 1.", + id="list-zero-int", + ), + pytest.param( + "last-5", + "Page range start must be a page number greater than or equal to 1.", + id="range-last-to-number", + ), + pytest.param( + "3-2", + "Page range end must be greater than or equal to the start.", + id="range-descending", + ), + pytest.param( + "foo", + "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'.", + id="scalar-word", + ), + pytest.param( + ["1", "foo"], + "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'.", + id="list-mixed", + ), + ], +) +def test_convert_to_png_invalid_page_range_value( + monkeypatch: pytest.MonkeyPatch, bad_page_range: Any, expected: str +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match=re.escape(expected)), + ): + client.convert_to_png( + _make_pdf_file(PdfRestFileID.generate(1)), + page_range=bad_page_range, + ) + + +def test_convert_to_png_invalid_color_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Input should be 'rgb', 'rgba' or 'gray'"), + ), + ): + client.convert_to_png( + _make_pdf_file(PdfRestFileID.generate(1)), + color_model="cmyk", # type: ignore[arg-type] + ) + + +def test_convert_to_png_invalid_smoothing_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Input should be 'none', 'all', 'text', 'line' or 'image'"), + ), + ): + client.convert_to_png( + _make_pdf_file(PdfRestFileID.generate(1)), + smoothing="invalid", # type: ignore[arg-type] + ) + + +def test_convert_to_png_multiple_files_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + first = _make_pdf_file(PdfRestFileID.generate(1)) + second = _make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("List should have at most 1 item after validation"), + ), + ): + client.convert_to_png([first, second]) + + +def test_convert_to_png_empty_page_range_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("List should have at least 1 item after validation"), + ), + ): + client.convert_to_png( + _make_pdf_file(PdfRestFileID.generate(1)), + page_range=[], + ) + + +def test_live_convert_to_png(pdfrest_api_key: str, pdfrest_live_base_url: str) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = client.files.create_from_paths([resource]) + response = client.convert_to_png( + uploaded[0], + output_prefix="live-convert", + page_range="1", + ) + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + + +@pytest.mark.asyncio +async def test_live_async_convert_to_png( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = await client.files.create_from_paths([resource]) + response = await client.convert_to_png( + uploaded[0], + output_prefix="live-async-convert", + page_range="1", + ) + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files From 07ad72e162fa1b17437f0f8a5cd11ee7f60b95dd Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 31 Oct 2025 14:21:39 -0500 Subject: [PATCH 33/51] Add `get` method for retrieving file metadata in clients - Added `get` method in both synchronous and asynchronous clients to fetch file metadata by file identifier (`PdfRestFileID` or string input). - Implemented validation for invalid file IDs in both sync and async clients. - Enriched test cases to ensure coverage for valid and invalid scenarios, including both sync and async executions. Assisted-by: Codex --- src/pdfrest/client.py | 17 +++++++++ tests/test_files.py | 80 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 95f45b31..f975999c 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -27,6 +27,7 @@ PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, + PdfRestFileID, UpResponse, ) @@ -206,6 +207,12 @@ def _resolve_file_id(file_ref: PdfRestFile | str) -> str: return file_ref.id if isinstance(file_ref, PdfRestFile) else str(file_ref) +def _normalize_file_id(file_ref: PdfRestFileID | str) -> PdfRestFileID: + if isinstance(file_ref, PdfRestFileID): + return file_ref + return PdfRestFileID(str(file_ref)) + + ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient) @@ -742,6 +749,11 @@ class _FilesClient: def __init__(self, client: _SyncApiClient) -> None: self._client = client + def get(self, file_ref: PdfRestFileID | str) -> PdfRestFile: + """Retrieve file metadata given a file identifier.""" + file_id = _normalize_file_id(file_ref) + return self._client.fetch_file_info(str(file_id)) + def create(self, files: UploadFiles) -> list[PdfRestFile]: """Upload one or more files by content. @@ -857,6 +869,11 @@ def __init__( self._client = client self._concurrency_limit = concurrency_limit + async def get(self, file_ref: PdfRestFileID | str) -> PdfRestFile: + """Retrieve file metadata given a file identifier.""" + file_id = _normalize_file_id(file_ref) + return await self._client.fetch_file_info(str(file_id)) + async def create(self, files: UploadFiles) -> list[PdfRestFile]: """Upload one or more files by content. diff --git a/tests/test_files.py b/tests/test_files.py index 736ea369..6a767560 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -14,7 +14,7 @@ import pytest_asyncio from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import PdfRestFile +from pdfrest.models import PdfRestFile, PdfRestFileID from .resources import get_test_resource_path @@ -145,6 +145,45 @@ def live_async_file( ) +@pytest.mark.parametrize( + "file_ref", + [ + pytest.param(PdfRestFileID.generate(), id="pdfrest-file-id"), + pytest.param(str(uuid.uuid4()), id="raw-str"), + ], +) +def test_files_get_fetches_info(file_ref: PdfRestFileID | str) -> None: + file_id = str(file_ref) + info_payload = _build_file_info_payload(file_id, "report.pdf") + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path == f"/resource/{file_id}": + assert request.url.params["format"] == "info" + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + file_repr = client.files.get(file_ref) + + _assert_file_matches_payload(file_repr, info_payload) + + +def test_files_get_rejects_invalid_id() -> None: + transport = httpx.MockTransport( + lambda request: (_ for _ in ()).throw( + AssertionError("Request should not be sent for invalid IDs.") + ) + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValueError, match="Invalid PdfRestPrefixedUUID4"), + ): + client.files.get("not-a-valid-id") + + def test_files_create_uses_upload_and_info() -> None: uploaded_file_id = str(uuid.uuid4()) info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") @@ -698,6 +737,45 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(response[0], info_payload) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "file_ref", + [ + pytest.param(PdfRestFileID.generate(), id="pdfrest-file-id"), + pytest.param(str(uuid.uuid4()), id="raw-str"), + ], +) +async def test_async_files_get_fetches_info(file_ref: PdfRestFileID | str) -> None: + file_id = str(file_ref) + info_payload = _build_file_info_payload(file_id, "report.pdf") + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path == f"/resource/{file_id}": + assert request.url.params["format"] == "info" + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + file_repr = await client.files.get(file_ref) + + _assert_file_matches_payload(file_repr, info_payload) + + +@pytest.mark.asyncio +async def test_async_files_get_rejects_invalid_id() -> None: + transport = httpx.MockTransport( + lambda request: (_ for _ in ()).throw( + AssertionError("Request should not be sent for invalid IDs.") + ) + ) + + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + with pytest.raises(ValueError, match="Invalid PdfRestPrefixedUUID4"): + await client.files.get("not-a-valid-id") + + @pytest.mark.asyncio async def test_async_files_create_from_paths() -> None: uploaded_ids = [str(uuid.uuid4()), str(uuid.uuid4())] From e21ba8d72a4a44249748ff149d5acb85188031e4 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 3 Nov 2025 12:43:53 -0600 Subject: [PATCH 34/51] Support request customization for all API endpoints - Introduced `extra_query`, `extra_headers`, `extra_body`, and `timeout` parameters across synchronous and asynchronous methods for enhanced flexibility in API integrations. - Added a validation check to prevent combining JSON payloads with multipart file uploads. - Updated client methods for file handling, downloads, and conversions to include optional request customizations. - Extended tests to cover new request customization capabilities and ensure proper handling of custom parameters in both sync and async clients. Assisted-by: Codex --- src/pdfrest/client.py | 429 +++++++++++++++++++++++++++++++---- tests/test_client.py | 19 ++ tests/test_convert_to_png.py | 144 +++++++++++- tests/test_files.py | 379 +++++++++++++++++++++++++++++++ 4 files changed, 919 insertions(+), 52 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index f975999c..9016bac1 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -397,6 +397,9 @@ def _prepare_request( headers = self._compose_headers(extra_headers) params = self._compose_query_params(query, extra_query) json_payload = self._compose_json_body(json_body, extra_body) + if files is not None and json_payload is not None: + msg = "JSON payloads cannot be combined with multipart file uploads." + raise PdfRestConfigurationError(msg) timeout_value = timeout if timeout is not None else self._config.timeout try: @@ -578,10 +581,36 @@ def _send_request(self, request: _RequestModel) -> Any: def send_request(self, request: _RequestModel) -> Any: return self._send_request(request) - def download_file(self, file_id: str) -> httpx.Response: - request = self._client.build_request("GET", f"/resource/{file_id}") + def download_file( + self, + file_id: str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> httpx.Response: + request = self.prepare_request( + "GET", + f"/resource/{file_id}", + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + http_request = self._client.build_request( + request.method, + request.endpoint, + params=request.params or None, + headers=request.headers or None, + ) + if request.timeout is not None: + timeout_value = ( + request.timeout + if isinstance(request.timeout, httpx.Timeout) + else httpx.Timeout(request.timeout) + ) + http_request.extensions["timeout"] = timeout_value.as_dict() try: - response = self._client.send(request, stream=True) + response = self._client.send(http_request, stream=True) except httpx.HTTPError as exc: raise translate_httpx_error(exc) from exc if not response.is_success: @@ -591,11 +620,21 @@ def download_file(self, file_id: str) -> httpx.Response: response.close() return response - def fetch_file_info(self, file_id: str) -> PdfRestFile: + def fetch_file_info( + self, + file_id: str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFile: request = self.prepare_request( "GET", f"/resource/{file_id}", query={"format": "info"}, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, ) payload = self._send_request(request) return PdfRestFile.model_validate(payload) @@ -660,10 +699,36 @@ async def _send_request(self, request: _RequestModel) -> Any: async def send_request(self, request: _RequestModel) -> Any: return await self._send_request(request) - async def download_file(self, file_id: str) -> httpx.Response: - request = self._client.build_request("GET", f"/resource/{file_id}") + async def download_file( + self, + file_id: str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> httpx.Response: + request = self.prepare_request( + "GET", + f"/resource/{file_id}", + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + http_request = self._client.build_request( + request.method, + request.endpoint, + params=request.params or None, + headers=request.headers or None, + ) + if request.timeout is not None: + timeout_value = ( + request.timeout + if isinstance(request.timeout, httpx.Timeout) + else httpx.Timeout(request.timeout) + ) + http_request.extensions["timeout"] = timeout_value.as_dict() try: - response = await self._client.send(request, stream=True) + response = await self._client.send(http_request, stream=True) except httpx.HTTPError as exc: raise translate_httpx_error(exc) from exc if not response.is_success: @@ -673,11 +738,21 @@ async def download_file(self, file_id: str) -> httpx.Response: await response.aclose() return response - async def fetch_file_info(self, file_id: str) -> PdfRestFile: + async def fetch_file_info( + self, + file_id: str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFile: request = self.prepare_request( "GET", f"/resource/{file_id}", query={"format": "info"}, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, ) payload = await self._send_request(request) return PdfRestFile.model_validate(payload) @@ -749,12 +824,31 @@ class _FilesClient: def __init__(self, client: _SyncApiClient) -> None: self._client = client - def get(self, file_ref: PdfRestFileID | str) -> PdfRestFile: + def get( + self, + id: PdfRestFileID | str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFile: """Retrieve file metadata given a file identifier.""" - file_id = _normalize_file_id(file_ref) - return self._client.fetch_file_info(str(file_id)) + file_id = _normalize_file_id(id) + return self._client.fetch_file_info( + str(file_id), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) - def create(self, files: UploadFiles) -> list[PdfRestFile]: + def create( + self, + files: UploadFiles, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> list[PdfRestFile]: """Upload one or more files by content. Provide either a single file specification or a sequence of file @@ -763,13 +857,33 @@ def create(self, files: UploadFiles) -> list[PdfRestFile]: """ normalized_files = _normalize_upload_files(files) request = self._client.prepare_request( - "POST", "/upload", files=normalized_files + "POST", + "/upload", + files=normalized_files, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, ) payload = self._client.send_request(request) file_ids = _extract_uploaded_file_ids(payload) - return [self._client.fetch_file_info(file_id) for file_id in file_ids] + return [ + self._client.fetch_file_info( + file_id, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + for file_id in file_ids + ] - def create_from_paths(self, file_paths: FilePathInput) -> list[PdfRestFile]: + def create_from_paths( + self, + file_paths: FilePathInput, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> list[PdfRestFile]: """Upload one or more files by their path. Each entry may be a bare path-like object or a tuple of @@ -791,9 +905,22 @@ def create_from_paths(self, file_paths: FilePathInput) -> list[PdfRestFile]: upload_specs.append((filename, file_obj, content_type)) else: upload_specs.append((filename, file_obj)) - return self.create(upload_specs) + return self.create( + upload_specs, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) - def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]: + def create_from_urls( + self, + urls: UrlInput, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> list[PdfRestFile]: """Upload one or more files by providing remote URLs.""" normalized_urls = UploadURLs.model_validate({"url": urls}) # pyright: ignore[reportPrivateUsage] @@ -801,13 +928,37 @@ def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]: "POST", "/upload", json_body=normalized_urls.model_dump(mode="json"), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, ) payload = self._client.send_request(request) file_ids = _extract_uploaded_file_ids(payload) - return [self._client.fetch_file_info(file_id) for file_id in file_ids] + return [ + self._client.fetch_file_info( + file_id, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + for file_id in file_ids + ] - def read_bytes(self, file_ref: PdfRestFile | str) -> bytes: - response = self._client.download_file(_resolve_file_id(file_ref)) + def read_bytes( + self, + file_ref: PdfRestFile | str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> bytes: + response = self._client.download_file( + _resolve_file_id(file_ref), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) try: return response.read() finally: @@ -818,8 +969,16 @@ def read_text( file_ref: PdfRestFile | str, *, encoding: str = "utf-8", + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, ) -> str: - response = self._client.download_file(_resolve_file_id(file_ref)) + response = self._client.download_file( + _resolve_file_id(file_ref), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) try: response.encoding = encoding data = response.read() @@ -828,8 +987,20 @@ def read_text( finally: response.close() - def read_json(self, file_ref: PdfRestFile | str) -> Any: - response = self._client.download_file(_resolve_file_id(file_ref)) + def read_json( + self, + file_ref: PdfRestFile | str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> Any: + response = self._client.download_file( + _resolve_file_id(file_ref), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) try: data = response.read() codec = response.encoding or "utf-8" @@ -841,8 +1012,17 @@ def write_bytes( self, file_ref: PdfRestFile | str, destination: DestinationPath, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, ) -> Path: - response = self._client.download_file(_resolve_file_id(file_ref)) + response = self._client.download_file( + _resolve_file_id(file_ref), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) path = Path(destination) try: with path.open("wb") as file_handle: @@ -852,8 +1032,20 @@ def write_bytes( response.close() return path - def stream(self, file_ref: PdfRestFile | str) -> PdfRestFileStream: - response = self._client.download_file(_resolve_file_id(file_ref)) + def stream( + self, + file_ref: PdfRestFile | str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileStream: + response = self._client.download_file( + _resolve_file_id(file_ref), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) return PdfRestFileStream(response) @@ -869,12 +1061,31 @@ def __init__( self._client = client self._concurrency_limit = concurrency_limit - async def get(self, file_ref: PdfRestFileID | str) -> PdfRestFile: + async def get( + self, + id: PdfRestFileID | str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFile: """Retrieve file metadata given a file identifier.""" - file_id = _normalize_file_id(file_ref) - return await self._client.fetch_file_info(str(file_id)) + file_id = _normalize_file_id(id) + return await self._client.fetch_file_info( + str(file_id), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) - async def create(self, files: UploadFiles) -> list[PdfRestFile]: + async def create( + self, + files: UploadFiles, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> list[PdfRestFile]: """Upload one or more files by content. Provide either a single file specification or a sequence of file @@ -883,7 +1094,12 @@ async def create(self, files: UploadFiles) -> list[PdfRestFile]: """ normalized_files = _normalize_upload_files(files) request = self._client.prepare_request( - "POST", "/upload", files=normalized_files + "POST", + "/upload", + files=normalized_files, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, ) payload = await self._client.send_request(request) file_ids = _extract_uploaded_file_ids(payload) @@ -891,11 +1107,23 @@ async def create(self, files: UploadFiles) -> list[PdfRestFile]: async def fetch(file_id: str) -> PdfRestFile: async with semaphore: - return await self._client.fetch_file_info(file_id) + return await self._client.fetch_file_info( + file_id, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) return await asyncio.gather(*(fetch(file_id) for file_id in file_ids)) - async def create_from_paths(self, file_paths: FilePathInput) -> list[PdfRestFile]: + async def create_from_paths( + self, + file_paths: FilePathInput, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> list[PdfRestFile]: """Upload one or more files by their path. Each entry may be a bare path-like object or a tuple of @@ -917,9 +1145,22 @@ async def create_from_paths(self, file_paths: FilePathInput) -> list[PdfRestFile upload_specs.append((filename, file_obj, content_type)) else: upload_specs.append((filename, file_obj)) - return await self.create(upload_specs) + return await self.create( + upload_specs, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) - async def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]: + async def create_from_urls( + self, + urls: UrlInput, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> list[PdfRestFile]: """Upload one or more files by providing remote URLs.""" normalized_urls = UploadURLs.model_validate({"url": urls}) @@ -927,6 +1168,10 @@ async def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]: "POST", "/upload", json_body=normalized_urls.model_dump(mode="json"), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, ) payload = await self._client.send_request(request) file_ids = _extract_uploaded_file_ids(payload) @@ -934,12 +1179,29 @@ async def create_from_urls(self, urls: UrlInput) -> list[PdfRestFile]: async def fetch(file_id: str) -> PdfRestFile: async with semaphore: - return await self._client.fetch_file_info(file_id) + return await self._client.fetch_file_info( + file_id, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) return await asyncio.gather(*(fetch(file_id) for file_id in file_ids)) - async def read_bytes(self, file_ref: PdfRestFile | str) -> bytes: - response = await self._client.download_file(_resolve_file_id(file_ref)) + async def read_bytes( + self, + file_ref: PdfRestFile | str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> bytes: + response = await self._client.download_file( + _resolve_file_id(file_ref), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) try: return await response.aread() finally: @@ -950,8 +1212,16 @@ async def read_text( file_ref: PdfRestFile | str, *, encoding: str = "utf-8", + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, ) -> str: - response = await self._client.download_file(_resolve_file_id(file_ref)) + response = await self._client.download_file( + _resolve_file_id(file_ref), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) try: response.encoding = encoding data = await response.aread() @@ -960,8 +1230,20 @@ async def read_text( finally: await response.aclose() - async def read_json(self, file_ref: PdfRestFile | str) -> Any: - response = await self._client.download_file(_resolve_file_id(file_ref)) + async def read_json( + self, + file_ref: PdfRestFile | str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> Any: + response = await self._client.download_file( + _resolve_file_id(file_ref), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) try: data = await response.aread() codec = response.encoding or "utf-8" @@ -973,8 +1255,17 @@ async def write_bytes( self, file_ref: PdfRestFile | str, destination: DestinationPath, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, ) -> Path: - response = await self._client.download_file(_resolve_file_id(file_ref)) + response = await self._client.download_file( + _resolve_file_id(file_ref), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) path = Path(destination) try: with path.open("wb") as file_handle: @@ -984,8 +1275,20 @@ async def write_bytes( await response.aclose() return path - async def stream(self, file_ref: PdfRestFile | str) -> AsyncPdfRestFileStream: - response = await self._client.download_file(_resolve_file_id(file_ref)) + async def stream( + self, + file_ref: PdfRestFile | str, + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + timeout: TimeoutTypes | None = None, + ) -> AsyncPdfRestFileStream: + response = await self._client.download_file( + _resolve_file_id(file_ref), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) return AsyncPdfRestFileStream(response) @@ -1057,6 +1360,10 @@ def convert_to_png( smoothing: Literal["none", "all", "text", "line", "image"] | Sequence[Literal["none", "all", "text", "line", "image"]] | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: """Convert one or more pdfRest files to PNG images.""" @@ -1077,14 +1384,26 @@ def convert_to_png( "POST", "/png", json_body=conversion_options.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_defaults=True + mode="json", by_alias=True, exclude_none=True, exclude_unset=True ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, ) raw_payload = self._send_request(request) raw_response = PdfRestRawFileResponse.model_validate(raw_payload) output_ids = raw_response.ids or [] - output_files = [self.fetch_file_info(str(file_id)) for file_id in output_ids] + output_files = [ + self.fetch_file_info( + str(file_id), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + for file_id in output_ids + ] return PdfRestFileBasedResponse.model_validate( { @@ -1165,6 +1484,10 @@ async def convert_to_png( smoothing: Literal["none", "all", "text", "line", "image"] | Sequence[Literal["none", "all", "text", "line", "image"]] | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: """Asynchronously convert one or more pdfRest files to PNG images.""" @@ -1187,6 +1510,10 @@ async def convert_to_png( json_body=conversion_options.model_dump( mode="json", by_alias=True, exclude_none=True, exclude_unset=True ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, ) raw_payload = await self._send_request(request) raw_response = PdfRestRawFileResponse.model_validate(raw_payload) @@ -1196,7 +1523,15 @@ async def convert_to_png( if output_ids: output_files = list( await asyncio.gather( - *(self.fetch_file_info(str(file_id)) for file_id in output_ids) + *( + self.fetch_file_info( + str(file_id), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + for file_id in output_ids + ) ) ) diff --git a/tests/test_client.py b/tests/test_client.py index e9fd7f54..a664de3e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -211,6 +211,25 @@ def test_prepare_request_merges_queries(monkeypatch: pytest.MonkeyPatch) -> None } +def test_prepare_request_rejects_files_with_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY) + with ( + PdfRestClient(api_key=VALID_API_KEY) as client, + pytest.raises( + PdfRestConfigurationError, + match="JSON payloads cannot be combined with multipart file uploads", + ), + ): + client.prepare_request( + "POST", + "/upload", + json_body={"foo": "bar"}, + files=[("file", b"data")], + ) + + def test_authentication_error_raises_specific_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_convert_to_png.py b/tests/test_convert_to_png.py index f2eaad12..a1b1dc68 100644 --- a/tests/test_convert_to_png.py +++ b/tests/test_convert_to_png.py @@ -49,6 +49,20 @@ def _make_pdf_file(file_id: str, name: str = "example.pdf") -> PdfRestFile: ) +def _assert_conversion_payload( + payload: dict[str, Any], expected: dict[str, Any] +) -> None: + for key, value in expected.items(): + assert payload[key] == value + extras = set(payload) - set(expected) + allowed_extras = {"color_model", "resolution"} + assert extras <= allowed_extras + if "resolution" not in expected: + assert payload.get("resolution") == 300 + if "color_model" not in expected: + assert payload.get("color_model") == "rgb" + + def test_convert_to_png_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = _make_pdf_file(PdfRestFileID.generate(1)) @@ -71,7 +85,8 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/png": seen["post"] += 1 assert request.headers["wsn"] == "pdfrest-python" - assert json.loads(request.content.decode("utf-8")) == request_payload + payload = json.loads(request.content.decode("utf-8")) + _assert_conversion_payload(payload, request_payload) return httpx.Response( 200, json={ @@ -112,6 +127,63 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.warning is None +def test_convert_to_png_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_pdf_file(PdfRestFileID.generate(1)) + output_id = "9f4a9b10-3c55-4e6d-a111-1234567890ab" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/png": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "1" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] is True + assert payload["resolution"] == 450 + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "1" + return httpx.Response( + 200, json=_build_file_info_payload(output_id, "custom-001.png") + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_png( + input_file, + resolution=450, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "1"}, + extra_body={"debug": True}, + timeout=0.25, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files[0].name == "custom-001.png" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.25) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.25) + + def test_convert_to_png_validation_error(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -151,7 +223,8 @@ async def test_async_convert_to_png_success(monkeypatch: pytest.MonkeyPatch) -> def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/png": seen["post"] += 1 - assert json.loads(request.content.decode("utf-8")) == request_payload + payload = json.loads(request.content.decode("utf-8")) + _assert_conversion_payload(payload, request_payload) return httpx.Response( 200, json={ @@ -191,6 +264,64 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.warning is None +@pytest.mark.asyncio +async def test_async_convert_to_png_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_pdf_file(PdfRestFileID.generate(1)) + output_id = "abcdb5f9-1234-4c67-98ef-abcdefabcdef" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/png": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] is True + assert payload["resolution"] == 500 + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async" + return httpx.Response( + 200, json=_build_file_info_payload(output_id, "async-custom-001.png") + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_png( + input_file, + resolution=500, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": True}, + timeout=0.6, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files[0].name == "async-custom-001.png" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.6) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.6) + + @pytest.mark.asyncio async def test_async_convert_to_png_validation_error( monkeypatch: pytest.MonkeyPatch, @@ -227,7 +358,8 @@ def test_convert_to_png_sequence_arguments(monkeypatch: pytest.MonkeyPatch) -> N def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/png": seen["post"] += 1 - assert json.loads(request.content.decode("utf-8")) == request_payload + payload = json.loads(request.content.decode("utf-8")) + _assert_conversion_payload(payload, request_payload) return httpx.Response( 200, json={ @@ -278,7 +410,8 @@ def test_convert_to_png_page_range_variants(monkeypatch: pytest.MonkeyPatch) -> def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/png": seen["post"] += 1 - assert json.loads(request.content.decode("utf-8")) == request_payload + payload = json.loads(request.content.decode("utf-8")) + _assert_conversion_payload(payload, request_payload) return httpx.Response( 200, json={ @@ -327,7 +460,8 @@ def test_convert_to_png_defaults_excluded(monkeypatch: pytest.MonkeyPatch) -> No def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/png": seen["post"] += 1 - assert json.loads(request.content.decode("utf-8")) == request_payload + payload = json.loads(request.content.decode("utf-8")) + _assert_conversion_payload(payload, request_payload) return httpx.Response( 200, json={ diff --git a/tests/test_files.py b/tests/test_files.py index 6a767560..be718d7c 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -170,6 +170,39 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(file_repr, info_payload) +def test_files_get_request_customization() -> None: + file_id = str(uuid.uuid4()) + info_payload = _build_file_info_payload(file_id, "report.pdf") + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path == f"/resource/{file_id}": + assert request.url.params["format"] == "info" + assert request.headers["X-Trace"] == "1" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + file_repr = client.files.get( + file_id, + extra_headers={"X-Trace": "1"}, + timeout=0.6, + ) + + _assert_file_matches_payload(file_repr, info_payload) + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.6) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.6) + + def test_files_get_rejects_invalid_id() -> None: transport = httpx.MockTransport( lambda request: (_ for _ in ()).throw( @@ -222,6 +255,123 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(file_repr, info_payload) +def test_files_create_request_customization() -> None: + uploaded_file_id = str(uuid.uuid4()) + info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + assert request.url.params["mode"] == "extended" + assert request.headers["X-Upload-Token"] == "token" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_file_id}, + ] + }, + ) + if ( + request.method == "GET" + and request.url.path == f"/resource/{uploaded_file_id}" + ): + assert request.url.params["format"] == "info" + assert request.url.params["mode"] == "extended" + assert request.headers["X-Upload-Token"] == "token" + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.files.create( + [("report.pdf", b"payload")], + extra_query={"mode": "extended"}, + extra_headers={"X-Upload-Token": "token"}, + timeout=0.75, + ) + + assert len(response) == 1 + _assert_file_matches_payload(response[0], info_payload) + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.75) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.75) + + +def test_download_file_request_customization() -> None: + file_id = str(uuid.uuid4()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert request.url.path == f"/resource/{file_id}" + assert request.url.params["mode"] == "raw" + assert request.headers["X-Trace"] == "1" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response(200, content=b"content") + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.download_file( + file_id, + extra_query={"mode": "raw"}, + extra_headers={"X-Trace": "1"}, + timeout=1.25, + ) + data = response.read() + response.close() + + assert data == b"content" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(1.25) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(1.25) + + +def test_files_read_bytes_request_customization() -> None: + file_id = str(uuid.uuid4()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path == f"/resource/{file_id}": + assert request.url.params["mode"] == "raw" + assert request.headers["X-Trace"] == "1" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response(200, content=b"payload") + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + data = client.files.read_bytes( + file_id, + extra_query={"mode": "raw"}, + extra_headers={"X-Trace": "1"}, + timeout=0.4, + ) + + assert data == b"payload" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.4) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.4) + + def test_files_create_from_paths_uses_upload_and_info() -> None: uploaded_ids = [str(uuid.uuid4()), str(uuid.uuid4())] info_payloads = { @@ -372,6 +522,43 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(response[0], info_payload) +def test_files_create_from_urls_extra_body() -> None: + uploaded_file_id = str(uuid.uuid4()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + payload = json.loads(request.content.decode("utf-8")) + assert payload == { + "url": ["https://example.com/report.pdf"], + "metadata": {"source": "test"}, + } + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_file_id}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + assert request.url.params["format"] == "info" + return httpx.Response( + 200, json=_build_file_info_payload(uploaded_file_id, "report.pdf") + ) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.files.create_from_urls( + "https://example.com/report.pdf", + extra_body={"metadata": {"source": "test"}}, + ) + + assert len(response) == 1 + assert response[0].id == uploaded_file_id + + def test_files_create_from_paths_supports_metadata() -> None: uploaded_file_id = str(uuid.uuid4()) info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") @@ -660,6 +847,160 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(file_repr, payload) +@pytest.mark.asyncio +async def test_async_files_get_request_customization() -> None: + file_id = str(uuid.uuid4()) + info_payload = _build_file_info_payload(file_id, "report.pdf") + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path == f"/resource/{file_id}": + assert request.url.params["format"] == "info" + assert request.headers["X-Trace"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + file_repr = await client.files.get( + file_id, + extra_headers={"X-Trace": "async"}, + timeout=0.55, + ) + + _assert_file_matches_payload(file_repr, info_payload) + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.55) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.55) + + +@pytest.mark.asyncio +async def test_async_files_create_request_customization() -> None: + uploaded_file_id = str(uuid.uuid4()) + info_payload = _build_file_info_payload(uploaded_file_id, "report.pdf") + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + assert request.url.params["mode"] == "extended" + assert request.headers["X-Upload-Token"] == "token" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_file_id}, + ] + }, + ) + if ( + request.method == "GET" + and request.url.path == f"/resource/{uploaded_file_id}" + ): + assert request.url.params["format"] == "info" + assert request.url.params["mode"] == "extended" + assert request.headers["X-Upload-Token"] == "token" + return httpx.Response(200, json=info_payload) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = await client.files.create( + [("report.pdf", b"payload")], + extra_query={"mode": "extended"}, + extra_headers={"X-Upload-Token": "token"}, + timeout=0.5, + ) + + assert len(response) == 1 + _assert_file_matches_payload(response[0], info_payload) + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.5) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.5) + + +@pytest.mark.asyncio +async def test_async_download_file_request_customization() -> None: + file_id = str(uuid.uuid4()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert request.url.path == f"/resource/{file_id}" + assert request.url.params["mode"] == "raw" + assert request.headers["X-Trace"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response(200, content=b"content") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = await client.download_file( + file_id, + extra_query={"mode": "raw"}, + extra_headers={"X-Trace": "async"}, + timeout=0.9, + ) + data = await response.aread() + await response.aclose() + + assert data == b"content" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.9) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.9) + + +@pytest.mark.asyncio +async def test_async_files_read_bytes_request_customization() -> None: + file_id = str(uuid.uuid4()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path == f"/resource/{file_id}": + assert request.url.params["mode"] == "raw" + assert request.headers["X-Trace"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response(200, content=b"payload") + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + data = await client.files.read_bytes( + file_id, + extra_query={"mode": "raw"}, + extra_headers={"X-Trace": "async"}, + timeout=0.35, + ) + + assert data == b"payload" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.35) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.35) + + @pytest.mark.asyncio async def test_async_files_create_from_urls() -> None: uploaded_ids = [str(uuid.uuid4()), str(uuid.uuid4())] @@ -737,6 +1078,44 @@ def handler(request: httpx.Request) -> httpx.Response: _assert_file_matches_payload(response[0], info_payload) +@pytest.mark.asyncio +async def test_async_files_create_from_urls_extra_body() -> None: + uploaded_file_id = str(uuid.uuid4()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/upload": + payload = json.loads(request.content.decode("utf-8")) + assert payload == { + "url": ["https://example.com/report.pdf"], + "metadata": {"source": "async-test"}, + } + return httpx.Response( + 200, + json={ + "files": [ + {"name": "report.pdf", "id": uploaded_file_id}, + ] + }, + ) + if request.method == "GET" and request.url.path.startswith("/resource/"): + assert request.url.params["format"] == "info" + return httpx.Response( + 200, json=_build_file_info_payload(uploaded_file_id, "report.pdf") + ) + msg = f"Unexpected request: {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = await client.files.create_from_urls( + "https://example.com/report.pdf", + extra_body={"metadata": {"source": "async-test"}}, + ) + + assert len(response) == 1 + assert response[0].id == uploaded_file_id + + @pytest.mark.asyncio @pytest.mark.parametrize( "file_ref", From b860594f0a06156d328f5bb670a7ad674abd58ca Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 4 Nov 2025 15:08:04 -0600 Subject: [PATCH 35/51] Add support for multi-format graphic conversions (sync & async) - Implemented methods to convert PDF files to BMP, GIF, JPEG, PNG, and TIFF formats in both synchronous and asynchronous clients. - Centralized `convert_to_graphic` method for reusable logic across all conversion methods. - Added payload models for each graphic format to ensure parameter validation and API compatibility. - Extended tests to verify conversion logic, including live integration tests for color models, resolution bounds, and smoothing options. Assisted-by: Codex --- src/pdfrest/client.py | 479 ++++++++++++++-- src/pdfrest/models/_internal.py | 44 +- tests/graphics_test_helpers.py | 59 ++ tests/live/__init__.py | 1 + tests/live/test_live_graphic_conversions.py | 251 +++++++++ tests/test_convert_to_bmp.py | 558 ++++++++++++++++++ tests/test_convert_to_gif.py | 551 ++++++++++++++++++ tests/test_convert_to_jpeg.py | 590 ++++++++++++++++++++ tests/test_convert_to_png.py | 379 +++++-------- tests/test_convert_to_tiff.py | 565 +++++++++++++++++++ tests/test_graphic_payload_validation.py | 141 +++++ 11 files changed, 3321 insertions(+), 297 deletions(-) create mode 100644 tests/graphics_test_helpers.py create mode 100644 tests/live/__init__.py create mode 100644 tests/live/test_live_graphic_conversions.py create mode 100644 tests/test_convert_to_bmp.py create mode 100644 tests/test_convert_to_gif.py create mode 100644 tests/test_convert_to_jpeg.py create mode 100644 tests/test_convert_to_tiff.py create mode 100644 tests/test_graphic_payload_validation.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 9016bac1..fb61b8cb 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -33,7 +33,16 @@ __all__ = ("AsyncPdfRestClient", "PdfRestClient") -from .models._internal import ConvertToGraphic, PdfRestRawFileResponse, UploadURLs +from .models._internal import ( + BasePdfRestGraphicPayload, + BmpPdfRestPayload, + GifPdfRestPayload, + JpegPdfRestPayload, + PdfRestRawFileResponse, + PngPdfRestPayload, + TiffPdfRestPayload, + UploadURLs, +) DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" @@ -1349,40 +1358,21 @@ def up( payload = self._send_request(request) return UpResponse.model_validate(payload) - def convert_to_png( + def _convert_to_graphic( self, - files: PdfRestFile | Sequence[PdfRestFile], *, - output_prefix: str | None = None, - page_range: str | Sequence[str] | None = None, - resolution: int = 300, - color_model: Literal["rgb", "rgba", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + endpoint: str, + payload: dict[str, Any], + payload_model: type[BasePdfRestGraphicPayload[Any]], extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - """Convert one or more pdfRest files to PNG images.""" - - payload: dict[str, Any] = { - "files": files, - "resolution": resolution, - "color_model": color_model, - } - if output_prefix is not None: - payload["output_prefix"] = output_prefix - if page_range is not None: - payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing - - conversion_options = ConvertToGraphic.model_validate(payload) + conversion_options = payload_model.model_validate(payload) request = self.prepare_request( "POST", - "/png", + endpoint, json_body=conversion_options.model_dump( mode="json", by_alias=True, exclude_none=True, exclude_unset=True ), @@ -1415,6 +1405,209 @@ def convert_to_png( } ) + def convert_to_png( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "rgba", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Convert one or more pdfRest files to PNG images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + + return self._convert_to_graphic( + endpoint="/png", + payload=payload, + payload_model=PngPdfRestPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def convert_to_bmp( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Convert one or more pdfRest files to BMP images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + + return self._convert_to_graphic( + endpoint="/bmp", + payload=payload, + payload_model=BmpPdfRestPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def convert_to_gif( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Convert one or more pdfRest files to GIF images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + + return self._convert_to_graphic( + endpoint="/gif", + payload=payload, + payload_model=GifPdfRestPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def convert_to_jpeg( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "cmyk", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + jpeg_quality: int | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Convert one or more pdfRest files to JPEG images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + if jpeg_quality is not None: + payload["jpeg_quality"] = jpeg_quality + + return self._convert_to_graphic( + endpoint="/jpg", + payload=payload, + payload_model=JpegPdfRestPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def convert_to_tiff( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "rgba", "cmyk", "lab", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Convert one or more pdfRest files to TIFF images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + + return self._convert_to_graphic( + endpoint="/tif", + payload=payload, + payload_model=TiffPdfRestPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + class AsyncPdfRestClient(_AsyncApiClient): """Asynchronous client for interacting with the pdfrest API.""" @@ -1473,40 +1666,21 @@ async def up( payload = await self._send_request(request) return UpResponse.model_validate(payload) - async def convert_to_png( + async def _convert_to_graphic( self, - files: PdfRestFile | Sequence[PdfRestFile], *, - output_prefix: str | None = None, - page_range: str | Sequence[str] | None = None, - resolution: int = 300, - color_model: Literal["rgb", "rgba", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + endpoint: str, + payload: dict[str, Any], + payload_model: type[BasePdfRestGraphicPayload[Any]], extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - """Asynchronously convert one or more pdfRest files to PNG images.""" - - payload: dict[str, Any] = { - "files": files, - "resolution": resolution, - "color_model": color_model, - } - if output_prefix is not None: - payload["output_prefix"] = output_prefix - if page_range is not None: - payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing - - conversion_options = ConvertToGraphic.model_validate(payload) + conversion_options = payload_model.model_validate(payload) request = self.prepare_request( "POST", - "/png", + endpoint, json_body=conversion_options.model_dump( mode="json", by_alias=True, exclude_none=True, exclude_unset=True ), @@ -1544,3 +1718,206 @@ async def convert_to_png( "warning": raw_response.warning, } ) + + async def convert_to_png( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "rgba", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously convert one or more pdfRest files to PNG images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + + return await self._convert_to_graphic( + endpoint="/png", + payload=payload, + payload_model=PngPdfRestPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def convert_to_bmp( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously convert one or more pdfRest files to BMP images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + + return await self._convert_to_graphic( + endpoint="/bmp", + payload=payload, + payload_model=BmpPdfRestPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def convert_to_gif( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously convert one or more pdfRest files to GIF images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + + return await self._convert_to_graphic( + endpoint="/gif", + payload=payload, + payload_model=GifPdfRestPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def convert_to_jpeg( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "cmyk", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + jpeg_quality: int | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously convert one or more pdfRest files to JPEG images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + if jpeg_quality is not None: + payload["jpeg_quality"] = jpeg_quality + + return await self._convert_to_graphic( + endpoint="/jpg", + payload=payload, + payload_model=JpegPdfRestPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def convert_to_tiff( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + output_prefix: str | None = None, + page_range: str | Sequence[str] | None = None, + resolution: int = 300, + color_model: Literal["rgb", "rgba", "cmyk", "lab", "gray"] = "rgb", + smoothing: Literal["none", "all", "text", "line", "image"] + | Sequence[Literal["none", "all", "text", "line", "image"]] + | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously convert one or more pdfRest files to TIFF images.""" + + payload: dict[str, Any] = { + "files": files, + "resolution": resolution, + "color_model": color_model, + } + if output_prefix is not None: + payload["output_prefix"] = output_prefix + if page_range is not None: + payload["page_range"] = page_range + if smoothing is not None: + payload["smoothing"] = smoothing + + return await self._convert_to_graphic( + endpoint="/tif", + payload=payload, + payload_model=TiffPdfRestPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 26a838dc..bdb9f9c0 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -3,7 +3,7 @@ import re from collections.abc import Callable from pathlib import PurePath -from typing import Annotated, Any, Literal +from typing import Annotated, Any, Generic, Literal, TypeVar from pydantic import ( AfterValidator, @@ -178,7 +178,10 @@ class UploadURLs(BaseModel): ] -class ConvertToGraphic(BaseModel): +ColorModelT = TypeVar("ColorModelT", bound=str) + + +class BasePdfRestGraphicPayload(BaseModel, Generic[ColorModelT]): files: Annotated[ list[PdfRestFile], Field( @@ -207,7 +210,7 @@ class ConvertToGraphic(BaseModel): PlainSerializer(_serialize_as_comma_separated_string), ] resolution: Annotated[int, Field(ge=12, le=2400, default=300)] - color_model: Annotated[Literal["rgb", "rgba", "gray"], Field(default="rgb")] + color_model: Annotated[ColorModelT, Field(default=...)] smoothing: Annotated[ list[Literal["none", "all", "text", "line", "image"]], Field(default="none"), @@ -217,6 +220,41 @@ class ConvertToGraphic(BaseModel): ] +class PngPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "rgba", "gray"]]): + """Adapt caller options into a pdfRest-ready PNG request payload.""" + + color_model: Annotated[Literal["rgb", "rgba", "gray"], Field(default="rgb")] + + +class BmpPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "gray"]]): + """Adapt caller options into a pdfRest-ready BMP request payload.""" + + color_model: Annotated[Literal["rgb", "gray"], Field(default="rgb")] + + +class GifPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "gray"]]): + """Adapt caller options into a pdfRest-ready GIF request payload.""" + + color_model: Annotated[Literal["rgb", "gray"], Field(default="rgb")] + + +class JpegPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "cmyk", "gray"]]): + """Adapt caller options into a pdfRest-ready JPEG request payload.""" + + color_model: Annotated[Literal["rgb", "cmyk", "gray"], Field(default="rgb")] + jpeg_quality: Annotated[int, Field(ge=1, le=100, default=75)] + + +class TiffPdfRestPayload( + BasePdfRestGraphicPayload[Literal["rgb", "rgba", "cmyk", "lab", "gray"]] +): + """Adapt caller options into a pdfRest-ready TIFF request payload.""" + + color_model: Annotated[ + Literal["rgb", "rgba", "cmyk", "lab", "gray"], Field(default="rgb") + ] + + class PdfRestRawUploadedFile(BaseModel): """The response sent by /upload is a list of these. /unzip returns files like this with outputUrl""" diff --git a/tests/graphics_test_helpers.py b/tests/graphics_test_helpers.py new file mode 100644 index 00000000..8fff8bfa --- /dev/null +++ b/tests/graphics_test_helpers.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime, timezone +from typing import Any + +from pdfrest.models import PdfRestFile + +VALID_API_KEY = "12345678-1234-1234-1234-123456789abc" +ASYNC_API_KEY = "fedcba98-7654-3210-fedc-ba9876543210" + + +def build_file_info_payload(file_id: str, name: str, mime_type: str) -> dict[str, Any]: + return { + "id": file_id, + "name": name, + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": mime_type, + "size": 256, + "modified": datetime(2024, 1, 1, tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + "scheduledDeletionTimeUtc": None, + } + + +def make_pdf_file(file_id: str, name: str = "example.pdf") -> PdfRestFile: + return PdfRestFile.model_validate( + { + "id": file_id, + "name": name, + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "application/pdf", + "size": 1024, + "modified": datetime(2024, 1, 1, tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + "scheduledDeletionTimeUtc": None, + } + ) + + +def assert_conversion_payload( + payload: dict[str, Any], + expected: dict[str, Any], + *, + allowed_extras: Iterable[str] | None = None, +) -> None: + for key, value in expected.items(): + assert payload[key] == value + extra_keys = set(payload) - set(expected) + permitted = {"color_model", "resolution"} + if allowed_extras is not None: + permitted.update(allowed_extras) + assert extra_keys <= permitted + if "resolution" not in expected and "resolution" in payload: + assert payload["resolution"] == 300 + if "color_model" not in expected and "color_model" in payload: + assert payload["color_model"] == "rgb" diff --git a/tests/live/__init__.py b/tests/live/__init__.py new file mode 100644 index 00000000..18b37dd9 --- /dev/null +++ b/tests/live/__init__.py @@ -0,0 +1 @@ +# Package for live integration tests. diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py new file mode 100644 index 00000000..46269233 --- /dev/null +++ b/tests/live/test_live_graphic_conversions.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any, NamedTuple, get_args + +import pytest + +from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models._internal import ( + BasePdfRestGraphicPayload, + BmpPdfRestPayload, + GifPdfRestPayload, + JpegPdfRestPayload, + PngPdfRestPayload, + TiffPdfRestPayload, +) + +from ..resources import get_test_resource_path + + +class _GraphicEndpointSpec(NamedTuple): + method_name: str + payload_model: type[BasePdfRestGraphicPayload[Any]] + + +PAYLOAD_MODELS: dict[str, _GraphicEndpointSpec] = { + "png": _GraphicEndpointSpec("convert_to_png", PngPdfRestPayload), + "bmp": _GraphicEndpointSpec("convert_to_bmp", BmpPdfRestPayload), + "gif": _GraphicEndpointSpec("convert_to_gif", GifPdfRestPayload), + "jpeg": _GraphicEndpointSpec("convert_to_jpeg", JpegPdfRestPayload), + "tiff": _GraphicEndpointSpec("convert_to_tiff", TiffPdfRestPayload), +} + + +def _enumerate_color_models( + payload_model: type[BasePdfRestGraphicPayload[Any]], +) -> Iterable[str]: + field = payload_model.model_fields["color_model"] + return get_args(field.annotation) or () + + +def _resolution_bounds( + payload_model: type[BasePdfRestGraphicPayload[Any]], +) -> tuple[int, int]: + field = payload_model.model_fields["resolution"] + ge = field.metadata[0].ge if field.metadata else 12 + le = field.metadata[1].le if len(field.metadata) > 1 else 2400 + return int(ge), int(le) + + +def _valid_color_cases() -> list[Any]: + cases = [] + for label, spec in PAYLOAD_MODELS.items(): + for color_model in _enumerate_color_models(spec.payload_model): + cases.append( + pytest.param(label, spec, color_model, id=f"{label}-{color_model}") + ) + return cases + + +def _invalid_color_cases() -> list[Any]: + cases = [] + candidates = ("lab", "rgba", "cmyk", "xyz", "ultraviolet", "infrared-spectrum") + for label, spec in PAYLOAD_MODELS.items(): + allowed = set(_enumerate_color_models(spec.payload_model)) + seen: set[str] = set() + for value in (*candidates, "not-a-color-model"): + if value in allowed or value in seen: + continue + seen.add(value) + cases.append(pytest.param(label, spec, value, id=f"{label}-{value}")) + return cases + + +_SMOOTHING_VALUES: tuple[str, ...] = ("none", "all", "text", "line", "image") + + +def _valid_smoothing_cases() -> list[Any]: + cases = [] + for label, spec in PAYLOAD_MODELS.items(): + for smoothing in _SMOOTHING_VALUES: + cases.append( + pytest.param(label, spec, smoothing, id=f"{label}-{smoothing}") + ) + return cases + + +def _invalid_smoothing_cases() -> list[Any]: + cases = [] + invalid_inputs: tuple[Any, ...] = ( + "quantum", + "super-smooth", + "line, hyperreal", + ) + for label, spec in PAYLOAD_MODELS.items(): + for candidate in invalid_inputs: + case_id = ( + f"{label}-{'-'.join(candidate)}" + if isinstance(candidate, list) + else f"{label}-{candidate}" + ) + cases.append(pytest.param(label, spec, candidate, id=case_id)) + return cases + + +@pytest.mark.parametrize( + ("_endpoint_label", "spec", "color_model"), + _valid_color_cases(), +) +def test_live_graphic_valid_color_models( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + _endpoint_label: str, + spec: _GraphicEndpointSpec, + color_model: str, +) -> None: + resource = get_test_resource_path("report.pdf") + payload_model = spec.payload_model + resolution = _resolution_bounds(payload_model)[0] + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + client_method = getattr(client, spec.method_name) + response = client_method( + uploaded, + color_model=color_model, + resolution=resolution, + ) + assert response.output_files + + +@pytest.mark.parametrize( + ("_endpoint_label", "spec", "invalid_color"), + _invalid_color_cases(), +) +def test_live_graphic_invalid_color_model( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + _endpoint_label: str, + spec: _GraphicEndpointSpec, + invalid_color: str, +) -> None: + payload_model = spec.payload_model + + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + client_method = getattr(client, spec.method_name) + resolution = _resolution_bounds(payload_model)[0] + with pytest.raises(PdfRestApiError): + client_method( + uploaded, + resolution=resolution, + extra_body={"color_model": invalid_color}, + ) + + +@pytest.mark.parametrize( + ("_endpoint_label", "spec"), + PAYLOAD_MODELS.items(), + ids=list(PAYLOAD_MODELS), +) +@pytest.mark.parametrize( + ("bound", "offset", "should_raise"), + [ + pytest.param("min", 0, False, id="min"), + pytest.param("max", 0, False, id="max"), + pytest.param("min", -1, True, id="below-min"), + pytest.param("max", 1, True, id="above-max"), + ], +) +def test_live_graphic_resolution_bounds( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + _endpoint_label: str, + spec: _GraphicEndpointSpec, + bound: str, + offset: int, + should_raise: bool, +) -> None: + payload_model = spec.payload_model + min_res, max_res = _resolution_bounds(payload_model) + resource = get_test_resource_path("report.pdf") + + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + client_method = getattr(client, spec.method_name) + base_resolution = min_res if bound == "min" else max_res + call_kwargs: dict[str, Any] = {"resolution": base_resolution} + + if should_raise: + call_kwargs["extra_body"] = {"resolution": base_resolution + offset} + with pytest.raises(PdfRestApiError): + client_method(uploaded, **call_kwargs) + else: + response = client_method(uploaded, **call_kwargs) + assert response.output_files + + +@pytest.mark.parametrize( + ("_endpoint_label", "spec", "smoothing_value"), + _valid_smoothing_cases(), +) +def test_live_graphic_valid_smoothing( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + _endpoint_label: str, + spec: _GraphicEndpointSpec, + smoothing_value: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + client_method = getattr(client, spec.method_name) + response = client_method( + uploaded, + smoothing=smoothing_value, + ) + assert response.output_files + + +@pytest.mark.parametrize( + ("_endpoint_label", "spec", "invalid_smoothing"), + _invalid_smoothing_cases(), +) +def test_live_graphic_invalid_smoothing( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + _endpoint_label: str, + spec: _GraphicEndpointSpec, + invalid_smoothing: Any, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + client_method = getattr(client, spec.method_name) + with pytest.raises(PdfRestApiError): + client_method( + uploaded, + smoothing="none", + extra_body={"smoothing": invalid_smoothing}, + ) diff --git a/tests/test_convert_to_bmp.py b/tests/test_convert_to_bmp.py new file mode 100644 index 00000000..a07eb749 --- /dev/null +++ b/tests/test_convert_to_bmp.py @@ -0,0 +1,558 @@ +from __future__ import annotations + +import json +import re + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import BmpPdfRestPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + assert_conversion_payload, + build_file_info_payload, + make_pdf_file, +) +from .resources import get_test_resource_path + + +@pytest.mark.parametrize("color_model", ["rgb", "gray"]) +def test_convert_to_bmp_success( + monkeypatch: pytest.MonkeyPatch, color_model: str +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + expected_name = f"converted-{color_model}-001.bmp" + + request_payload = BmpPdfRestPayload.model_validate( + { + "files": [input_file], + "output_prefix": "converted", + "page_range": ["1", "2-3"], + "resolution": 600, + "color_model": color_model, + "smoothing": ["text", "image"], + } + ).model_dump(mode="json", by_alias=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/bmp": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload(output_id, expected_name, "image/bmp"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_bmp( + input_file, + output_prefix="converted", + page_range=["1", "2-3"], + resolution=600, + color_model=color_model, # type: ignore[arg-type] + smoothing=["text", "image"], + ) + + assert seen == {"post": 1, "get": 1} + output_file = response.output_files[0] + assert output_file.name == expected_name + assert output_file.type == "image/bmp" + assert output_file.size == 256 + assert str(output_file.url).endswith(output_id) + assert response.warning is None + + +def test_convert_to_bmp_defaults_excluded(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "2c3d4e5f-2345-4bcd-8ef0-abcdefabcdef" + + request_payload = BmpPdfRestPayload.model_validate( + {"files": input_file} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/bmp": + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "example-001.bmp", "image/bmp"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_bmp(input_file) + + output_file = response.output_files[0] + assert output_file.name == "example-001.bmp" + assert output_file.type == "image/bmp" + assert response.warning is None + + +@pytest.mark.parametrize("resolution", [12, 2400]) +def test_convert_to_bmp_resolution_limits( + monkeypatch: pytest.MonkeyPatch, resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + request_payload = BmpPdfRestPayload.model_validate( + { + "files": [input_file], + "resolution": resolution, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/bmp": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, f"example-resolution-{resolution}.bmp", "image/bmp" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_bmp( + input_file, + resolution=resolution, + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == f"example-resolution-{resolution}.bmp" + + +@pytest.mark.parametrize("invalid_resolution", [11, 2401]) +def test_convert_to_bmp_resolution_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, invalid_resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=r"less than or equal to 2400|greater than or equal to 12", + ), + ): + client.convert_to_bmp( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=invalid_resolution, + ) + + +@pytest.mark.parametrize( + "invalid_color", + [ + pytest.param("rgba", id="rgba"), + pytest.param("cmyk", id="cmyk"), + pytest.param("lab", id="lab"), + ], +) +def test_convert_to_bmp_invalid_color_models( + monkeypatch: pytest.MonkeyPatch, invalid_color: str +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Input should be 'rgb' or 'gray'"), + ), + ): + client.convert_to_bmp( + make_pdf_file(PdfRestFileID.generate(1)), + color_model=invalid_color, # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_bmp_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "3d4e5f60-3456-4cde-8f01-cdefabcdef12" + + request_payload = BmpPdfRestPayload.model_validate( + { + "files": [input_file], + "output_prefix": "async-output", + "page_range": "1-2", + "resolution": 450, + "color_model": "gray", + "smoothing": ["all"], + } + ).model_dump(mode="json", by_alias=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/bmp": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-output-001.bmp", "image/bmp" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_bmp( + [input_file], + output_prefix="async-output", + page_range="1-2", + resolution=450, + color_model="gray", + smoothing=["all"], + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_files[0] + assert output_file.name == "async-output-001.bmp" + assert output_file.type == "image/bmp" + assert output_file.size == 256 + assert str(output_file.url).endswith(output_id) + assert response.warning is None + + +def test_convert_to_bmp_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "4e5f6071-4567-4def-90ab-abcdefabcdef" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/bmp": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "bmp" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] is True + assert payload["resolution"] == 500 + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "bmp" + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "custom-001.bmp", "image/bmp"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_bmp( + input_file, + resolution=500, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "bmp"}, + extra_body={"debug": True}, + timeout=0.4, + ) + + assert response.output_files[0].name == "custom-001.bmp" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.4) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.4) + + +def test_convert_to_bmp_validation_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match="less than or equal to 2400", + ), + ): + client.convert_to_bmp( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=5000, + ) + + +def test_convert_to_bmp_invalid_smoothing_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Input should be 'none', 'all', 'text', 'line' or 'image'"), + ), + ): + client.convert_to_bmp( + make_pdf_file(PdfRestFileID.generate(1)), + smoothing="invalid", # type: ignore[arg-type] + ) + + +def test_convert_to_bmp_multiple_files_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + first = make_pdf_file(PdfRestFileID.generate(1)) + second = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("List should have at most 1 item after validation"), + ), + ): + client.convert_to_bmp([first, second]) + + +def test_convert_to_bmp_empty_page_range_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("List should have at least 1 item after validation"), + ), + ): + client.convert_to_bmp( + make_pdf_file(PdfRestFileID.generate(1)), + page_range=[], + ) + + +def test_convert_to_bmp_sequence_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "5f607182-5678-4ef0-91bc-cdefabcdef99" + + request_payload = BmpPdfRestPayload.model_validate( + { + "files": [input_file], + "page_range": "1, 3", + "smoothing": "text", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/bmp": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "example-001.bmp", "image/bmp"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_bmp( + [input_file], + page_range="1, 3", + smoothing="text", + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == "example-001.bmp" + + +@pytest.mark.asyncio +async def test_async_convert_to_bmp_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "6a7b8c9d-6789-4012-a1b2-c3d4e5f6a7b8" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/bmp": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async-bmp" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] is True + assert payload["resolution"] == 475 + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async-bmp" + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-custom-001.bmp", "image/bmp" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_bmp( + input_file, + resolution=475, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async-bmp"}, + extra_body={"debug": True}, + timeout=0.55, + ) + + assert response.output_files[0].name == "async-custom-001.bmp" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.55) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.55) + + +def test_live_convert_to_bmp( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = client.files.create_from_paths([resource]) + response = client.convert_to_bmp( + uploaded[0], + output_prefix="live-bmp", + page_range="1", + ) + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + + +@pytest.mark.asyncio +async def test_live_async_convert_to_bmp( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = await client.files.create_from_paths([resource]) + response = await client.convert_to_bmp( + uploaded[0], + output_prefix="live-async-bmp", + page_range="1", + ) + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files diff --git a/tests/test_convert_to_gif.py b/tests/test_convert_to_gif.py new file mode 100644 index 00000000..00569c67 --- /dev/null +++ b/tests/test_convert_to_gif.py @@ -0,0 +1,551 @@ +from __future__ import annotations + +import json +import re + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import GifPdfRestPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + assert_conversion_payload, + build_file_info_payload, + make_pdf_file, +) +from .resources import get_test_resource_path + + +@pytest.mark.parametrize("color_model", ["rgb", "gray"]) +def test_convert_to_gif_success( + monkeypatch: pytest.MonkeyPatch, color_model: str +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + expected_name = f"converted-{color_model}-001.gif" + + request_payload = GifPdfRestPayload.model_validate( + { + "files": [input_file], + "output_prefix": "converted", + "page_range": ["1", "3-4"], + "resolution": 500, + "color_model": color_model, + "smoothing": ["text", "image"], + } + ).model_dump(mode="json", by_alias=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/gif": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload(output_id, expected_name, "image/gif"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_gif( + input_file, + output_prefix="converted", + page_range=["1", "3-4"], + resolution=500, + color_model=color_model, # type: ignore[arg-type] + smoothing=["text", "image"], + ) + + assert seen == {"post": 1, "get": 1} + output_file = response.output_files[0] + assert output_file.name == expected_name + assert output_file.type == "image/gif" + assert str(output_file.url).endswith(output_id) + + +def test_convert_to_gif_defaults_excluded(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "5b6c7d8e-2345-4bcd-9ef0-abcdefabcdef" + + request_payload = GifPdfRestPayload.model_validate( + {"files": input_file} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/gif": + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "example-001.gif", "image/gif"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_gif(input_file) + + output_file = response.output_files[0] + assert output_file.name == "example-001.gif" + assert output_file.type == "image/gif" + + +@pytest.mark.parametrize("resolution", [12, 2400]) +def test_convert_to_gif_resolution_limits( + monkeypatch: pytest.MonkeyPatch, resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + request_payload = GifPdfRestPayload.model_validate( + { + "files": [input_file], + "resolution": resolution, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/gif": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, f"example-resolution-{resolution}.gif", "image/gif" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_gif( + input_file, + resolution=resolution, + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == f"example-resolution-{resolution}.gif" + + +@pytest.mark.parametrize( + "invalid_color", + [ + pytest.param("rgba", id="rgba"), + pytest.param("cmyk", id="cmyk"), + pytest.param("lab", id="lab"), + ], +) +def test_convert_to_gif_invalid_color_model( + monkeypatch: pytest.MonkeyPatch, invalid_color: str +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Input should be 'rgb' or 'gray'"), + ), + ): + client.convert_to_gif( + make_pdf_file(PdfRestFileID.generate(1)), + color_model=invalid_color, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize("invalid_resolution", [11, 2401]) +def test_convert_to_gif_resolution_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, invalid_resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=r"less than or equal to 2400|greater than or equal to 12", + ), + ): + client.convert_to_gif( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=invalid_resolution, + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_gif_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "6c7d8e9f-3456-4cde-af01-cdefabcdef12" + + request_payload = GifPdfRestPayload.model_validate( + { + "files": [input_file], + "output_prefix": "async-output", + "page_range": "1-2", + "resolution": 400, + "color_model": "gray", + "smoothing": ["all"], + } + ).model_dump(mode="json", by_alias=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/gif": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-output-001.gif", "image/gif" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_gif( + [input_file], + output_prefix="async-output", + page_range="1-2", + resolution=400, + color_model="gray", + smoothing=["all"], + ) + + assert seen == {"post": 1, "get": 1} + output_file = response.output_files[0] + assert output_file.name == "async-output-001.gif" + assert output_file.type == "image/gif" + + +def test_convert_to_gif_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "7d8e9f00-4567-4ef0-90ab-abcdefabcdef" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/gif": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "gif" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] is True + assert payload["resolution"] == 475 + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "true" + assert request.url.params["format"] == "info" + assert request.headers["X-Debug"] == "gif" + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "custom-001.gif", "image/gif"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_gif( + input_file, + resolution=475, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "gif"}, + extra_body={"debug": True}, + timeout=0.35, + ) + + assert response.output_files[0].name == "custom-001.gif" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.35) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.35) + + +def test_convert_to_gif_validation_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match="less than or equal to 2400", + ), + ): + client.convert_to_gif( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=9999, + ) + + +def test_convert_to_gif_invalid_smoothing_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Input should be 'none', 'all', 'text', 'line' or 'image'"), + ), + ): + client.convert_to_gif( + make_pdf_file(PdfRestFileID.generate(1)), + smoothing="invalid", # type: ignore[arg-type] + ) + + +def test_convert_to_gif_multiple_files_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + first = make_pdf_file(PdfRestFileID.generate(1)) + second = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("List should have at most 1 item after validation"), + ), + ): + client.convert_to_gif([first, second]) + + +def test_convert_to_gif_empty_page_range_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("List should have at least 1 item after validation"), + ), + ): + client.convert_to_gif( + make_pdf_file(PdfRestFileID.generate(1)), + page_range=[], + ) + + +def test_convert_to_gif_sequence_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "8e9f0011-5678-4f01-a234-cdefabcdef55" + + request_payload = GifPdfRestPayload.model_validate( + { + "files": [input_file], + "page_range": "1, 3", + "smoothing": "text", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/gif": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "example-001.gif", "image/gif"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_gif( + [input_file], + page_range="1, 3", + smoothing="text", + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == "example-001.gif" + + +@pytest.mark.asyncio +async def test_async_convert_to_gif_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "9f001122-6789-4012-b345-cdefabcdef66" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/gif": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async-gif" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] is True + assert payload["resolution"] == 425 + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async-gif" + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-custom-001.gif", "image/gif" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_gif( + input_file, + resolution=425, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async-gif"}, + extra_body={"debug": True}, + timeout=0.48, + ) + + assert response.output_files[0].name == "async-custom-001.gif" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.48) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.48) + + +def test_live_convert_to_gif( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = client.files.create_from_paths([resource]) + response = client.convert_to_gif( + uploaded[0], + output_prefix="live-gif", + page_range="1", + ) + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + + +@pytest.mark.asyncio +async def test_live_async_convert_to_gif( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = await client.files.create_from_paths([resource]) + response = await client.convert_to_gif( + uploaded[0], + output_prefix="live-async-gif", + page_range="1", + ) + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files diff --git a/tests/test_convert_to_jpeg.py b/tests/test_convert_to_jpeg.py new file mode 100644 index 00000000..46e5f648 --- /dev/null +++ b/tests/test_convert_to_jpeg.py @@ -0,0 +1,590 @@ +from __future__ import annotations + +import json +import re + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import JpegPdfRestPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + assert_conversion_payload, + build_file_info_payload, + make_pdf_file, +) +from .resources import get_test_resource_path + + +@pytest.mark.parametrize("color_model", ["rgb", "cmyk", "gray"]) +def test_convert_to_jpeg_success( + monkeypatch: pytest.MonkeyPatch, color_model: str +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + expected_name = f"converted-{color_model}-001.jpg" + + request_payload = JpegPdfRestPayload.model_validate( + { + "files": [input_file], + "output_prefix": "converted", + "page_range": ["1", "2-3"], + "resolution": 450, + "color_model": color_model, + "jpeg_quality": 90, + "smoothing": ["text", "image"], + } + ).model_dump(mode="json", by_alias=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/jpg": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload( + payload, request_payload, allowed_extras={"jpeg_quality"} + ) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload(output_id, expected_name, "image/jpeg"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_jpeg( + input_file, + output_prefix="converted", + page_range=["1", "2-3"], + resolution=450, + color_model=color_model, # type: ignore[arg-type] + smoothing=["text", "image"], + jpeg_quality=90, + ) + + assert seen == {"post": 1, "get": 1} + output_file = response.output_files[0] + assert output_file.name == expected_name + assert output_file.type == "image/jpeg" + assert str(output_file.url).endswith(output_id) + + +def test_convert_to_jpeg_defaults_excluded(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "8e9f0011-2222-4bcd-9f00-abcdefabcdef" + + request_payload = JpegPdfRestPayload.model_validate( + {"files": input_file} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/jpg": + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload( + payload, request_payload, allowed_extras={"jpeg_quality"} + ) + assert "jpeg_quality" not in payload + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "example-001.jpg", "image/jpeg" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_jpeg(input_file) + + output_file = response.output_files[0] + assert output_file.name == "example-001.jpg" + assert output_file.type == "image/jpeg" + + +@pytest.mark.parametrize("resolution", [12, 2400]) +def test_convert_to_jpeg_resolution_limits( + monkeypatch: pytest.MonkeyPatch, resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + request_payload = JpegPdfRestPayload.model_validate( + { + "files": [input_file], + "resolution": resolution, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/jpg": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload( + payload, request_payload, allowed_extras={"jpeg_quality"} + ) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, f"example-resolution-{resolution}.jpg", "image/jpeg" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_jpeg( + input_file, + resolution=resolution, + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == f"example-resolution-{resolution}.jpg" + + +@pytest.mark.parametrize("invalid_resolution", [11, 2401]) +def test_convert_to_jpeg_resolution_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, invalid_resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=r"less than or equal to 2400|greater than or equal to 12", + ), + ): + client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=invalid_resolution, + ) + + +@pytest.mark.parametrize( + "invalid_color", + [pytest.param("rgba", id="rgba"), pytest.param("lab", id="lab")], +) +def test_convert_to_jpeg_invalid_color_model( + monkeypatch: pytest.MonkeyPatch, invalid_color: str +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Input should be 'rgb', 'cmyk' or 'gray'"), + ), + ): + client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + color_model=invalid_color, # type: ignore[arg-type] + ) + + +def test_convert_to_jpeg_invalid_quality(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Input should be greater than or equal to 1"), + ), + ): + client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + jpeg_quality=0, + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_jpeg_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "9f001122-3333-4cde-af01-cdefabcdef12" + + request_payload = JpegPdfRestPayload.model_validate( + { + "files": [input_file], + "output_prefix": "async-output", + "page_range": "1-2", + "resolution": 500, + "color_model": "gray", + "jpeg_quality": 85, + "smoothing": ["all"], + } + ).model_dump(mode="json", by_alias=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/jpg": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload( + payload, request_payload, allowed_extras={"jpeg_quality"} + ) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-output-001.jpg", "image/jpeg" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_jpeg( + [input_file], + output_prefix="async-output", + page_range="1-2", + resolution=500, + color_model="gray", + smoothing=["all"], + jpeg_quality=85, + ) + + assert seen == {"post": 1, "get": 1} + output_file = response.output_files[0] + assert output_file.name == "async-output-001.jpg" + assert output_file.type == "image/jpeg" + + +def test_convert_to_jpeg_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "abcdef01-4444-4def-9012-bbbbbbbbbbbb" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/jpg": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "jpeg" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] is True + assert payload["resolution"] == 475 + assert payload["jpeg_quality"] == 82 + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "true" + assert request.url.params["format"] == "info" + assert request.headers["X-Debug"] == "jpeg" + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "custom-001.jpg", "image/jpeg"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_jpeg( + input_file, + resolution=475, + color_model="rgb", + jpeg_quality=82, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "jpeg"}, + extra_body={"debug": True}, + timeout=0.42, + ) + + assert response.output_files[0].name == "custom-001.jpg" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.42) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.42) + + +def test_convert_to_jpeg_validation_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match="less than or equal to 2400", + ), + ): + client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=5000, + ) + + +def test_convert_to_jpeg_invalid_smoothing_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Input should be 'none', 'all', 'text', 'line' or 'image'"), + ), + ): + client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + smoothing="invalid", # type: ignore[arg-type] + ) + + +def test_convert_to_jpeg_multiple_files_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + first = make_pdf_file(PdfRestFileID.generate(1)) + second = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("List should have at most 1 item after validation"), + ), + ): + client.convert_to_jpeg([first, second]) + + +def test_convert_to_jpeg_empty_page_range_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("List should have at least 1 item after validation"), + ), + ): + client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + page_range=[], + ) + + +def test_convert_to_jpeg_sequence_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "cdef0123-5555-4ab0-9123-dededededede" + + request_payload = JpegPdfRestPayload.model_validate( + { + "files": [input_file], + "page_range": "1, 3", + "smoothing": "text", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/jpg": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload, allowed_extras=set()) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "example-001.jpg", "image/jpeg" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_jpeg( + [input_file], + page_range="1, 3", + smoothing="text", + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == "example-001.jpg" + + +@pytest.mark.asyncio +async def test_async_convert_to_jpeg_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "def01234-6666-4bc1-9234-eeeeeeeeeeee" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/jpg": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async-jpeg" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] is True + assert payload["resolution"] == 440 + assert payload["jpeg_quality"] == 88 + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async-jpeg" + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-custom-001.jpg", "image/jpeg" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_jpeg( + input_file, + resolution=440, + color_model="gray", + jpeg_quality=88, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async-jpeg"}, + extra_body={"debug": True}, + timeout=0.51, + ) + + assert response.output_files[0].name == "async-custom-001.jpg" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.51) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.51) + + +def test_live_convert_to_jpeg( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = client.files.create_from_paths([resource]) + response = client.convert_to_jpeg( + uploaded[0], + output_prefix="live-jpeg", + page_range="1", + ) + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + + +@pytest.mark.asyncio +async def test_live_async_convert_to_jpeg( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = await client.files.create_from_paths([resource]) + response = await client.convert_to_jpeg( + uploaded[0], + output_prefix="live-async-jpeg", + page_range="1", + ) + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files diff --git a/tests/test_convert_to_png.py b/tests/test_convert_to_png.py index a1b1dc68..4ac8b1f7 100644 --- a/tests/test_convert_to_png.py +++ b/tests/test_convert_to_png.py @@ -2,79 +2,41 @@ import json import re -from datetime import datetime, timezone -from typing import Any import httpx import pytest from pydantic import ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID -from pdfrest.models._internal import ConvertToGraphic - +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PngPdfRestPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + assert_conversion_payload, + build_file_info_payload, + make_pdf_file, +) from .resources import get_test_resource_path -VALID_API_KEY = "12345678-1234-1234-1234-123456789abc" -ASYNC_API_KEY = "fedcba98-7654-3210-fedc-ba9876543210" - - -def _build_file_info_payload(file_id: str, name: str) -> dict[str, Any]: - return { - "id": file_id, - "name": name, - "url": f"https://api.pdfrest.com/resource/{file_id}", - "type": "image/png", - "size": 256, - "modified": datetime(2024, 1, 1, tzinfo=timezone.utc) - .isoformat() - .replace("+00:00", "Z"), - "scheduledDeletionTimeUtc": None, - } - -def _make_pdf_file(file_id: str, name: str = "example.pdf") -> PdfRestFile: - return PdfRestFile.model_validate( - { - "id": file_id, - "name": name, - "url": f"https://api.pdfrest.com/resource/{file_id}", - "type": "application/pdf", - "size": 1024, - "modified": datetime(2024, 1, 1, tzinfo=timezone.utc) - .isoformat() - .replace("+00:00", "Z"), - "scheduledDeletionTimeUtc": None, - } - ) - - -def _assert_conversion_payload( - payload: dict[str, Any], expected: dict[str, Any] +@pytest.mark.parametrize("color_model", ["rgb", "rgba", "gray"]) +def test_convert_to_png_success( + monkeypatch: pytest.MonkeyPatch, color_model: str ) -> None: - for key, value in expected.items(): - assert payload[key] == value - extras = set(payload) - set(expected) - allowed_extras = {"color_model", "resolution"} - assert extras <= allowed_extras - if "resolution" not in expected: - assert payload.get("resolution") == 300 - if "color_model" not in expected: - assert payload.get("color_model") == "rgb" - - -def test_convert_to_png_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) - input_file = _make_pdf_file(PdfRestFileID.generate(1)) - output_id = "1de305d2-b6a0-4b5d-9a55-4e4e6d8c2d39" + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + expected_name = f"converted-{color_model}-001.png" - request_payload = ConvertToGraphic.model_validate( + request_payload = PngPdfRestPayload.model_validate( { "files": [input_file], "output_prefix": "converted", "page_range": ["1", "2-3"], "resolution": 600, - "color_model": "rgba", + "color_model": color_model, "smoothing": ["text", "image"], } ).model_dump(mode="json", by_alias=True) @@ -86,7 +48,7 @@ def handler(request: httpx.Request) -> httpx.Response: seen["post"] += 1 assert request.headers["wsn"] == "pdfrest-python" payload = json.loads(request.content.decode("utf-8")) - _assert_conversion_payload(payload, request_payload) + assert_conversion_payload(payload, request_payload) return httpx.Response( 200, json={ @@ -98,7 +60,8 @@ def handler(request: httpx.Request) -> httpx.Response: seen["get"] += 1 assert request.url.params["format"] == "info" return httpx.Response( - 200, json=_build_file_info_payload(output_id, "converted-001.png") + 200, + json=build_file_info_payload(output_id, expected_name, "image/png"), ) msg = f"Unexpected request {request.method} {request.url}" raise AssertionError(msg) @@ -110,7 +73,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "2-3"], resolution=600, - color_model="rgba", + color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], ) @@ -118,8 +81,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert isinstance(response, PdfRestFileBasedResponse) assert len(response.output_files) == 1 output_file = response.output_files[0] - assert output_file.name == "converted-001.png" - assert output_file.name.startswith("converted") + assert output_file.name == expected_name assert output_file.type == "image/png" assert output_file.size == 256 assert str(output_file.url).endswith(output_id) @@ -131,7 +93,7 @@ def test_convert_to_png_request_customization( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) - input_file = _make_pdf_file(PdfRestFileID.generate(1)) + input_file = make_pdf_file(PdfRestFileID.generate(1)) output_id = "9f4a9b10-3c55-4e6d-a111-1234567890ab" captured_timeout: dict[str, float | dict[str, float] | None] = {} @@ -156,7 +118,8 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.url.params["trace"] == "true" assert request.headers["X-Debug"] == "1" return httpx.Response( - 200, json=_build_file_info_payload(output_id, "custom-001.png") + 200, + json=build_file_info_payload(output_id, "custom-001.png", "image/png"), ) msg = f"Unexpected request {request.method} {request.url}" raise AssertionError(msg) @@ -184,6 +147,83 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.25) +@pytest.mark.parametrize("resolution", [12, 2400]) +def test_convert_to_png_resolution_limits( + monkeypatch: pytest.MonkeyPatch, resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + request_payload = PngPdfRestPayload.model_validate( + { + "files": [input_file], + "resolution": resolution, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/png": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, f"example-resolution-{resolution}.png", "image/png" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_png( + input_file, + resolution=resolution, + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == f"example-resolution-{resolution}.png" + + +@pytest.mark.parametrize( + "invalid_resolution", + [11, 2401], +) +def test_convert_to_png_resolution_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, invalid_resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=r"less than or equal to 2400|greater than or equal to 12", + ), + ): + client.convert_to_png( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=invalid_resolution, + ) + + def test_convert_to_png_validation_error(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -196,7 +236,7 @@ def handler(_: httpx.Request) -> httpx.Response: pytest.raises(ValidationError, match="greater than or equal to 12"), ): client.convert_to_png( - _make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(1)), resolution=5, ) @@ -204,10 +244,10 @@ def handler(_: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_convert_to_png_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) - input_file = _make_pdf_file(PdfRestFileID.generate(1)) + input_file = make_pdf_file(PdfRestFileID.generate(1)) output_id = "2c134412-aaaa-4bbb-8ccc-dddddddddddd" - request_payload = ConvertToGraphic.model_validate( + request_payload = PngPdfRestPayload.model_validate( { "files": [input_file], "output_prefix": "async-output", @@ -224,7 +264,7 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/png": seen["post"] += 1 payload = json.loads(request.content.decode("utf-8")) - _assert_conversion_payload(payload, request_payload) + assert_conversion_payload(payload, request_payload) return httpx.Response( 200, json={ @@ -236,7 +276,10 @@ def handler(request: httpx.Request) -> httpx.Response: seen["get"] += 1 assert request.url.params["format"] == "info" return httpx.Response( - 200, json=_build_file_info_payload(output_id, "async-output-001.png") + 200, + json=build_file_info_payload( + output_id, "async-output-001.png", "image/png" + ), ) msg = f"Unexpected request {request.method} {request.url}" raise AssertionError(msg) @@ -269,7 +312,7 @@ async def test_async_convert_to_png_request_customization( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) - input_file = _make_pdf_file(PdfRestFileID.generate(1)) + input_file = make_pdf_file(PdfRestFileID.generate(1)) output_id = "abcdb5f9-1234-4c67-98ef-abcdefabcdef" captured_timeout: dict[str, float | dict[str, float] | None] = {} @@ -294,7 +337,10 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.url.params["trace"] == "true" assert request.headers["X-Debug"] == "async" return httpx.Response( - 200, json=_build_file_info_payload(output_id, "async-custom-001.png") + 200, + json=build_file_info_payload( + output_id, "async-custom-001.png", "image/png" + ), ) msg = f"Unexpected request {request.method} {request.url}" raise AssertionError(msg) @@ -335,17 +381,17 @@ def handler(_: httpx.Request) -> httpx.Response: async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: with pytest.raises(ValidationError, match="less than or equal to 2400"): await client.convert_to_png( - _make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(1)), resolution=9000, ) def test_convert_to_png_sequence_arguments(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) - input_file = _make_pdf_file(PdfRestFileID.generate(1)) + input_file = make_pdf_file(PdfRestFileID.generate(1)) output_id = "1f9c6d0a-5ec4-4f6c-b1f2-bbbbbbbbbbbb" - request_payload = ConvertToGraphic.model_validate( + request_payload = PngPdfRestPayload.model_validate( { "files": [input_file], "page_range": "1, 3", @@ -359,7 +405,7 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/png": seen["post"] += 1 payload = json.loads(request.content.decode("utf-8")) - _assert_conversion_payload(payload, request_payload) + assert_conversion_payload(payload, request_payload) return httpx.Response( 200, json={ @@ -370,7 +416,8 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "GET" and request.url.path == f"/resource/{output_id}": seen["get"] += 1 return httpx.Response( - 200, json=_build_file_info_payload(output_id, "example-001.png") + 200, + json=build_file_info_payload(output_id, "example-001.png", "image/png"), ) msg = f"Unexpected request {request.method} {request.url}" raise AssertionError(msg) @@ -393,63 +440,12 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.warning is None -def test_convert_to_png_page_range_variants(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("PDFREST_API_KEY", raising=False) - input_file = _make_pdf_file(PdfRestFileID.generate(1)) - output_id = "1c2d3e4f-5061-4728-b93a-aaaaaaaa2222" - - request_payload = ConvertToGraphic.model_validate( - { - "files": [input_file], - "page_range": [1, "last", "6-last"], - } - ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) - - seen: dict[str, int] = {"post": 0, "get": 0} - - def handler(request: httpx.Request) -> httpx.Response: - if request.method == "POST" and request.url.path == "/png": - seen["post"] += 1 - payload = json.loads(request.content.decode("utf-8")) - _assert_conversion_payload(payload, request_payload) - return httpx.Response( - 200, - json={ - "inputId": [input_file.id], - "outputId": [output_id], - }, - ) - if request.method == "GET" and request.url.path == f"/resource/{output_id}": - seen["get"] += 1 - return httpx.Response( - 200, json=_build_file_info_payload(output_id, "example-002.png") - ) - msg = f"Unexpected request {request.method} {request.url}" - raise AssertionError(msg) - - transport = httpx.MockTransport(handler) - with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: - response = client.convert_to_png( - input_file, - # Deliberately using an integer here to test automatic conversion. - page_range=[1, "last", "6-last"], # type: ignore[list-item] - ) - - assert seen == {"post": 1, "get": 1} - output_file = response.output_files[0] - assert output_file.name == "example-002.png" - assert output_file.name.startswith("example") - assert output_file.type == "image/png" - assert output_file.size == 256 - assert str(output_file.url).endswith(output_id) - - def test_convert_to_png_defaults_excluded(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) - input_file = _make_pdf_file(PdfRestFileID.generate(1)) + input_file = make_pdf_file(PdfRestFileID.generate(1)) output_id = "2ab0c1d2-3e4f-4a5b-8c9d-dddddddddddd" - request_payload = ConvertToGraphic.model_validate( + request_payload = PngPdfRestPayload.model_validate( { "files": input_file, } @@ -461,7 +457,7 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/png": seen["post"] += 1 payload = json.loads(request.content.decode("utf-8")) - _assert_conversion_payload(payload, request_payload) + assert_conversion_payload(payload, request_payload) return httpx.Response( 200, json={ @@ -472,7 +468,8 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "GET" and request.url.path == f"/resource/{output_id}": seen["get"] += 1 return httpx.Response( - 200, json=_build_file_info_payload(output_id, "example-001.png") + 200, + json=build_file_info_payload(output_id, "example-001.png", "image/png"), ) msg = f"Unexpected request {request.method} {request.url}" raise AssertionError(msg) @@ -492,115 +489,11 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize( - ("bad_prefix", "expected"), - [ - pytest.param( - ".hidden", - "The output prefix must not start with a `.`.", - id="leading-dot", - ), - pytest.param( - "profile.json", - "The output prefix is a reserved name.", - id="reserved-profile", - ), - pytest.param( - "metadata.json", - "The output prefix is a reserved name.", - id="reserved-metadata", - ), - pytest.param( - "invalid!name", - "The output prefix must not contain special characters: '!'.", - id="special-char", - ), - pytest.param( - "nested/path", - "The output prefix must not contain a directory separator.", - id="directory-separator", - ), - ], -) -def test_convert_to_png_invalid_output_prefix( - monkeypatch: pytest.MonkeyPatch, bad_prefix: str, expected: str -) -> None: - monkeypatch.delenv("PDFREST_API_KEY", raising=False) - - def handler(_: httpx.Request) -> httpx.Response: - pytest.fail("Request should not be sent when validation fails.") - - transport = httpx.MockTransport(handler) - with ( - PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValidationError, match=re.escape(expected)), - ): - client.convert_to_png( - _make_pdf_file(PdfRestFileID.generate(1)), - output_prefix=bad_prefix, - ) - - -@pytest.mark.parametrize( - ("bad_page_range", "expected"), - [ - pytest.param( - "0", - "Page numbers must be greater than or equal to 1.", - id="scalar-zero", - ), - pytest.param( - ["0"], - "Page numbers must be greater than or equal to 1.", - id="list-zero-string", - ), - pytest.param( - [0], - "Page numbers must be greater than or equal to 1.", - id="list-zero-int", - ), - pytest.param( - "last-5", - "Page range start must be a page number greater than or equal to 1.", - id="range-last-to-number", - ), - pytest.param( - "3-2", - "Page range end must be greater than or equal to the start.", - id="range-descending", - ), - pytest.param( - "foo", - "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'.", - id="scalar-word", - ), - pytest.param( - ["1", "foo"], - "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'.", - id="list-mixed", - ), - ], + "invalid_color", + [pytest.param("cmyk", id="cmyk"), pytest.param("lab", id="lab")], ) -def test_convert_to_png_invalid_page_range_value( - monkeypatch: pytest.MonkeyPatch, bad_page_range: Any, expected: str -) -> None: - monkeypatch.delenv("PDFREST_API_KEY", raising=False) - - def handler(_: httpx.Request) -> httpx.Response: - pytest.fail("Request should not be sent when validation fails.") - - transport = httpx.MockTransport(handler) - with ( - PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValidationError, match=re.escape(expected)), - ): - client.convert_to_png( - _make_pdf_file(PdfRestFileID.generate(1)), - page_range=bad_page_range, - ) - - def test_convert_to_png_invalid_color_model( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, invalid_color: str ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -616,8 +509,8 @@ def handler(_: httpx.Request) -> httpx.Response: ), ): client.convert_to_png( - _make_pdf_file(PdfRestFileID.generate(1)), - color_model="cmyk", # type: ignore[arg-type] + make_pdf_file(PdfRestFileID.generate(1)), + color_model=invalid_color, # type: ignore[arg-type] ) @@ -638,7 +531,7 @@ def handler(_: httpx.Request) -> httpx.Response: ), ): client.convert_to_png( - _make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(1)), smoothing="invalid", # type: ignore[arg-type] ) @@ -651,8 +544,8 @@ def test_convert_to_png_multiple_files_rejected( def handler(_: httpx.Request) -> httpx.Response: pytest.fail("Request should not be sent when validation fails.") - first = _make_pdf_file(PdfRestFileID.generate(1)) - second = _make_pdf_file(PdfRestFileID.generate(1)) + first = make_pdf_file(PdfRestFileID.generate(1)) + second = make_pdf_file(PdfRestFileID.generate(1)) transport = httpx.MockTransport(handler) with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, @@ -681,7 +574,7 @@ def handler(_: httpx.Request) -> httpx.Response: ), ): client.convert_to_png( - _make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(1)), page_range=[], ) diff --git a/tests/test_convert_to_tiff.py b/tests/test_convert_to_tiff.py new file mode 100644 index 00000000..13fe836d --- /dev/null +++ b/tests/test_convert_to_tiff.py @@ -0,0 +1,565 @@ +from __future__ import annotations + +import json +import re + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import TiffPdfRestPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + assert_conversion_payload, + build_file_info_payload, + make_pdf_file, +) +from .resources import get_test_resource_path + + +@pytest.mark.parametrize( + "color_model", + ["rgb", "rgba", "cmyk", "lab", "gray"], +) +def test_convert_to_tiff_success( + monkeypatch: pytest.MonkeyPatch, color_model: str +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + expected_name = f"converted-{color_model}-001.tif" + + request_payload = TiffPdfRestPayload.model_validate( + { + "files": [input_file], + "output_prefix": "converted", + "page_range": ["1", "last"], + "resolution": 600, + "color_model": color_model, + "smoothing": ["text", "image"], + } + ).model_dump(mode="json", by_alias=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/tif": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload(output_id, expected_name, "image/tiff"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_tiff( + input_file, + output_prefix="converted", + page_range=["1", "last"], + resolution=600, + color_model=color_model, # type: ignore[arg-type] + smoothing=["text", "image"], + ) + + assert seen == {"post": 1, "get": 1} + output_file = response.output_files[0] + assert output_file.name == expected_name + assert output_file.type == "image/tiff" + + +def test_convert_to_tiff_defaults_excluded(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "bcdefa23-4567-4bcd-af01-bbbbbbbbcccc" + + request_payload = TiffPdfRestPayload.model_validate( + {"files": input_file} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/tif": + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "example-001.tif", "image/tiff" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_tiff(input_file) + + output_file = response.output_files[0] + assert output_file.name == "example-001.tif" + assert output_file.type == "image/tiff" + + +@pytest.mark.parametrize("resolution", [12, 2400]) +def test_convert_to_tiff_resolution_limits( + monkeypatch: pytest.MonkeyPatch, resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + request_payload = TiffPdfRestPayload.model_validate( + { + "files": [input_file], + "resolution": resolution, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/tif": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, f"example-resolution-{resolution}.tif", "image/tiff" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_tiff( + input_file, + resolution=resolution, + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == f"example-resolution-{resolution}.tif" + + +@pytest.mark.parametrize("invalid_resolution", [11, 2401]) +def test_convert_to_tiff_resolution_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, invalid_resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=r"less than or equal to 2400|greater than or equal to 12", + ), + ): + client.convert_to_tiff( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=invalid_resolution, + ) + + +@pytest.mark.parametrize( + ("invalid_color", "message"), + [ + pytest.param( + "xyz", + "Input should be 'rgb', 'rgba', 'cmyk', 'lab' or 'gray'", + id="unknown", + ), + ], +) +def test_convert_to_tiff_invalid_color_model( + monkeypatch: pytest.MonkeyPatch, + invalid_color: str, + message: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape(message), + ), + ): + client.convert_to_tiff( + make_pdf_file(PdfRestFileID.generate(1)), + color_model=invalid_color, # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_tiff_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "cdefab34-5678-4cde-b012-cdefabcdef34" + + request_payload = TiffPdfRestPayload.model_validate( + { + "files": [input_file], + "output_prefix": "async-output", + "page_range": "1-2", + "resolution": 500, + "color_model": "rgba", + "smoothing": ["all"], + } + ).model_dump(mode="json", by_alias=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/tif": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-output-001.tif", "image/tiff" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_tiff( + [input_file], + output_prefix="async-output", + page_range="1-2", + resolution=500, + color_model="rgba", + smoothing=["all"], + ) + + assert seen == {"post": 1, "get": 1} + output_file = response.output_files[0] + assert output_file.name == "async-output-001.tif" + assert output_file.type == "image/tiff" + + +def test_convert_to_tiff_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "defabc45-6789-4def-9123-abcdefabcdef" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/tif": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "tiff" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] is True + assert payload["resolution"] == 520 + assert payload["color_model"] == "rgba" + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "true" + assert request.url.params["format"] == "info" + assert request.headers["X-Debug"] == "tiff" + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "custom-001.tif", "image/tiff"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_tiff( + input_file, + resolution=520, + color_model="rgba", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "tiff"}, + extra_body={"debug": True}, + timeout=0.46, + ) + + assert response.output_files[0].name == "custom-001.tif" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.46) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.46) + + +def test_convert_to_tiff_validation_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match="less than or equal to 2400", + ), + ): + client.convert_to_tiff( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=9999, + ) + + +def test_convert_to_tiff_invalid_smoothing_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Input should be 'none', 'all', 'text', 'line' or 'image'"), + ), + ): + client.convert_to_tiff( + make_pdf_file(PdfRestFileID.generate(1)), + smoothing="invalid", # type: ignore[arg-type] + ) + + +def test_convert_to_tiff_multiple_files_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + first = make_pdf_file(PdfRestFileID.generate(1)) + second = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("List should have at most 1 item after validation"), + ), + ): + client.convert_to_tiff([first, second]) + + +def test_convert_to_tiff_empty_page_range_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("List should have at least 1 item after validation"), + ), + ): + client.convert_to_tiff( + make_pdf_file(PdfRestFileID.generate(1)), + page_range=[], + ) + + +def test_convert_to_tiff_sequence_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "efabcd56-7890-4f12-a345-f1f2f3f4f5f6" + + request_payload = TiffPdfRestPayload.model_validate( + { + "files": [input_file], + "page_range": "1, 3", + "smoothing": "text", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/tif": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "example-001.tif", "image/tiff" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_tiff( + [input_file], + page_range="1, 3", + smoothing="text", + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == "example-001.tif" + + +@pytest.mark.asyncio +async def test_async_convert_to_tiff_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "fabcd567-8901-4a23-b456-123412341234" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/tif": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async-tiff" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["debug"] is True + assert payload["resolution"] == 540 + assert payload["color_model"] == "cmyk" + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async-tiff" + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-custom-001.tif", "image/tiff" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_tiff( + input_file, + resolution=540, + color_model="cmyk", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async-tiff"}, + extra_body={"debug": True}, + timeout=0.62, + ) + + assert response.output_files[0].name == "async-custom-001.tif" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.62) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.62) + + +def test_live_convert_to_tiff( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = client.files.create_from_paths([resource]) + response = client.convert_to_tiff( + uploaded[0], + output_prefix="live-tiff", + page_range="1", + ) + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + + +@pytest.mark.asyncio +async def test_live_async_convert_to_tiff( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = await client.files.create_from_paths([resource]) + response = await client.convert_to_tiff( + uploaded[0], + output_prefix="live-async-tiff", + page_range="1", + ) + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files diff --git a/tests/test_graphic_payload_validation.py b/tests/test_graphic_payload_validation.py new file mode 100644 index 00000000..4c7b1701 --- /dev/null +++ b/tests/test_graphic_payload_validation.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import re +from typing import Any + +import pytest +from pydantic import ValidationError + +from pdfrest.models._internal import ( + BasePdfRestGraphicPayload, + BmpPdfRestPayload, + GifPdfRestPayload, + JpegPdfRestPayload, + PngPdfRestPayload, + TiffPdfRestPayload, +) + +from .graphics_test_helpers import make_pdf_file + +PAYLOAD_MODELS: tuple[type[BasePdfRestGraphicPayload[Any]], ...] = ( + PngPdfRestPayload, + BmpPdfRestPayload, + GifPdfRestPayload, + JpegPdfRestPayload, + TiffPdfRestPayload, +) + + +@pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) +def test_graphic_payload_accepts_page_range_variants( + payload_model: type[BasePdfRestGraphicPayload[Any]], +) -> None: + payload = payload_model.model_validate( + { + "files": [make_pdf_file("12345678-1234-4abc-8def-1234567890ab")], + "page_range": [1, "last", "6-last"], + } + ) + + data = payload.model_dump(mode="json", by_alias=True, exclude_none=True) + assert data["pages"] == "1,last,6-last" + + +@pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) +@pytest.mark.parametrize( + ("bad_prefix", "expected"), + [ + pytest.param( + ".hidden", + "The output prefix must not start with a `.`.", + id="leading-dot", + ), + pytest.param( + "profile.json", + "The output prefix is a reserved name.", + id="reserved-profile", + ), + pytest.param( + "metadata.json", + "The output prefix is a reserved name.", + id="reserved-metadata", + ), + pytest.param( + "invalid!name", + "The output prefix must not contain special characters: '!'.", + id="special-char", + ), + pytest.param( + "nested/path", + "The output prefix must not contain a directory separator.", + id="directory-separator", + ), + ], +) +def test_graphic_payload_invalid_output_prefix( + payload_model: type[BasePdfRestGraphicPayload[Any]], + bad_prefix: str, + expected: str, +) -> None: + with pytest.raises(ValidationError, match=re.escape(expected)): + payload_model.model_validate( + { + "files": [make_pdf_file("12345678-1234-4abc-8def-1234567890ab")], + "output_prefix": bad_prefix, + } + ) + + +@pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) +@pytest.mark.parametrize( + ("bad_page_range", "expected"), + [ + pytest.param( + "0", + "Page numbers must be greater than or equal to 1.", + id="scalar-zero", + ), + pytest.param( + ["0"], + "Page numbers must be greater than or equal to 1.", + id="list-zero-string", + ), + pytest.param( + [0], + "Page numbers must be greater than or equal to 1.", + id="list-zero-int", + ), + pytest.param( + "last-5", + "Page range start must be a page number greater than or equal to 1.", + id="range-last-to-number", + ), + pytest.param( + "3-2", + "Page range end must be greater than or equal to the start.", + id="range-descending", + ), + pytest.param( + "foo", + "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'.", + id="scalar-word", + ), + pytest.param( + ["1", "foo"], + "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'.", + id="list-mixed", + ), + ], +) +def test_graphic_payload_invalid_page_range_value( + payload_model: type[BasePdfRestGraphicPayload[Any]], + bad_page_range: object, + expected: str, +) -> None: + with pytest.raises(ValidationError, match=re.escape(expected)): + payload_model.model_validate( + { + "files": [make_pdf_file("12345678-1234-4abc-8def-1234567890ab")], + "page_range": bad_page_range, + } + ) From ae128453773d66b5e2c2713c620b975835c3a835 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 4 Nov 2025 15:11:58 -0600 Subject: [PATCH 36/51] noxfile: Run tests in parallel with 8 processes --- noxfile.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index fc25b89a..e312dc0a 100644 --- a/noxfile.py +++ b/noxfile.py @@ -16,4 +16,12 @@ def tests(session: nox.Session) -> None: f"--python={session.virtualenv.location}", env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, ) - session.run("pytest", "--cov=pdfrest", "--cov-report=term-missing") + session.run( + "pytest", + "--cov=pdfrest", + "--cov-report=term-missing", + "--numprocesses", + "8", + "--maxschedchunk", + "2", + ) From a3c9ef3bc38f1da8922700bc43b086394567da19 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 4 Nov 2025 15:22:38 -0600 Subject: [PATCH 37/51] Increase default read timeout - Default timeout remains 10 seconds, except for 120 seconds for read, for when pdfRest takes longer to process a file. - Timeout is configurable by the customer both at the client and individual call level. Assisted-by: Codex --- src/pdfrest/client.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index fb61b8cb..60869779 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -47,7 +47,8 @@ DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" API_KEY_HEADER_NAME = "Api-Key" -DEFAULT_TIMEOUT_SECONDS = 10.0 +DEFAULT_GENERAL_TIMEOUT_SECONDS = 10.0 +DEFAULT_READ_TIMEOUT_SECONDS = 120.0 FILE_UPLOAD_FIELD_NAME = "file" DEFAULT_FILE_INFO_CONCURRENCY = 8 @@ -76,6 +77,13 @@ DestinationPath = str | PathLike[str] +def _default_timeout() -> httpx.Timeout: + return httpx.Timeout( + timeout=DEFAULT_GENERAL_TIMEOUT_SECONDS, + read=DEFAULT_READ_TIMEOUT_SECONDS, + ) + + def _extract_uploaded_file_ids(payload: Any) -> list[str]: try: files_payload = payload["files"] @@ -230,7 +238,7 @@ class _ClientConfig(BaseModel): base_url: URL api_key: str | None = None - timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS + timeout: TimeoutTypes = Field(default_factory=_default_timeout) headers: dict[str, str] = Field(default_factory=dict) model_config = ConfigDict(arbitrary_types_allowed=True) @@ -273,7 +281,7 @@ def _validate_headers(cls, value: Any) -> dict[str, str]: @classmethod def _validate_timeout(cls, value: Any) -> TimeoutTypes: if value is None: - return DEFAULT_TIMEOUT_SECONDS + return _default_timeout() if isinstance(value, (int, float)): return float(value) if isinstance(value, httpx.Timeout): @@ -317,7 +325,7 @@ def __init__( *, api_key: str | None = None, base_url: str | URL | None = None, - timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, + timeout: TimeoutTypes | None = None, headers: AnyMapping | None = None, ) -> None: raw_api_key = api_key if api_key is not None else os.getenv(API_KEY_ENV_VAR) @@ -357,7 +365,7 @@ def __init__( self._config = _ClientConfig( base_url=resolved_base_url, api_key=resolved_api_key, - timeout=timeout, + timeout=timeout if timeout is not None else _default_timeout(), headers=default_headers, ) except PdfRestConfigurationError: @@ -541,7 +549,7 @@ def __init__( *, api_key: str | None = None, base_url: str | URL | None = None, - timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, + timeout: TimeoutTypes | None = None, headers: AnyMapping | None = None, http_client: httpx.Client | None = None, transport: httpx.BaseTransport | None = None, @@ -659,7 +667,7 @@ def __init__( *, api_key: str | None = None, base_url: str | URL | None = None, - timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, + timeout: TimeoutTypes | None = None, headers: AnyMapping | None = None, http_client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, @@ -1309,7 +1317,7 @@ def __init__( *, api_key: str | None = None, base_url: str | URL | None = None, - timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, + timeout: TimeoutTypes | None = None, headers: AnyMapping | None = None, http_client: httpx.Client | None = None, transport: httpx.BaseTransport | None = None, @@ -1617,7 +1625,7 @@ def __init__( *, api_key: str | None = None, base_url: str | URL | None = None, - timeout: TimeoutTypes = DEFAULT_TIMEOUT_SECONDS, + timeout: TimeoutTypes | None = None, headers: AnyMapping | None = None, http_client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, From 11acb2f4304357870046c0f6fb444f2f3dd07227 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 4 Nov 2025 16:39:19 -0600 Subject: [PATCH 38/51] Update AGENTS.md with usage, conventions, and testing guidelines - Added details on pytest parallel execution and scheduling. - Documented context manager usage for sync/async clients. - Expanded coding style with endpoint-specific validation, payload models, and method naming conventions. - Provided detailed testing instructions, including parameterized tests, live tests, and validation checks. - Clarified integration guidelines for new endpoints and shared validation suites. Assisted-by: Codex --- AGENTS.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 75d594d5..a6a8657b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,9 @@ - `uv build` — produce wheels and sdists identical to the release workflow. - `uvx nox -s tests` — create matrix virtualenvs via nox and execute the pytest session. +- `nox` executes pytest sessions with built-in parallelism; when invoking pytest + directly use `pytest -n 8 --maxschedchunk 2` to mirror the parallel test + scheduling and keep runtimes predictable. ## Coding Style & Naming Conventions @@ -36,6 +39,38 @@ - When calling pdfRest, supply the API key via the `Api-Key` header (not `Authorization: Bearer`); keep tests and client defaults in sync with this convention. +- Treat `PdfRestClient` and `AsyncPdfRestClient` as context managers in both + production code and tests so transports are disposed deterministically. +- When uploading content, always send the multipart field name `file`; when + uploading by URL, send a JSON payload using the `url` key with a list of + http/https addresses (single values are promoted to lists internally). +- `prepare_request` rejects mixed multipart (`files`) and JSON payloads; only + URL uploads (`create_from_urls`) should combine JSON bodies with the request. +- Replicate server-side safeguards when porting validation logic: the output + prefix must stay basename-only, reject reserved names (`profile.json`, + `metadata.json`), forbid leading dots or special characters, and report the + offending characters in error messages. Page-range validation operates on each + list item individually—accepts positive integers, `last`, or ranges like + `1-3`/`6-last`—and must raise errors that match the front-end wording. +- Combine multiple synchronous context managers in a single `with` statement + (ruff enforces `SIM117`). When an async context manager participates (e.g., + `async with AsyncPdfRestClient(...)`), nest any synchronous companions such as + `pytest.raises` inside the async block—Python forbids mixing `async with` and + regular `with` clauses in the same statement. When working with `HttpUrl` + objects, cast to `str` before string operations such as suffix checks. +- For image conversions, adapt request data with `BasePdfRestGraphicPayload` + generics; name concrete payloads `BmpPdfRestPayload`, `GifPdfRestPayload`, + `JpegPdfRestPayload`, `PngPdfRestPayload`, and `TiffPdfRestPayload`. Client + helpers should accept a `payload_model` argument and use fully spelled-out + method names such as `convert_to_jpeg`/`convert_to_tiff` (avoid historic + three-letter suffixes). +- When adding new services, provide per-endpoint test modules mirroring PNG’s + coverage: parameterized successes for every allowed literal value, request + customization (sync + async), validation failures, and multi-file guards. Add + a shared validation suite when multiple endpoints rely on the same input rules + (e.g., `tests/test_graphic_payload_validation.py`). +- Do not import from private modules (names beginning with an underscore) in + tests or production code—expose any shared helpers via a public module first. ## Testing Guidelines @@ -45,6 +80,47 @@ in test docstrings when non-obvious. - Use `uvx nox -s tests` to exercise the full interpreter matrix locally when validating compatibility. +- When writing live tests for URL uploads, first create the remote resources via + `create_from_paths`, then reuse the returned URLs in `create_from_urls` to + avoid relying on third-party availability. +- For parameterized tests prefer `pytest.param(..., id="short-label")` so test + IDs stay readable; make assertions for every relevant response attribute (name + prefix, MIME type, size, URLs, warnings). +- Always couple `pytest.raises` with an explicit `match=` regex that reflects + the intended validation error wording—mirror the human-readable text rather + than relying on default exception formatting. +- Mirror PNG’s request/response scenarios for each graphic conversion endpoint: + maintain per-endpoint test modules (`test_convert_to_png.py`, + `test_convert_to_bmp.py`, etc.) covering success, parameter customization, + validation errors, multi-file guards, and async flows. Keep shared payload + validation (output prefix and page-range cases) in a dedicated suite (e.g., + `tests/test_graphic_payload_validation.py`) that exercises every payload + model. +- When introducing additional pdfRest endpoints, follow the same pattern used + for graphic conversions: encapsulate shared request validation in a typed + payload model, expose fully named client methods, and create a dedicated test + module per endpoint that verifies success paths, request customization, + validation errors, and async behavior. Centralize any reusable validation + checks (e.g., common field requirements, payload serialization) in shared + helper tests so new services inherit consistent coverage with minimal + duplication. +- Prefer `pytest.mark.parametrize` (with `pytest.param(..., id="...")`) over + explicit loops inside tests; nest parametrization for multi-dimensional + coverage so each case appears as an individual test item. +- Live tests should verify that literal enumerations match pdfRest’s accepted + values. Exercise format-specific options (e.g., each image format’s + `color_model`) individually, and run smoothing enumerations through every + enabled endpoint to confirm consistent server behaviour. Include “wildly” + invalid values (e.g., bogus literals or mixed lists) alongside boundary + failures so the server-side error messaging is exercised. +- Provide live integration tests under `tests/live/` (with an `__init__.py` so + pytest discovers the package) that introspect payload models to enumerate + valid/invalid literal values and numeric boundaries. These tests should vary a + single parameter per request, assert success for legal inputs, and confirm + pdfRest raises errors for out-of-range or unsupported values. When bypassing + local validation to reach the server (e.g., for negative tests), inject the + override via `extra_body` and expect `PdfRestApiError` (or the precise + exception surfaced by the client). ## Commit & Pull Request Guidelines From fbbaaba8299f04148aebc1caab2eef6bcbb1b2d5 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 4 Nov 2025 18:07:20 -0600 Subject: [PATCH 39/51] Add `query_pdf_info` API - Implemented `query_pdf_info` method in both synchronous and asynchronous clients to retrieve metadata about PDF documents. - Introduced `PdfInfoPayload` and `PdfRestInfoResponse` models for payload validation and response handling. - Added `PdfInfoQuery` type and extended it with various metadata fields supported by the pdfRest API. - Expanded test suite with unit, live, and async tests for the new endpoint. - Enhanced validation utilities to handle query inputs and sequence normalization. --- src/pdfrest/client.py | 57 ++++++ src/pdfrest/models/__init__.py | 2 + src/pdfrest/models/_internal.py | 36 +++- src/pdfrest/models/public.py | 287 +++++++++++++++++++++++++++++++ src/pdfrest/types/__init__.py | 5 + src/pdfrest/types/public.py | 41 +++++ tests/live/test_live_pdf_info.py | 163 ++++++++++++++++++ tests/test_query_pdf_info.py | 207 ++++++++++++++++++++++ 8 files changed, 794 insertions(+), 4 deletions(-) create mode 100644 src/pdfrest/types/__init__.py create mode 100644 src/pdfrest/types/public.py create mode 100644 tests/live/test_live_pdf_info.py create mode 100644 tests/test_query_pdf_info.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 60869779..cc6af526 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -28,6 +28,7 @@ PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID, + PdfRestInfoResponse, UpResponse, ) @@ -38,11 +39,13 @@ BmpPdfRestPayload, GifPdfRestPayload, JpegPdfRestPayload, + PdfInfoPayload, PdfRestRawFileResponse, PngPdfRestPayload, TiffPdfRestPayload, UploadURLs, ) +from .types import PdfInfoQuery DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" @@ -1413,6 +1416,33 @@ def _convert_to_graphic( } ) + def query_pdf_info( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + queries: Sequence[PdfInfoQuery] | PdfInfoQuery, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestInfoResponse: + """Query pdfRest for metadata describing a PDF document.""" + + payload = PdfInfoPayload.model_validate({"file": file, "queries": queries}) + request = self.prepare_request( + "POST", + "/pdf-info", + json_body=payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_defaults=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._send_request(request) + return PdfRestInfoResponse.model_validate(raw_payload) + def convert_to_png( self, files: PdfRestFile | Sequence[PdfRestFile], @@ -1653,6 +1683,33 @@ async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: def files(self) -> _AsyncFilesClient: return self._files_client + async def query_pdf_info( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + queries: Sequence[PdfInfoQuery] | PdfInfoQuery, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestInfoResponse: + """Query pdfRest for metadata describing a PDF document asynchronously.""" + + payload = PdfInfoPayload.model_validate({"file": file, "queries": queries}) + request = self.prepare_request( + "POST", + "/pdf-info", + json_body=payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_defaults=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._send_request(request) + return PdfRestInfoResponse.model_validate(raw_payload) + async def up( self, *, diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index 34e3fea0..e88b2f3d 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -3,6 +3,7 @@ PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID, + PdfRestInfoResponse, UpResponse, ) @@ -11,5 +12,6 @@ "PdfRestFile", "PdfRestFileBasedResponse", "PdfRestFileID", + "PdfRestInfoResponse", "UpResponse", ] diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index bdb9f9c0..3fb51b54 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import PurePath from typing import Annotated, Any, Generic, Literal, TypeVar @@ -17,6 +17,7 @@ model_validator, ) +from ..types import PdfInfoQuery from . import PdfRestFile from .public import PdfRestFileID @@ -24,9 +25,11 @@ def _ensure_list(value: Any) -> Any: if value is None: return None - if not isinstance(value, list): - return [value] - return value + if isinstance(value, list): + return value + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return list(value) + return [value] def _list_of_strings(value: list[Any]) -> list[str]: @@ -118,6 +121,8 @@ def _split_comma_list(value: Any) -> Any: return value.split(",") if isinstance(value, list): return value + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return list(value) msg = "Must be a comma separated string or a list of strings." raise ValueError(msg) @@ -178,6 +183,29 @@ class UploadURLs(BaseModel): ] +class PdfInfoPayload(BaseModel): + """Adapt caller options into a pdfRest-ready pdf-info request payload.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_as_first_file_id), + ] + queries: Annotated[ + list[PdfInfoQuery], + Field(min_length=1), + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_list), + PlainSerializer(_serialize_as_comma_separated_string), + ] + + ColorModelT = TypeVar("ColorModelT", bound=str) diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 57de80c4..d572f6d6 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -284,3 +284,290 @@ def output_file(self) -> PdfRestFile: else: msg = "multiple output files were returned by the pdfRest operation" raise ValueError(msg) + + +class PdfRestInfoResponse(BaseModel): + """A response containing the output from the /info route.""" + + # Allow all extra fields to be stored and serialized + # See: https://docs.pydantic.dev/latest/concepts/models/#extra-fields + model_config = ConfigDict(extra="allow") + + input_id: Annotated[ + PdfRestFileID, + Field( + validation_alias=AliasChoices("input_id", "inputId"), + description="The id of the input file", + ), + ] + tagged: Annotated[ + bool | None, + Field( + description="Indicates whether structure tags are present in the PDF " + "document. The result is true or false." + ), + ] = None + image_only: Annotated[ + bool | None, + Field( + description=( + "Indicates whether the document is 'image only,' meaning it consists " + "solely of embedded graphical images with no text or other standard " + "PDF document features except for metadata. The result is true or " + "false." + ) + ), + ] = None + title: Annotated[ + str | None, + Field( + description=( + "The title of the PDF as retrieved from the metadata. The result is a " + "string that may be empty if the document does not have a title." + ) + ), + ] = None + subject: Annotated[ + str | None, + Field( + description=( + "The subject of the PDF as retrieved from the metadata. The result is " + "a string that may be empty if the document does not have a subject." + ) + ), + ] = None + author: Annotated[ + str | None, + Field( + description=( + "The author of the PDF as retrieved from the metadata. The result is " + "a string that may be empty if the document does not have an author." + ) + ), + ] = None + producer: Annotated[ + str | None, + Field( + description=( + "The producer of the PDF as retrieved from the metadata. The result " + "is a string that may be empty if the document does not have a " + "producer." + ) + ), + ] = None + creator: Annotated[ + str | None, + Field( + description=( + "The creator of the PDF as retrieved from the metadata. The result is " + "a string that may be empty if the document does not have a creator." + ) + ), + ] = None + creation_date: Annotated[ + str | None, + Field( + description=( + "The creation date of the PDF as retrieved from the metadata. The " + "result is a string that may be empty if the document does not " + "have a creation date." + ) + ), + ] = None + modified_date: Annotated[ + str | None, + Field( + description=( + "The most recent modification date of the PDF as retrieved from the " + "metadata. The result is a string that may be empty if the document " + "does not have a modification date." + ) + ), + ] = None + keywords: Annotated[ + str | None, + Field( + description=( + "The keywords of the PDF as retrieved from the metadata. The result " + "is a string that may be empty if the document does not include " + "keywords." + ) + ), + ] = None + custom_metadata: Annotated[ + dict[str, Any] | None, + Field( + description=( + "Custom metadata entries extracted from the PDF. The result is a " + "dictionary mapping keys to their stored values, or None when no " + "custom metadata exists." + ) + ), + ] = None + doc_language: Annotated[ + str | None, + Field( + description="The language of the document as declared in its metadata. " + "The result is a string." + ), + ] = None + page_count: Annotated[ + int | None, + Field( + description="The number of pages in the PDF document. The result is an " + "integer." + ), + ] = None + contains_annotations: Annotated[ + bool | None, + Field( + description=( + "Indicates whether the PDF document contains annotations such as " + "notes, highlighted text, file attachments, crossed-out text, or text " + "callout boxes. The result is true or false." + ) + ), + ] = None + contains_signature: Annotated[ + bool | None, + Field( + description="Indicates whether the PDF contains any digital signatures. " + "The result is true or false." + ), + ] = None + pdf_version: Annotated[ + str | None, + Field( + description=( + "The version of the PDF standard used to create the document. The " + "result is a string in the format X.Y.Z, where X, Y, and Z represent " + "the major, minor, and extension versions." + ) + ), + ] = None + file_size: Annotated[ + int | None, + Field( + description="The size of the PDF file in bytes. The result is an integer." + ), + ] = None + filename: Annotated[ + str | None, + Field(description="The name of the PDF file. The result is a string."), + ] = None + restrict_permissions_set: Annotated[ + bool | None, + Field( + description=( + "Indicates whether the PDF file has restricted permissions, such as " + "preventing printing, copying, or signing. The result is true or " + "false." + ) + ), + ] = None + contains_xfa: Annotated[ + bool | None, + Field( + description="Indicates whether the PDF contains XFA forms. The result is " + "true or false." + ), + ] = None + contains_acroforms: Annotated[ + bool | None, + Field( + description="Indicates whether the PDF contains Acroforms. The result is " + "true or false." + ), + ] = None + contains_javascript: Annotated[ + bool | None, + Field( + description="Indicates whether the PDF contains JavaScript. The result is " + "true or false." + ), + ] = None + contains_transparency: Annotated[ + bool | None, + Field( + description="Indicates whether the PDF contains transparent objects. The " + "result is true or false." + ), + ] = None + contains_embedded_file: Annotated[ + bool | None, + Field( + description="Indicates whether the PDF contains one or more embedded " + "files. The result is true or false." + ), + ] = None + uses_embedded_fonts: Annotated[ + bool | None, + Field( + description="Indicates whether the PDF contains fully embedded fonts. " + "The result is true or false." + ), + ] = None + uses_nonembedded_fonts: Annotated[ + bool | None, + Field( + description="Indicates whether the PDF contains non-embedded fonts. The " + "result is true or false." + ), + ] = None + pdfa: Annotated[ + bool | None, + Field( + description="Indicates whether the document conforms to the PDF/A " + "standard. The result is true or false." + ), + ] = None + pdfua_claim: Annotated[ + bool | None, + Field( + description="Indicates whether the document claims to conform to the " + "PDF/UA standard. The result is true or false." + ), + ] = None + pdfe_claim: Annotated[ + bool | None, + Field( + description="Indicates whether the document claims to conform to the " + "PDF/E standard. The result is true or false." + ), + ] = None + pdfx_claim: Annotated[ + bool | None, + Field( + description="Indicates whether the document claims to conform to the " + "PDF/X standard. The result is true or false." + ), + ] = None + requires_password_to_open: Annotated[ + bool | None, + Field( + description=( + "Indicates whether the PDF requires a password to open. The result " + "is true or false. *Note*: A document requiring a password cannot be " + "opened by this route and will not provide much other information." + ) + ), + ] = None + all_queries_processed: Annotated[ + bool, + Field( + validation_alias=AliasChoices( + "all_queries_processed", "allQueriesProcessed" + ), + description=( + "Indicates whether all possible queries about the PDF document were " + "successfully processed. This field is required, and the result is " + "true or false." + ), + ), + ] + warning: Annotated[ + str | None, + Field( + description="A warning indicating why not all queries could be processed.", + ), + ] = None diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py new file mode 100644 index 00000000..038ed30c --- /dev/null +++ b/src/pdfrest/types/__init__.py @@ -0,0 +1,5 @@ +"""Public import surface for shared pdfrest types.""" + +from .public import PdfInfoQuery + +__all__ = ["PdfInfoQuery"] diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py new file mode 100644 index 00000000..149d6d41 --- /dev/null +++ b/src/pdfrest/types/public.py @@ -0,0 +1,41 @@ +"""Public type definitions for the pdfrest client.""" + +from __future__ import annotations + +from typing import Literal + +__all__ = ("PdfInfoQuery",) + +PdfInfoQuery = Literal[ + "tagged", + "image_only", + "title", + "subject", + "author", + "producer", + "creator", + "creation_date", + "modified_date", + "keywords", + "custom_metadata", + "doc_language", + "page_count", + "contains_annotations", + "contains_signature", + "pdf_version", + "file_size", + "filename", + "restrict_permissions_set", + "contains_xfa", + "contains_acroforms", + "contains_javascript", + "contains_transparency", + "contains_embedded_file", + "uses_embedded_fonts", + "uses_nonembedded_fonts", + "pdfa", + "pdfua_claim", + "pdfe_claim", + "pdfx_claim", + "requires_password_to_open", +] diff --git a/tests/live/test_live_pdf_info.py b/tests/live/test_live_pdf_info.py new file mode 100644 index 00000000..4cebdcc1 --- /dev/null +++ b/tests/live/test_live_pdf_info.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import Any, cast, get_args + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestInfoResponse +from pdfrest.models._internal import PdfInfoPayload +from pdfrest.types import PdfInfoQuery + +from ..resources import get_test_resource_path + + +def _allowed_queries() -> tuple[PdfInfoQuery, ...]: + field = PdfInfoPayload.model_fields["queries"] + (item_type,) = get_args(field.annotation) + return cast(tuple[PdfInfoQuery, ...], tuple(get_args(item_type))) + + +ALLOWED_QUERIES: tuple[PdfInfoQuery, ...] = _allowed_queries() + + +EXPECTED_VALUES: dict[PdfInfoQuery, Any] = { + "tagged": False, + "image_only": False, + "title": "", + "subject": "", + "author": "", + "producer": "", + "creator": "", + "creation_date": "", + "modified_date": "", + "keywords": "", + "custom_metadata": {}, + "doc_language": "en-US", + "page_count": 1, + "contains_annotations": False, + "contains_signature": False, + "pdf_version": "1.7.0", + "file_size": 25588, + "filename": "report.pdf", + "restrict_permissions_set": False, + "contains_xfa": False, + "contains_acroforms": False, + "contains_javascript": False, + "contains_transparency": False, + "contains_embedded_file": False, + "uses_embedded_fonts": False, + "uses_nonembedded_fonts": False, + "pdfa": False, + "pdfua_claim": False, + "pdfe_claim": False, + "pdfx_claim": False, + "requires_password_to_open": False, +} + + +def _assert_expected_value(query: PdfInfoQuery, value: Any) -> None: + assert value == EXPECTED_VALUES[query] + + +@pytest.fixture(scope="module") +def uploaded_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize("query_name", ALLOWED_QUERIES, ids=list(ALLOWED_QUERIES)) +def test_live_pdf_info_queries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf: PdfRestFile, + query_name: PdfInfoQuery, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + response = client.query_pdf_info(uploaded_pdf, queries=query_name) + + assert isinstance(response, PdfRestInfoResponse) + assert str(response.input_id) == str(uploaded_pdf.id) + assert response.all_queries_processed is True + + value = getattr(response, query_name) + _assert_expected_value(query_name, value) + + +@pytest.mark.parametrize( + "invalid_query", + [ + pytest.param("invalid_query", id="invalid-query"), + pytest.param("tagged,!!invalid!!", id="mixed-invalid"), + pytest.param("🚫", id="emoji"), + ], +) +def test_live_pdf_info_invalid_query( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf: PdfRestFile, + invalid_query: str, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client, + pytest.raises(PdfRestApiError), + ): + client.query_pdf_info( + uploaded_pdf, + queries="tagged", + extra_body={"queries": invalid_query}, + ) + + +@pytest.mark.parametrize( + "query_group", + [ + pytest.param(("tagged", "filename"), id="two-values"), + pytest.param(("page_count", "file_size", "pdf_version"), id="three-values"), + ], +) +def test_live_pdf_info_multiple_queries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf: PdfRestFile, + query_group: tuple[PdfInfoQuery, ...], +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + response = client.query_pdf_info(uploaded_pdf, queries=query_group) + + assert isinstance(response, PdfRestInfoResponse) + assert str(response.input_id) == str(uploaded_pdf.id) + assert response.all_queries_processed is True + for item in query_group: + _assert_expected_value(item, getattr(response, item)) + + +@pytest.mark.asyncio +async def test_live_pdf_info_async_all_queries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.query_pdf_info(uploaded_pdf, queries=ALLOWED_QUERIES) + + assert isinstance(response, PdfRestInfoResponse) + assert str(response.input_id) == str(uploaded_pdf.id) + assert response.all_queries_processed is True + for query in ALLOWED_QUERIES: + _assert_expected_value(query, getattr(response, query)) diff --git a/tests/test_query_pdf_info.py b/tests/test_query_pdf_info.py new file mode 100644 index 00000000..7943b5a4 --- /dev/null +++ b/tests/test_query_pdf_info.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import json +from collections.abc import Sequence + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileID, PdfRestInfoResponse +from pdfrest.types import PdfInfoQuery + +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file + + +@pytest.mark.parametrize( + ("queries_input", "expected_serialized"), + [ + pytest.param(["tagged", "page_count"], "tagged,page_count", id="list"), + pytest.param(("tagged", "page_count"), "tagged,page_count", id="tuple"), + pytest.param("tagged", "tagged", id="single"), + ], +) +def test_query_pdf_info_success( + monkeypatch: pytest.MonkeyPatch, + queries_input: Sequence[PdfInfoQuery] | PdfInfoQuery, + expected_serialized: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(str(PdfRestFileID.generate())) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method != "POST" or request.url.path != "/pdf-info": + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + payload = json.loads(request.content.decode("utf-8")) + assert payload == { + "id": str(input_file.id), + "queries": expected_serialized, + } + return httpx.Response( + 200, + json={ + "inputId": str(input_file.id), + "page_count": 2, + "title": "Example Document", + "tagged": True, + "allQueriesProcessed": True, + }, + ) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.query_pdf_info(input_file, queries=queries_input) + + assert isinstance(response, PdfRestInfoResponse) + assert response.page_count == 2 + assert response.title == "Example Document" + assert response.tagged is True + assert response.all_queries_processed is True + + +def test_query_pdf_info_request_customization(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + resource_id = PdfRestFileID.generate() + resource_file = make_pdf_file(str(resource_id)) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method != "POST" or request.url.path != "/pdf-info": + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "1" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload == { + "id": str(resource_file.id), + "queries": "tagged", + "debug": True, + } + return httpx.Response( + 200, + json={ + "inputId": str(resource_file.id), + "tagged": False, + "allQueriesProcessed": True, + }, + ) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.query_pdf_info( + resource_file, + queries="tagged", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "1"}, + extra_body={"debug": True}, + timeout=0.75, + ) + + assert isinstance(response, PdfRestInfoResponse) + assert response.tagged is False + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.75) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.75) + + +def test_query_pdf_info_requires_queries(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(str(PdfRestFileID.generate())) + transport = httpx.MockTransport( + lambda request: pytest.fail("request should not be sent") + ) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="List should have at least 1 item"), + ): + client.query_pdf_info(input_file, queries=[]) + + +def test_query_pdf_info_rejects_invalid_queries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(str(PdfRestFileID.generate())) + transport = httpx.MockTransport( + lambda request: pytest.fail("request should not be sent") + ) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match="Input should be 'tagged'", + ), + ): + client.query_pdf_info(input_file, queries=["not_a_real_query"]) # type: ignore[list-item] + + +def test_query_pdf_info_accepts_sequence(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + file_a = make_pdf_file(str(PdfRestFileID.generate())) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method != "POST" or request.url.path != "/pdf-info": + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + payload = json.loads(request.content.decode("utf-8")) + assert payload == { + "id": str(file_a.id), + "queries": "tagged,page_count", + } + return httpx.Response( + 200, + json={ + "inputId": str(file_a.id), + "tagged": True, + "page_count": 5, + "allQueriesProcessed": True, + }, + ) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.query_pdf_info([file_a], queries=("tagged", "page_count")) + + assert isinstance(response, PdfRestInfoResponse) + assert response.page_count == 5 + assert response.tagged is True + + +@pytest.mark.asyncio +async def test_async_query_pdf_info(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(str(PdfRestFileID.generate())) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method != "POST" or request.url.path != "/pdf-info": + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + payload = json.loads(request.content.decode("utf-8")) + assert payload == { + "id": str(input_file.id), + "queries": "tagged", + } + return httpx.Response( + 200, + json={ + "inputId": str(input_file.id), + "tagged": True, + "allQueriesProcessed": True, + }, + ) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.query_pdf_info(input_file, queries="tagged") + + assert isinstance(response, PdfRestInfoResponse) + assert response.tagged is True + assert response.all_queries_processed is True From 0e839c0631cfff01701dbc39b0b4c2df7bd414a6 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 5 Nov 2025 14:41:57 -0600 Subject: [PATCH 40/51] query_pdf_info: Default to all queries. - Introduced `ALL_PDF_INFO_QUERIES` constant for predefined query sets. - Updated synchronous and asynchronous `query_pdf_info` methods to use `ALL_PDF_INFO_QUERIES` as the default value for `queries`. - Adjusted type definitions and imports accordingly. - Updated tests to cover new defaults. Assisted-by: Codex --- src/pdfrest/client.py | 6 +++--- src/pdfrest/models/_validators.py | 0 src/pdfrest/types/__init__.py | 4 ++-- src/pdfrest/types/public.py | 8 +++++-- tests/live/test_live_pdf_info.py | 3 ++- tests/test_query_pdf_info.py | 35 ++++++++++++++++++++++++++++++- 6 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 src/pdfrest/models/_validators.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index cc6af526..c5c55fd7 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -45,7 +45,7 @@ TiffPdfRestPayload, UploadURLs, ) -from .types import PdfInfoQuery +from .types import ALL_PDF_INFO_QUERIES, PdfInfoQuery DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" @@ -1420,7 +1420,7 @@ def query_pdf_info( self, file: PdfRestFile | Sequence[PdfRestFile], *, - queries: Sequence[PdfInfoQuery] | PdfInfoQuery, + queries: Sequence[PdfInfoQuery] | PdfInfoQuery = ALL_PDF_INFO_QUERIES, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -1687,7 +1687,7 @@ async def query_pdf_info( self, file: PdfRestFile | Sequence[PdfRestFile], *, - queries: Sequence[PdfInfoQuery] | PdfInfoQuery, + queries: Sequence[PdfInfoQuery] | PdfInfoQuery = ALL_PDF_INFO_QUERIES, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, diff --git a/src/pdfrest/models/_validators.py b/src/pdfrest/models/_validators.py new file mode 100644 index 00000000..e69de29b diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 038ed30c..0efbb167 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -1,5 +1,5 @@ """Public import surface for shared pdfrest types.""" -from .public import PdfInfoQuery +from .public import ALL_PDF_INFO_QUERIES, PdfInfoQuery -__all__ = ["PdfInfoQuery"] +__all__ = ["ALL_PDF_INFO_QUERIES", "PdfInfoQuery"] diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 149d6d41..492a7856 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -2,9 +2,9 @@ from __future__ import annotations -from typing import Literal +from typing import Literal, cast, get_args -__all__ = ("PdfInfoQuery",) +__all__ = ("ALL_PDF_INFO_QUERIES", "PdfInfoQuery") PdfInfoQuery = Literal[ "tagged", @@ -39,3 +39,7 @@ "pdfx_claim", "requires_password_to_open", ] + +ALL_PDF_INFO_QUERIES: tuple[PdfInfoQuery, ...] = cast( + tuple[PdfInfoQuery, ...], get_args(PdfInfoQuery) +) diff --git a/tests/live/test_live_pdf_info.py b/tests/live/test_live_pdf_info.py index 4cebdcc1..977fe87d 100644 --- a/tests/live/test_live_pdf_info.py +++ b/tests/live/test_live_pdf_info.py @@ -7,7 +7,7 @@ from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile, PdfRestInfoResponse from pdfrest.models._internal import PdfInfoPayload -from pdfrest.types import PdfInfoQuery +from pdfrest.types import ALL_PDF_INFO_QUERIES, PdfInfoQuery from ..resources import get_test_resource_path @@ -19,6 +19,7 @@ def _allowed_queries() -> tuple[PdfInfoQuery, ...]: ALLOWED_QUERIES: tuple[PdfInfoQuery, ...] = _allowed_queries() +assert ALLOWED_QUERIES == ALL_PDF_INFO_QUERIES EXPECTED_VALUES: dict[PdfInfoQuery, Any] = { diff --git a/tests/test_query_pdf_info.py b/tests/test_query_pdf_info.py index 7943b5a4..8e4fe885 100644 --- a/tests/test_query_pdf_info.py +++ b/tests/test_query_pdf_info.py @@ -9,7 +9,7 @@ from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models import PdfRestFileID, PdfRestInfoResponse -from pdfrest.types import PdfInfoQuery +from pdfrest.types import ALL_PDF_INFO_QUERIES, PdfInfoQuery from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file @@ -61,6 +61,39 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.all_queries_processed is True +def test_query_pdf_info_default_queries(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(str(PdfRestFileID.generate())) + expected_serialized = ",".join(ALL_PDF_INFO_QUERIES) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method != "POST" or request.url.path != "/pdf-info": + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + payload = json.loads(request.content.decode("utf-8")) + assert payload == { + "id": str(input_file.id), + "queries": expected_serialized, + } + return httpx.Response( + 200, + json={ + "inputId": str(input_file.id), + "page_count": 1, + "tagged": False, + "allQueriesProcessed": True, + }, + ) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.query_pdf_info(input_file) + + assert isinstance(response, PdfRestInfoResponse) + assert response.page_count == 1 + assert response.tagged is False + + def test_query_pdf_info_request_customization(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) resource_id = PdfRestFileID.generate() From 470897e796bac15ae8f0bd4e39a6e54e5e132819 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 6 Nov 2025 10:23:53 -0600 Subject: [PATCH 41/51] PdfInfoPayload: Add missing mime-type check - Files must be PDF. --- src/pdfrest/models/_internal.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 3fb51b54..a12ab207 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -195,6 +195,9 @@ class PdfInfoPayload(BaseModel): serialization_alias="id", ), BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), PlainSerializer(_serialize_as_first_file_id), ] queries: Annotated[ From a47208737cd77ebb487bed1104138032f10c074a Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 6 Nov 2025 10:25:38 -0600 Subject: [PATCH 42/51] Add redaction support - Introduced `preview_redactions` and `apply_redactions` methods for generating redaction previews and applying redactions in both sync and async clients. - Added `PdfRedactionPreviewPayload` and `PdfRedactionApplyPayload` models for payload validation. - Implemented new redaction-related types: `PdfRedactionInstruction`, `PdfRedactionPreset`, `PdfRedactionType`, and `PdfRGBColor`. - Centralized file operation handling via `_post_file_operation` for shared logic across clients. Assisted-by: Codex --- src/pdfrest/client.py | 313 +++++++++++++++++++++++++------- src/pdfrest/models/_internal.py | 105 ++++++++++- src/pdfrest/types/__init__.py | 18 +- src/pdfrest/types/public.py | 36 +++- 4 files changed, 401 insertions(+), 71 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index c5c55fd7..6f257168 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -40,12 +40,19 @@ GifPdfRestPayload, JpegPdfRestPayload, PdfInfoPayload, + PdfRedactionApplyPayload, + PdfRedactionPreviewPayload, PdfRestRawFileResponse, PngPdfRestPayload, TiffPdfRestPayload, UploadURLs, ) -from .types import ALL_PDF_INFO_QUERIES, PdfInfoQuery +from .types import ( + ALL_PDF_INFO_QUERIES, + PdfInfoQuery, + PdfRedactionInstruction, + PdfRGBColor, +) DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" @@ -598,6 +605,54 @@ def _send_request(self, request: _RequestModel) -> Any: raise translate_httpx_error(exc) from exc return self._handle_response(response) + def _post_file_operation( + self, + *, + endpoint: str, + payload: dict[str, Any], + payload_model: type[BaseModel], + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + job_options = payload_model.model_validate(payload) + json_body = job_options.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + request = self.prepare_request( + "POST", + endpoint, + json_body=json_body, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._send_request(request) + raw_response = PdfRestRawFileResponse.model_validate(raw_payload) + + output_ids = raw_response.ids or [] + output_files = [ + self.fetch_file_info( + str(file_id), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + for file_id in output_ids + ] + + return PdfRestFileBasedResponse.model_validate( + { + "input_id": [str(file_id) for file_id in raw_response.input_id], + "output_file": [ + file.model_dump(mode="json", by_alias=True) for file in output_files + ], + "warning": raw_response.warning, + } + ) + def send_request(self, request: _RequestModel) -> Any: return self._send_request(request) @@ -674,6 +729,7 @@ def __init__( headers: AnyMapping | None = None, http_client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, + concurrency_limit: int = DEFAULT_FILE_INFO_CONCURRENCY, ) -> None: super().__init__( api_key=api_key, @@ -688,6 +744,7 @@ def __init__( timeout=self._config.timeout, transport=transport, ) + self._concurrency_limit = concurrency_limit async def aclose(self) -> None: if self._owns_http_client: @@ -716,6 +773,62 @@ async def _send_request(self, request: _RequestModel) -> Any: raise translate_httpx_error(exc) from exc return self._handle_response(response) + async def _post_file_operation( + self, + *, + endpoint: str, + payload: dict[str, Any], + payload_model: type[BaseModel], + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + job_options = payload_model.model_validate(payload) + request = self.prepare_request( + "POST", + endpoint, + json_body=job_options.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._send_request(request) + raw_response = PdfRestRawFileResponse.model_validate(raw_payload) + + output_ids = raw_response.ids or [] + output_files: list[PdfRestFile] = [] + semaphore = asyncio.Semaphore(self._concurrency_limit) + + async def throttled_fetch_file_info(file_id: str) -> PdfRestFile: + async with semaphore: + return await self.fetch_file_info( + str(file_id), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + + if output_ids: + output_files = list( + await asyncio.gather( + *(throttled_fetch_file_info(str(file_id)) for file_id in output_ids) + ) + ) + + return PdfRestFileBasedResponse.model_validate( + { + "input_id": [str(file_id) for file_id in raw_response.input_id], + "output_file": [ + file.model_dump(mode="json", by_alias=True) for file in output_files + ], + "warning": raw_response.warning, + } + ) + async def send_request(self, request: _RequestModel) -> Any: return await self._send_request(request) @@ -1380,41 +1493,15 @@ def _convert_to_graphic( extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - conversion_options = payload_model.model_validate(payload) - request = self.prepare_request( - "POST", - endpoint, - json_body=conversion_options.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return self._post_file_operation( + endpoint=endpoint, + payload=payload, + payload_model=payload_model, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = self._send_request(request) - raw_response = PdfRestRawFileResponse.model_validate(raw_payload) - - output_ids = raw_response.ids or [] - output_files = [ - self.fetch_file_info( - str(file_id), - extra_query=extra_query, - extra_headers=extra_headers, - timeout=timeout, - ) - for file_id in output_ids - ] - - return PdfRestFileBasedResponse.model_validate( - { - "input_id": [str(file_id) for file_id in raw_response.input_id], - "output_file": [ - file.model_dump(mode="json", by_alias=True) for file in output_files - ], - "warning": raw_response.warning, - } - ) def query_pdf_info( self, @@ -1443,6 +1530,67 @@ def query_pdf_info( raw_payload = self._send_request(request) return PdfRestInfoResponse.model_validate(raw_payload) + def preview_redactions( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + redactions: PdfRedactionInstruction | Sequence[PdfRedactionInstruction], + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Generate a PDF redaction preview with annotated redaction rectangles.""" + + payload: dict[str, Any] = { + "files": file, + "redactions": redactions, + } + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-redacted-text-preview", + payload=payload, + payload_model=PdfRedactionPreviewPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def apply_redactions( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + rgb_color: PdfRGBColor | Sequence[int] | None = None, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Apply previously previewed redactions and return the final redacted PDF.""" + + payload: dict[str, Any] = { + "files": file, + } + if rgb_color is not None: + payload["rgb_color"] = rgb_color + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-redacted-text-applied", + payload=payload, + payload_model=PdfRedactionApplyPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_png( self, files: PdfRestFile | Sequence[PdfRestFile], @@ -1659,6 +1807,7 @@ def __init__( headers: AnyMapping | None = None, http_client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, + concurrency_limit: int = DEFAULT_FILE_INFO_CONCURRENCY, ) -> None: """Create an asynchronous pdfRest client.""" @@ -1669,6 +1818,7 @@ def __init__( headers=headers, http_client=http_client, transport=transport, + concurrency_limit=concurrency_limit, ) self._files_client = _AsyncFilesClient(self) @@ -1710,6 +1860,67 @@ async def query_pdf_info( raw_payload = await self._send_request(request) return PdfRestInfoResponse.model_validate(raw_payload) + async def preview_redactions( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + redactions: PdfRedactionInstruction | Sequence[PdfRedactionInstruction], + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously generate a PDF redaction preview.""" + + payload: dict[str, Any] = { + "files": file, + "redactions": redactions, + } + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-redacted-text-preview", + payload=payload, + payload_model=PdfRedactionPreviewPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def apply_redactions( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + rgb_color: PdfRGBColor | Sequence[int] | None = None, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously apply PDF redactions.""" + + payload: dict[str, Any] = { + "files": file, + } + if rgb_color is not None: + payload["rgb_color"] = rgb_color + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-redacted-text-applied", + payload=payload, + payload_model=PdfRedactionApplyPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def up( self, *, @@ -1742,47 +1953,15 @@ async def _convert_to_graphic( extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - conversion_options = payload_model.model_validate(payload) - request = self.prepare_request( - "POST", - endpoint, - json_body=conversion_options.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return await self._post_file_operation( + endpoint=endpoint, + payload=payload, + payload_model=payload_model, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = await self._send_request(request) - raw_response = PdfRestRawFileResponse.model_validate(raw_payload) - - output_ids = raw_response.ids or [] - output_files: list[PdfRestFile] = [] - if output_ids: - output_files = list( - await asyncio.gather( - *( - self.fetch_file_info( - str(file_id), - extra_query=extra_query, - extra_headers=extra_headers, - timeout=timeout, - ) - for file_id in output_ids - ) - ) - ) - - return PdfRestFileBasedResponse.model_validate( - { - "input_id": [str(file_id) for file_id in raw_response.input_id], - "output_file": [ - file.model_dump(mode="json", by_alias=True) for file in output_files - ], - "warning": raw_response.warning, - } - ) async def convert_to_png( self, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index a12ab207..94537310 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import re from collections.abc import Callable, Sequence from pathlib import PurePath @@ -17,6 +18,8 @@ model_validator, ) +from pdfrest.types.public import PdfRedactionPreset + from ..types import PdfInfoQuery from . import PdfRestFile from .public import PdfRestFileID @@ -127,6 +130,17 @@ def _split_comma_list(value: Any) -> Any: raise ValueError(msg) +def _split_comma_string(value: Any) -> list[Any] | None: + if value is None: + return None + if isinstance(value, str): + return value.split(",") + if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)): + return list(value) + msg = "Must be a list, or a comma separated string." + raise ValueError(msg) + + def _pdfrest_file_to_id(value: Any) -> Any: if isinstance(value, PdfRestFile): return value.id @@ -140,12 +154,17 @@ def _serialize_as_first_file_id(value: list[PdfRestFile]) -> str: def _serialize_as_comma_separated_string(value: list[Any] | None) -> str | None: if value is None: return None - return ",".join(value) + return ",".join(str(element) for element in value) PageRangeEntry = Annotated[str, AfterValidator(_validate_page_range_entry)] +def _serialize_redactions(value: list[_PdfRedactionVariant]) -> str: + payload = [entry.model_dump(mode="json", exclude_none=True) for entry in value] + return json.dumps(payload, separators=(",", ":")) + + def _allowed_mime_types( allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None ) -> Callable[[Any], Any]: @@ -209,6 +228,90 @@ class PdfInfoPayload(BaseModel): ] +RgbChannel = Annotated[int, Field(ge=0, le=255)] + + +class PdfLiteralRedactionModel(BaseModel): + type: Literal["literal"] + value: Annotated[str, Field(min_length=1)] + + +class PdfRegexRedactionModel(BaseModel): + type: Literal["regex"] + value: Annotated[str, Field(min_length=1)] + + +class PdfPresetRedactionModel(BaseModel): + type: Literal["preset"] + value: PdfRedactionPreset + + +_PdfRedactionVariant = Annotated[ + PdfLiteralRedactionModel | PdfRegexRedactionModel | PdfPresetRedactionModel, + Field(discriminator="type"), +] + + +class PdfRedactionPreviewPayload(BaseModel): + """Adapt caller options into a pdfRest-compatible redaction preview request.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + redactions: Annotated[ + list[_PdfRedactionVariant], + Field(min_length=1), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_redactions), + ] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + +class PdfRedactionApplyPayload(BaseModel): + """Adapt caller options into a pdfRest-compatible redaction application request.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + rgb_color: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field(serialization_alias="rgb_color", default=None), + BeforeValidator(_split_comma_string), + PlainSerializer(_serialize_as_comma_separated_string), + ] = None + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + ColorModelT = TypeVar("ColorModelT", bound=str) diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 0efbb167..d2e54009 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -1,5 +1,19 @@ """Public import surface for shared pdfrest types.""" -from .public import ALL_PDF_INFO_QUERIES, PdfInfoQuery +from .public import ( + ALL_PDF_INFO_QUERIES, + PdfInfoQuery, + PdfRedactionInstruction, + PdfRedactionPreset, + PdfRedactionType, + PdfRGBColor, +) -__all__ = ["ALL_PDF_INFO_QUERIES", "PdfInfoQuery"] +__all__ = [ + "ALL_PDF_INFO_QUERIES", + "PdfInfoQuery", + "PdfRGBColor", + "PdfRedactionInstruction", + "PdfRedactionPreset", + "PdfRedactionType", +] diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 492a7856..c9528d5f 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -4,7 +4,16 @@ from typing import Literal, cast, get_args -__all__ = ("ALL_PDF_INFO_QUERIES", "PdfInfoQuery") +from typing_extensions import TypedDict + +__all__ = ( + "ALL_PDF_INFO_QUERIES", + "PdfInfoQuery", + "PdfRGBColor", + "PdfRedactionInstruction", + "PdfRedactionPreset", + "PdfRedactionType", +) PdfInfoQuery = Literal[ "tagged", @@ -43,3 +52,28 @@ ALL_PDF_INFO_QUERIES: tuple[PdfInfoQuery, ...] = cast( tuple[PdfInfoQuery, ...], get_args(PdfInfoQuery) ) + +PdfRedactionType = Literal["literal", "regex", "preset"] + +PdfRedactionPreset = Literal[ + "email", + "phone_number", + "date", + "us_ssn", + "url", + "credit_card", + "credit_debit_pin", + "bank_routing_number", + "international_bank_account_number", + "swift_bic_number", + "ipv4", + "ipv6", +] + + +class PdfRedactionInstruction(TypedDict): + type: PdfRedactionType + value: PdfRedactionPreset | str + + +PdfRGBColor = tuple[int, int, int] From b04ef14db91e79a2846e9b02f707bd04e7245a6f Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 6 Nov 2025 10:29:22 -0600 Subject: [PATCH 43/51] Add tests for PDF redaction features - Introduced live tests for `preview_redactions` and `apply_redactions` to validate redaction workflows. - Added unit tests for `PdfRedactionPreviewPayload` and `PdfRedactionApplyPayload` models, ensuring accurate payload validation. - Verified handling of invalid inputs, including incorrect redaction instructions and color values. - Updated test fixtures and utilities for live and isolated testing scenarios. Assisted-by: Codex --- tests/live/test_live_pdf_redactions.py | 169 +++++++++++++++++++++++++ tests/resources/redactable-text.pdf | Bin 0 -> 1083189 bytes tests/test_pdf_redaction_apply.py | 89 +++++++++++++ tests/test_pdf_redaction_preview.py | 125 ++++++++++++++++++ 4 files changed, 383 insertions(+) create mode 100644 tests/live/test_live_pdf_redactions.py create mode 100644 tests/resources/redactable-text.pdf create mode 100644 tests/test_pdf_redaction_apply.py create mode 100644 tests/test_pdf_redaction_preview.py diff --git a/tests/live/test_live_pdf_redactions.py b/tests/live/test_live_pdf_redactions.py new file mode 100644 index 00000000..796785a1 --- /dev/null +++ b/tests/live/test_live_pdf_redactions.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from typing import get_args + +import pytest + +from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile +from pdfrest.types import PdfRedactionInstruction, PdfRedactionPreset + +from ..resources import get_test_resource_path + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_redaction( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("redactable-text.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize( + "instruction", + [ + pytest.param( + { + "type": "literal", + "value": "The quick brown fox jumped over the lazy dog.", + }, + id="literal", + ), + pytest.param({"type": "regex", "value": r"\b\d{3}-\d{2}-\d{4}\b"}, id="regex"), + *[ + pytest.param({"type": "preset", "value": preset}, id=f"preset-{preset}") + for preset in get_args(PdfRedactionPreset) + ], + ], +) +def test_live_redaction_preview_and_apply_single( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_redaction: PdfRestFile, + instruction: PdfRedactionInstruction, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + preview = client.preview_redactions( + uploaded_pdf_for_redaction, + redactions=[instruction], + output="redaction-preview", + ) + + assert preview.output_files + preview_file = preview.output_files[0] + assert preview_file.name.endswith("redaction-preview.pdf") + assert preview_file.type == "application/pdf" + + applied = client.apply_redactions( + preview_file, + output="redaction-final", + ) + + assert applied.output_files + final_file = applied.output_files[0] + assert final_file.name.endswith("redaction-final.pdf") + assert final_file.type == "application/pdf" + + +@pytest.mark.parametrize( + "instructions", + [ + pytest.param( + [ + { + "type": "literal", + "value": "The quick brown fox jumped over the lazy dog.", + }, + {"type": "regex", "value": r"\b\d{3}-\d{2}-\d{4}\b"}, + ], + id="literal-and-regex", + ), + pytest.param( + [ + {"type": "preset", "value": "email"}, + {"type": "preset", "value": "phone_number"}, + ], + id="preset-email-and-phone", + ), + pytest.param( + [ + {"type": "preset", "value": "credit_card"}, + {"type": "preset", "value": "bank_routing_number"}, + {"type": "preset", "value": "swift_bic_number"}, + ], + id="multiple-presets", + ), + ], +) +def test_live_redaction_preview_and_apply_multiple( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_redaction: PdfRestFile, + instructions: list[PdfRedactionInstruction], +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + preview = client.preview_redactions( + uploaded_pdf_for_redaction, + redactions=instructions, + output="redaction-preview-multi", + ) + + assert preview.output_files + preview_file = preview.output_files[0] + assert preview_file.name.endswith("redaction-preview-multi.pdf") + assert preview_file.type == "application/pdf" + + applied = client.apply_redactions( + preview_file, + output="redaction-final-multi", + ) + + assert applied.output_files + final_file = applied.output_files[0] + assert final_file.name.endswith("redaction-final-multi.pdf") + assert final_file.type == "application/pdf" + + +@pytest.mark.parametrize( + "extra_body", + [ + pytest.param({"redactions": "invalid"}, id="invalid-redactions"), + pytest.param({"rgb_color": "-1,-1,-1"}, id="invalid-rgb"), + ], +) +def test_live_redactions_invalid_payloads( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_redaction: PdfRestFile, + extra_body: dict[str, object], +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + if "redactions" in extra_body: + with pytest.raises(PdfRestApiError): + client.preview_redactions( + uploaded_pdf_for_redaction, + redactions=[{"type": "literal", "value": "placeholder"}], + extra_body=extra_body, + ) + else: + preview = client.preview_redactions( + uploaded_pdf_for_redaction, + redactions=[{"type": "literal", "value": "placeholder"}], + ) + preview_file = preview.output_files[0] + with pytest.raises(PdfRestApiError): + client.apply_redactions(preview_file, extra_body=extra_body) diff --git a/tests/resources/redactable-text.pdf b/tests/resources/redactable-text.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b53e29507b537405d151003b8321b660e300dec4 GIT binary patch literal 1083189 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

x@*y)WMapFZBp(-jl>|E_{%gjp@{ruCvzDkzyfp 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 z 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 z%0A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

%L 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 z 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

%Dc<0{ahPvYs?0XM=SbvYvEHL~i!#9JN#}&S} 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 z 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 None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + payload_data: dict[str, object] = {"files": [input_file]} + if rgb_color is not None: + payload_data["rgb_color"] = rgb_color + payload_data["output"] = "final-output" + + payload_model_dump = PdfRedactionApplyPayload.model_validate( + payload_data + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + def handler(request: httpx.Request) -> httpx.Response: + if ( + request.method == "POST" + and request.url.path == "/pdf-with-redacted-text-applied" + ): + body = json.loads(request.content.decode("utf-8")) + assert body == payload_model_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "final-output.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.apply_redactions( + input_file, + rgb_color=rgb_color, + output="final-output", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files[0].name == "final-output.pdf" + assert response.output_files[0].type == "application/pdf" + + +def test_apply_redactions_invalid_color(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Field required"): + client.apply_redactions(input_file, rgb_color=[255, 255]) + + with pytest.raises(ValidationError, match="greater than or equal to 0"): + client.apply_redactions(input_file, rgb_color=[-1, 0, 0]) diff --git a/tests/test_pdf_redaction_preview.py b/tests/test_pdf_redaction_preview.py new file mode 100644 index 00000000..909d35c4 --- /dev/null +++ b/tests/test_pdf_redaction_preview.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfRedactionPreviewPayload +from pdfrest.types import PdfRedactionInstruction + +from .graphics_test_helpers import VALID_API_KEY, build_file_info_payload, make_pdf_file + + +@pytest.mark.parametrize( + "redactions", + [ + pytest.param( + [ + {"type": "literal", "value": "Sensitive"}, + {"type": "preset", "value": "email"}, + ], + id="list", + ), + pytest.param({"type": "regex", "value": "\\d{3}-\\d{2}-\\d{4}"}, id="single"), + ], +) +def test_preview_redactions_success( + monkeypatch: pytest.MonkeyPatch, + redactions: PdfRedactionInstruction | list[PdfRedactionInstruction], +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + payload_model_dump = PdfRedactionPreviewPayload.model_validate( + { + "files": [input_file], + "redactions": redactions, + "output": "preview-output", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + def handler(request: httpx.Request) -> httpx.Response: + if ( + request.method == "POST" + and request.url.path == "/pdf-with-redacted-text-preview" + ): + body = json.loads(request.content.decode("utf-8")) + assert body == payload_model_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "preview-output.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.preview_redactions( + input_file, + redactions=redactions, + output="preview-output", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert str(response.input_id) == str(input_file.id) + assert response.output_files[0].name == "preview-output.pdf" + assert response.output_files[0].type == "application/pdf" + assert response.warning is None + + +def test_preview_redactions_invalid_preset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Input should be 'email'"), + ): + client.preview_redactions( + input_file, + redactions=[{"type": "preset", "value": "unknown"}], + ) + + +def test_preview_redactions_reject_json_string(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="valid dictionary"), + ): + client.preview_redactions( + input_file, + redactions=json.dumps([{"type": "literal", "value": "secret"}]), # type: ignore[arg-type] + ) + + +def test_preview_redactions_requires_instruction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="at least 1 item"), + ): + client.preview_redactions(input_file, redactions=[]) From b473f1efce83373494437dd19a4b79b50ba3e26e Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 6 Nov 2025 11:04:41 -0600 Subject: [PATCH 44/51] Agents.md: More guidelines for validation/serialization - Enhanced the AGENTS.md with redaction-related guidelines and reusable patterns for validation and serialization. --- AGENTS.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a6a8657b..996c35cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,13 +57,34 @@ `async with AsyncPdfRestClient(...)`), nest any synchronous companions such as `pytest.raises` inside the async block—Python forbids mixing `async with` and regular `with` clauses in the same statement. When working with `HttpUrl` - objects, cast to `str` before string operations such as suffix checks. + objects, cast to `str` before string operations such as suffix checks. *When + using `pytest.raises`, prefer combining it into the same `with` clause as + another synchronous context manager when semantics allow.* - For image conversions, adapt request data with `BasePdfRestGraphicPayload` generics; name concrete payloads `BmpPdfRestPayload`, `GifPdfRestPayload`, `JpegPdfRestPayload`, `PngPdfRestPayload`, and `TiffPdfRestPayload`. Client helpers should accept a `payload_model` argument and use fully spelled-out method names such as `convert_to_jpeg`/`convert_to_tiff` (avoid historic three-letter suffixes). +- Define reusable literals and simple aliases under `src/pdfrest/types/` and + import them from `pdfrest.types` (e.g., `PdfInfoQuery`) instead of reaching + into underscored modules. Treat that package as the public surface for shared + type contracts consumed by both clients and tests. +- Payload models that reference uploaded resources should accept + `list[PdfRestFile]` with explicit length bounds and serialize IDs for the + allowed cardinality (`serialization_alias="id"` plus a serializer that emits + either the first id when `max_length == 1` or a list when larger). Client + helpers should pass sequences through without converting to raw IDs manually. +- When a payload accepts uploaded content, validate MIME types via + `_allowed_mime_types` to surface clear errors before making the request. +- When an endpoint expects JSON-encoded structures (e.g., arrays of redaction + rules), expose typed arguments (TypedDicts, Literals, etc.) via + `pdfrest.types` and let the payload serializer produce the JSON string for the + request body. +- Client helpers that consume existing resources must accept `PdfRestFile` + instances (optionally sequences) rather than raw IDs or strings; use the + `files` client helpers to resolve file IDs before invoking conversion or + metadata routes. - When adding new services, provide per-endpoint test modules mirroring PNG’s coverage: parameterized successes for every allowed literal value, request customization (sync + async), validation failures, and multi-file guards. Add @@ -86,6 +107,8 @@ - For parameterized tests prefer `pytest.param(..., id="short-label")` so test IDs stay readable; make assertions for every relevant response attribute (name prefix, MIME type, size, URLs, warnings). +- Avoid manual loops over test parameters; prefer `@pytest.mark.parametrize` + with explicit `id=` values so each combination is visible and reproducible. - Always couple `pytest.raises` with an explicit `match=` regex that reflects the intended validation error wording—mirror the human-readable text rather than relying on default exception formatting. @@ -120,7 +143,13 @@ pdfRest raises errors for out-of-range or unsupported values. When bypassing local validation to reach the server (e.g., for negative tests), inject the override via `extra_body` and expect `PdfRestApiError` (or the precise - exception surfaced by the client). + exception surfaced by the client). When test fixtures produce deterministic + results (e.g., `tests/resources/report.pdf`), assert the concrete values + returned by pdfRest rather than only checking for presence or type. +- Developers can load a pdfRest API key from `.env` during ad-hoc exploration. + The repo includes `python-dotenv`; call `load_dotenv()` (optionally pointing + to `.env`) in temporary scripts to drive the in-flight client against live + endpoints and capture responses for test data and assertions. ## Commit & Pull Request Guidelines From 198719dc5a9412804d509cd781f5d453fa7c8e4e Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 6 Nov 2025 12:35:31 -0600 Subject: [PATCH 45/51] noxfile: Add additional args, support no-parallel - Pass positional arguments to pytest - --no-parallel makes tests not run in parallel - manually handle -n and add maxschedchunk --- noxfile.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/noxfile.py b/noxfile.py index e312dc0a..dd7ed111 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,3 +1,5 @@ +import argparse + import nox nox.options.default_venv_backend = "uv" @@ -7,6 +9,24 @@ @nox.session(name="tests", python=python_versions, reuse_venv=True) def tests(session: nox.Session) -> None: + # Define only custom flags + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--no-parallel", action="store_true") + parser.add_argument( + "-n", "--workers", "--numprocesses" + ) # e.g., -n 4 to set workers + custom, remaining = parser.parse_known_args(session.posargs) + + pytest_args = list(remaining) + + # Default to parallel unless disabled or overridden + if custom.no_parallel: + pass + elif custom.workers: + pytest_args[:0] = ["-n", custom.workers, "--maxschedchunk", "2"] + else: + pytest_args[:0] = ["-n", "8", "--maxschedchunk", "2"] + session.run_install( "uv", "sync", @@ -20,8 +40,5 @@ def tests(session: nox.Session) -> None: "pytest", "--cov=pdfrest", "--cov-report=term-missing", - "--numprocesses", - "8", - "--maxschedchunk", - "2", + *pytest_args, ) From 34ad67cd26b1a7ae1f95681bc67bcaaccfb49d5e Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 6 Nov 2025 12:36:37 -0600 Subject: [PATCH 46/51] test-and-publish: Don't run tests in parallel in CI --- .github/workflows/test-and-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index fcdd7acd..ead3bbb9 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -35,7 +35,7 @@ jobs: cache-suffix: test-and-publish cache-dependency-glob: uv.lock - name: Run tests with nox - run: uvx nox --python ${{ matrix.python-version }} --session tests + run: uvx nox --python ${{ matrix.python-version }} --session tests -- --no-parallel env: PDFREST_API_KEY: ${{ secrets.PDFREST_API_KEY }} From a1f6a110279262f153632c15d4a43971ef97ef51 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 6 Nov 2025 12:47:55 -0600 Subject: [PATCH 47/51] Only live-test resolution bounds with PNG - Use only one graphic type to save on testing time; they all have the same limits. --- tests/live/test_live_graphic_conversions.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 46269233..62b9045c 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -23,8 +23,12 @@ class _GraphicEndpointSpec(NamedTuple): payload_model: type[BasePdfRestGraphicPayload[Any]] -PAYLOAD_MODELS: dict[str, _GraphicEndpointSpec] = { +PNG_PAYLOAD_ONLY: dict[str, _GraphicEndpointSpec] = { "png": _GraphicEndpointSpec("convert_to_png", PngPdfRestPayload), +} + +PAYLOAD_MODELS: dict[str, _GraphicEndpointSpec] = { + **PNG_PAYLOAD_ONLY, "bmp": _GraphicEndpointSpec("convert_to_bmp", BmpPdfRestPayload), "gif": _GraphicEndpointSpec("convert_to_gif", GifPdfRestPayload), "jpeg": _GraphicEndpointSpec("convert_to_jpeg", JpegPdfRestPayload), @@ -160,8 +164,8 @@ def test_live_graphic_invalid_color_model( @pytest.mark.parametrize( ("_endpoint_label", "spec"), - PAYLOAD_MODELS.items(), - ids=list(PAYLOAD_MODELS), + PNG_PAYLOAD_ONLY.items(), + ids=list(PNG_PAYLOAD_ONLY), ) @pytest.mark.parametrize( ("bound", "offset", "should_raise"), From 3f8ca74e2b6ef79c18dea3d6c42d4a75c1980821 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 10 Nov 2025 10:41:10 -0600 Subject: [PATCH 48/51] Refactor page range validation and serialization - Use Pydantic models to decompose and validate page ranges rather than longer bespoke parsing code. - Simplified page range handling by removing `_require_positive_page` and `_validate_page_range_entry` in favor of new `AscendingPageRange` and related validators. - Replaced `PageRangeEntry` with `AscendingPageRange` in `BasePdfRestGraphicPayload`. - Enhanced serialization logic with `_serialize_page_ranges`. - Updated supporting type definitions and removed redundant code. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 83 ++++++++++++++------------------- 1 file changed, 36 insertions(+), 47 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 94537310..7e12a103 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -75,50 +75,6 @@ def _validate_output_prefix(value: str | None) -> str | None: return value -def _require_positive_page( - text: str, *, description: str, require_page_word: bool = False -) -> str: - if not text.isdigit() or int(text) < 1: - message = ( - f"{description} must be a page number greater than or equal to 1." - if require_page_word - else f"{description} must be greater than or equal to 1." - ) - raise ValueError(message) - return text - - -def _validate_page_range_entry(value: str) -> str: - """Normalize and validate a single page range entry.""" - if not isinstance(value, str): - msg = "Each page range entry must be a string." - raise TypeError(msg) - entry = value.strip() - if entry == "": - msg = "Each page range entry must be a non-empty string." - raise ValueError(msg) - if entry == "last": - return entry - if entry.isdigit(): - return _require_positive_page(entry, description="Page numbers") - if "-" in entry: - start_raw, end_raw = (part.strip() for part in entry.split("-", maxsplit=1)) - start = _require_positive_page( - start_raw, description="Page range start", require_page_word=True - ) - if end_raw == "last": - return f"{start}-last" - end = _require_positive_page( - end_raw, description="Page range end", require_page_word=True - ) - if int(end) < int(start): - msg = "Page range end must be greater than or equal to the start." - raise ValueError(msg) - return f"{start}-{end}" - msg = "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'." - raise ValueError(msg) - - def _split_comma_list(value: Any) -> Any: if isinstance(value, str): return value.split(",") @@ -157,7 +113,13 @@ def _serialize_as_comma_separated_string(value: list[Any] | None) -> str | None: return ",".join(str(element) for element in value) -PageRangeEntry = Annotated[str, AfterValidator(_validate_page_range_entry)] +def _serialize_page_ranges(value: list[str | int | tuple[str | int, ...]]) -> str: + def join_tuple(value: str | int | tuple[str | int, ...]) -> str: + if isinstance(value, tuple): + return "-".join(str(e) for e in value) + return str(value) + + return ",".join(join_tuple(v) for v in value) def _serialize_redactions(value: list[_PdfRedactionVariant]) -> str: @@ -202,6 +164,33 @@ class UploadURLs(BaseModel): ] +PageNumber = Annotated[int, Field(ge=1), PlainSerializer(lambda x: str(x))] + + +def _split_page_range_tuple(x: str) -> tuple[str, str]: + start, end = x.split("-", maxsplit=1) + return start, end + + +def _ascending_page_range( + range: tuple[int, int | Literal["last"]], +) -> tuple[int, int | Literal["last"]]: + start, end = range + if end != "last" and int(start) > int(end): + msg = "The start page must be less than or equal to the end page." + raise ValueError(msg) + return range + + +_AscendingPageRangeTuple = Annotated[ + tuple[PageNumber, PageNumber] | tuple[PageNumber, Literal["last"]], + BeforeValidator(_split_page_range_tuple), + AfterValidator(_ascending_page_range), +] + +AscendingPageRange = PageNumber | Literal["last"] | _AscendingPageRangeTuple + + class PdfInfoPayload(BaseModel): """Adapt caller options into a pdfRest-ready pdf-info request payload.""" @@ -336,12 +325,12 @@ class BasePdfRestGraphicPayload(BaseModel, Generic[ColorModelT]): AfterValidator(_validate_output_prefix), ] page_range: Annotated[ - list[PageRangeEntry] | None, + list[AscendingPageRange] | None, Field(serialization_alias="pages", min_length=1, default=None), BeforeValidator(_ensure_list), BeforeValidator(_split_comma_list), BeforeValidator(_int_to_string), - PlainSerializer(_serialize_as_comma_separated_string), + PlainSerializer(_serialize_page_ranges), ] resolution: Annotated[int, Field(ge=12, le=2400, default=300)] color_model: Annotated[ColorModelT, Field(default=...)] From 01fd6d5d628068cd34c9e345ffd91a9c4837a1a3 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 10 Nov 2025 10:44:25 -0600 Subject: [PATCH 49/51] Add tests for PNG conversion with page ranges and invalid inputs - More thoroughly test page ranges in light of new changes. - Introduced new live tests to validate PNG conversion with various page range formats, including valid and invalid cases. - Added coverage for page range parsing and error scenarios. - Updated fixtures to support new tests with a 20-page PDF resource. Assisted-by: Codex --- tests/live/test_live_graphic_conversions.py | 143 +++++++++++++++++++- tests/resources/20-pages.pdf | Bin 0 -> 21823 bytes tests/test_graphic_payload_validation.py | 62 ++++++++- 3 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 tests/resources/20-pages.pdf diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 62b9045c..2b68edb3 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -1,11 +1,12 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from typing import Any, NamedTuple, get_args import pytest from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile from pdfrest.models._internal import ( BasePdfRestGraphicPayload, BmpPdfRestPayload, @@ -107,6 +108,19 @@ def _invalid_smoothing_cases() -> list[Any]: return cases +@pytest.fixture(scope="module") +def uploaded_20_page_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("20-pages.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + @pytest.mark.parametrize( ("_endpoint_label", "spec", "color_model"), _valid_color_cases(), @@ -253,3 +267,130 @@ def test_live_graphic_invalid_smoothing( smoothing="none", extra_body={"smoothing": invalid_smoothing}, ) + + +@pytest.mark.parametrize( + ("page_range", "expect_success"), + [ + pytest.param("5", True, id="single"), + pytest.param("3-7", True, id="ascending-range"), + pytest.param("last", True, id="last"), + pytest.param("1-last", True, id="entire-document"), + pytest.param(["1", "3", "5-7"], True, id="list-mixed"), + ], +) +def test_live_png_page_range_variants( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_20_page_pdf: PdfRestFile, + page_range: Any, + expect_success: bool, + request: pytest.FixtureRequest, +) -> None: + case_id = request.node.callspec.id + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + info = client.query_pdf_info(uploaded_20_page_pdf) + + assert info.page_count == 20 + assert str(info.input_id) == str(uploaded_20_page_pdf.id) + assert info.filename is None or info.filename.endswith(".pdf") + + if expect_success: + response = client.convert_to_png( + uploaded_20_page_pdf, + output_prefix=f"live-range-{case_id}", + page_range=page_range, + ) + + expected_pages = _expand_page_selection(page_range, total_pages=20) + assert len(response.output_files) == len(expected_pages) + assert any( + file_info.name.endswith(".png") for file_info in response.output_files + ) + assert all( + file_info.type == "image/png" and file_info.size > 0 + for file_info in response.output_files + ) + assert str(response.input_id) == str(uploaded_20_page_pdf.id) + else: + with pytest.raises(PdfRestApiError): + client.convert_to_png( + uploaded_20_page_pdf, + output_prefix=f"live-range-{case_id}", + extra_body={"page_range": page_range}, + ) + + +@pytest.mark.parametrize( + "page_override", + [ + pytest.param("0", id="zero"), + pytest.param("last-0", id="range-with-zero"), + pytest.param("7-3", id="descending-range"), + pytest.param("even", id="even"), + pytest.param("odd", id="odd"), + pytest.param("odd,even", id="odd-even"), + ], +) +def test_live_png_page_range_invalid_overrides( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_20_page_pdf: PdfRestFile, + page_override: str, + request: pytest.FixtureRequest, +) -> None: + case_id = request.node.callspec.id + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.convert_to_png( + uploaded_20_page_pdf, + output_prefix=f"live-range-invalid-{case_id}", + page_range="1", + extra_body={"pages": page_override}, + ) + + +def _expand_page_selection( + selection: Any, + *, + total_pages: int, +) -> list[int]: + def expand_entry(entry: Any) -> list[int]: + if isinstance(entry, int): + return [entry] + text = str(entry).strip() + lowered = text.lower() + if lowered == "even": + return list(range(2, total_pages + 1, 2)) + if lowered == "odd": + return list(range(1, total_pages + 1, 2)) + if lowered == "last": + return [total_pages] + if "-" in lowered: + start_raw, end_raw = (part.strip() for part in lowered.split("-", 1)) + + def resolve(range_token: str) -> int: + return total_pages if range_token == "last" else int(range_token) # noqa: S105 + + start = resolve(start_raw) + end = resolve(end_raw) + step = 1 if end >= start else -1 + return list(range(start, end + step, step)) + return [int(text)] + + if isinstance(selection, Sequence) and not isinstance( + selection, (str, bytes, bytearray) + ): + expanded: list[int] = [] + for segment in selection: + expanded.extend(expand_entry(segment)) + return expanded + return expand_entry(selection) diff --git a/tests/resources/20-pages.pdf b/tests/resources/20-pages.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d52b76833357bff63a135e7fcf54e2cdf5aa55c5 GIT binary patch literal 21823 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 Y-93Pf4sO!{#h?&S5HGL1h63pS0n-=wwEzGB literal 0 HcmV?d00001 diff --git a/tests/test_graphic_payload_validation.py b/tests/test_graphic_payload_validation.py index 4c7b1701..cea6c5a1 100644 --- a/tests/test_graphic_payload_validation.py +++ b/tests/test_graphic_payload_validation.py @@ -92,39 +92,49 @@ def test_graphic_payload_invalid_output_prefix( [ pytest.param( "0", - "Page numbers must be greater than or equal to 1.", + "greater than or equal to 1", id="scalar-zero", ), pytest.param( ["0"], - "Page numbers must be greater than or equal to 1.", + "greater than or equal to 1", id="list-zero-string", ), pytest.param( [0], - "Page numbers must be greater than or equal to 1.", + "greater than or equal to 1", id="list-zero-int", ), pytest.param( "last-5", - "Page range start must be a page number greater than or equal to 1.", + "unable to parse string as an integer", id="range-last-to-number", ), pytest.param( "3-2", - "Page range end must be greater than or equal to the start.", + "less than or equal to the end page", id="range-descending", ), pytest.param( "foo", - "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'.", + "unable to parse string as an integer", id="scalar-word", ), pytest.param( ["1", "foo"], - "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'.", + "unable to parse string as an integer", id="list-mixed", ), + pytest.param( + "even", + "unable to parse string as an integer", + id="even", + ), + pytest.param( + "odd", + "unable to parse string as an integer", + id="odd", + ), ], ) def test_graphic_payload_invalid_page_range_value( @@ -139,3 +149,41 @@ def test_graphic_payload_invalid_page_range_value( "page_range": bad_page_range, } ) + + +@pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) +@pytest.mark.parametrize( + ("page_range", "expected_message"), + [ + pytest.param( + "even", + "unable to parse string as an integer", + id="even", + ), + pytest.param( + "odd", + "unable to parse string as an integer", + id="odd", + ), + pytest.param( + "5-3", + "less than or equal to the end page", + id="descending", + ), + ], +) +def test_graphic_payload_rejects_special_ranges( + payload_model: type[BasePdfRestGraphicPayload[Any]], + page_range: str, + expected_message: str, +) -> None: + with pytest.raises( + ValidationError, + match=expected_message, + ): + payload_model.model_validate( + { + "files": [make_pdf_file("12345678-1234-4abc-8def-1234567890ab")], + "page_range": page_range, + } + ) From 7cb6685a6354c72aedd8d643bbabbd2c99caaecb Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 10 Nov 2025 13:01:10 -0600 Subject: [PATCH 50/51] Add PDF split/merge support with live tests - Introduced `split_pdf` and `merge_pdfs` methods in both sync and async clients. - Added `PdfSplitPayload` and `PdfMergePayload` models for payload validation and serialization. - Created associated types and serializers for page groupings and merge sources. - Added comprehensive live and unit tests for splitting and merging functionality. Assisted-by: Codex --- src/pdfrest/client.py | 114 ++++++ src/pdfrest/models/_internal.py | 133 +++++++ src/pdfrest/types/__init__.py | 6 + src/pdfrest/types/public.py | 23 +- tests/live/test_live_pdf_split_merge.py | 457 ++++++++++++++++++++++++ tests/test_merge_pdfs.py | 155 ++++++++ tests/test_split_pdf.py | 195 ++++++++++ 7 files changed, 1081 insertions(+), 2 deletions(-) create mode 100644 tests/live/test_live_pdf_split_merge.py create mode 100644 tests/test_merge_pdfs.py create mode 100644 tests/test_split_pdf.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 6f257168..8b8ea380 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -40,9 +40,11 @@ GifPdfRestPayload, JpegPdfRestPayload, PdfInfoPayload, + PdfMergePayload, PdfRedactionApplyPayload, PdfRedactionPreviewPayload, PdfRestRawFileResponse, + PdfSplitPayload, PngPdfRestPayload, TiffPdfRestPayload, UploadURLs, @@ -50,6 +52,8 @@ from .types import ( ALL_PDF_INFO_QUERIES, PdfInfoQuery, + PdfMergeInput, + PdfPageSelection, PdfRedactionInstruction, PdfRGBColor, ) @@ -1591,6 +1595,61 @@ def apply_redactions( timeout=timeout, ) + def split_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + page_groups: Sequence[PdfPageSelection] | PdfPageSelection | None = None, + output_prefix: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Split a PDF into one or more PDF files based on the provided page groups.""" + + payload: dict[str, Any] = {"files": file} + if page_groups is not None: + payload["page_groups"] = page_groups + if output_prefix is not None: + payload["output_prefix"] = output_prefix + + return self._post_file_operation( + endpoint="/split-pdf", + payload=payload, + payload_model=PdfSplitPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def merge_pdfs( + self, + sources: Sequence[PdfMergeInput], + *, + output_prefix: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Merge multiple PDFs (or page subsets) into a single PDF file.""" + + payload: dict[str, Any] = {"sources": sources} + if output_prefix is not None: + payload["output_prefix"] = output_prefix + + return self._post_file_operation( + endpoint="/merged-pdf", + payload=payload, + payload_model=PdfMergePayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_png( self, files: PdfRestFile | Sequence[PdfRestFile], @@ -1963,6 +2022,61 @@ async def _convert_to_graphic( timeout=timeout, ) + async def split_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + page_groups: Sequence[PdfPageSelection] | PdfPageSelection | None = None, + output_prefix: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously split a PDF into one or more PDF files.""" + + payload: dict[str, Any] = {"files": file} + if page_groups is not None: + payload["page_groups"] = page_groups + if output_prefix is not None: + payload["output_prefix"] = output_prefix + + return await self._post_file_operation( + endpoint="/split-pdf", + payload=payload, + payload_model=PdfSplitPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def merge_pdfs( + self, + sources: Sequence[PdfMergeInput], + *, + output_prefix: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously merge multiple PDFs (or page subsets) into a single PDF.""" + + payload: dict[str, Any] = {"sources": sources} + if output_prefix is not None: + payload["output_prefix"] = output_prefix + + return await self._post_file_operation( + endpoint="/merged-pdf", + payload=payload, + payload_model=PdfMergePayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def convert_to_png( self, files: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 7e12a103..887bc4ad 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -15,6 +15,7 @@ Field, HttpUrl, PlainSerializer, + model_serializer, model_validator, ) @@ -122,6 +123,12 @@ def join_tuple(value: str | int | tuple[str | int, ...]) -> str: return ",".join(join_tuple(v) for v in value) +def _serialize_grouped_page_ranges( + value: list[list[str | int | tuple[str | int, ...]]], +) -> list[str]: + return [_serialize_page_ranges(v) for v in value] + + def _serialize_redactions(value: list[_PdfRedactionVariant]) -> str: payload = [entry.model_dump(mode="json", exclude_none=True) for entry in value] return json.dumps(payload, separators=(",", ":")) @@ -182,6 +189,17 @@ def _ascending_page_range( return range +_PageRangeTupleWithLast = Annotated[ + tuple[PageNumber, PageNumber] + | tuple[Literal["last"], PageNumber] + | tuple[PageNumber, Literal["last"]], + BeforeValidator(_split_page_range_tuple), +] + +SplitMergePageRange = ( + Literal["even", "odd", "last"] | PageNumber | _PageRangeTupleWithLast +) + _AscendingPageRangeTuple = Annotated[ tuple[PageNumber, PageNumber] | tuple[PageNumber, Literal["last"]], BeforeValidator(_split_page_range_tuple), @@ -349,6 +367,121 @@ class PngPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "rgba", "gray"] color_model: Annotated[Literal["rgb", "rgba", "gray"], Field(default="rgb")] +_DEFAULT_FULL_DOCUMENT_RANGE: list[str] = ["1-last"] + + +class PdfSplitPayload(BaseModel): + """Adapt caller options into a pdfRest-ready split request payload.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + page_groups: Annotated[ + list[ + Annotated[ + list[SplitMergePageRange], + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_string), + ] + ] + | None, + Field( + default=None, + validation_alias=AliasChoices("pages", "page_groups"), + serialization_alias="pages", + min_length=1, + ), + BeforeValidator(_ensure_list), + BeforeValidator(_int_to_string), + PlainSerializer(_serialize_grouped_page_ranges), + ] + output_prefix: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + +class _PdfMergeItem(BaseModel): + file: Annotated[ + PdfRestFile, + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + ] + pages: Annotated[ + list[SplitMergePageRange], + Field( + min_length=1, + default_factory=lambda: list(_DEFAULT_FULL_DOCUMENT_RANGE).copy(), + ), + BeforeValidator(_list_of_strings), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_page_ranges), + ] + + @model_validator(mode="before") + @classmethod + def _transform_input(cls, data: Any) -> Any: + if isinstance(data, tuple): + if len(data) != 2: + msg = ( + "Tuple merge entries must contain exactly two items: (file, pages)." + ) + raise ValueError(msg) + file_candidate, pages = data + return {"file": file_candidate, "pages": pages} + if isinstance(data, PdfRestFile): + return {"file": data} + return data + + +class PdfMergePayload(BaseModel): + """Adapt caller options into a pdfRest-ready merge request payload.""" + + sources: Annotated[ + list[_PdfMergeItem], + Field( + min_length=2, + validation_alias=AliasChoices("sources", "documents", "files"), + ), + BeforeValidator(_ensure_list), + ] + output_prefix: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + @model_serializer(mode="wrap") + def _serialize_pdf_merge_payload( + self, handler: Callable[[PdfMergePayload], dict[str, Any]] + ) -> dict[str, Any]: + # Invoke all the serializers on the payload, which then properly serializes + # all the fields. + payload = handler(self) + # Reorganize the serialized data into the parallel arrays that pdfRest expects + payload["type"] = ["id"] * len(self.sources) + payload["pages"] = [ + source.get("pages", _DEFAULT_FULL_DOCUMENT_RANGE[0]) + for source in payload["sources"] + ] + payload["id"] = [source["file"]["id"] for source in payload["sources"]] + del payload["sources"] + return payload + + class BmpPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "gray"]]): """Adapt caller options into a pdfRest-ready BMP request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index d2e54009..635be543 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -3,6 +3,9 @@ from .public import ( ALL_PDF_INFO_QUERIES, PdfInfoQuery, + PdfMergeInput, + PdfMergeSource, + PdfPageSelection, PdfRedactionInstruction, PdfRedactionPreset, PdfRedactionType, @@ -12,6 +15,9 @@ __all__ = [ "ALL_PDF_INFO_QUERIES", "PdfInfoQuery", + "PdfMergeInput", + "PdfMergeSource", + "PdfPageSelection", "PdfRGBColor", "PdfRedactionInstruction", "PdfRedactionPreset", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index c9528d5f..496d9490 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -2,13 +2,22 @@ from __future__ import annotations -from typing import Literal, cast, get_args +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Literal, cast, get_args -from typing_extensions import TypedDict +from typing_extensions import Required, TypedDict + +if TYPE_CHECKING: + from pdfrest.models import PdfRestFile +else: # pragma: no cover - used only for typing at runtime + PdfRestFile = Any __all__ = ( "ALL_PDF_INFO_QUERIES", "PdfInfoQuery", + "PdfMergeInput", + "PdfMergeSource", + "PdfPageSelection", "PdfRGBColor", "PdfRedactionInstruction", "PdfRedactionPreset", @@ -77,3 +86,13 @@ class PdfRedactionInstruction(TypedDict): PdfRGBColor = tuple[int, int, int] + +PdfPageSelection = str | int | Sequence[str | int] + + +class PdfMergeSource(TypedDict, total=False): + file: Required[PdfRestFile] + pages: PdfPageSelection | None + + +PdfMergeInput = PdfRestFile | PdfMergeSource | tuple[PdfRestFile, PdfPageSelection] diff --git a/tests/live/test_live_pdf_split_merge.py b/tests/live/test_live_pdf_split_merge.py new file mode 100644 index 00000000..351be410 --- /dev/null +++ b/tests/live/test_live_pdf_split_merge.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +from collections.abc import Sequence + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile +from pdfrest.types import PdfMergeInput, PdfPageSelection + +from ..resources import get_test_resource_path + + +def _expand_page_selection( + selection: PdfPageSelection | Sequence[PdfPageSelection], + *, + total_pages: int, +) -> list[int]: + def expand_entry(entry: PdfPageSelection) -> list[int]: + if isinstance(entry, int): + return [entry] + text = str(entry).strip() + lowered = text.lower() + if lowered == "even": + return list(range(2, total_pages + 1, 2)) + if lowered == "odd": + return list(range(1, total_pages + 1, 2)) + if lowered == "last": + return [total_pages] + if "-" in lowered: + start_raw, end_raw = (part.strip() for part in lowered.split("-", 1)) + + def resolve(range_token: str) -> int: + return total_pages if range_token == "last" else int(range_token) # noqa: S105 + + start = resolve(start_raw) + end = resolve(end_raw) + step = 1 if end >= start else -1 + return list(range(start, end + step, step)) + return [int(text)] + + if isinstance(selection, Sequence) and not isinstance( + selection, (str, bytes, bytearray) + ): + expanded: list[int] = [] + for segment in selection: + expanded.extend(expand_entry(segment)) + return expanded + return expand_entry(selection) + + +def _extract_merge_entry( + entry: PdfMergeInput, +) -> tuple[PdfRestFile, PdfPageSelection | Sequence[PdfPageSelection]]: + if isinstance(entry, tuple): + return entry + if isinstance(entry, dict): + file = entry["file"] + if file is None: + msg = "PdfMergeDocument entries must include a 'file' key." + raise KeyError(msg) + pages = entry.get("pages") + selection: PdfPageSelection | Sequence[PdfPageSelection] = ( + pages if pages is not None else "1-last" + ) + return file, selection + return entry, "1-last" + + +def _fetch_page_count(client: PdfRestClient, file: PdfRestFile) -> int: + info = client.query_pdf_info(file) + assert info.page_count is not None + return int(info.page_count) + + +async def _fetch_page_count_async(client: AsyncPdfRestClient, file: PdfRestFile) -> int: + info = await client.query_pdf_info(file) + assert info.page_count is not None + return int(info.page_count) + + +@pytest.fixture(scope="module") +def uploaded_live_pdfs( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> tuple[PdfRestFile, PdfRestFile]: + split_source_path = get_test_resource_path("20-pages.pdf") + merge_partner_path = get_test_resource_path("report.pdf") + + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + split_source = client.files.create_from_paths([split_source_path])[0] + merge_partner = client.files.create_from_paths([merge_partner_path])[0] + + return split_source, merge_partner + + +@pytest.mark.parametrize( + ("page_groups", "expected_count"), + [ + pytest.param(["1-5", "6-last"], 2, id="two-ranges"), + pytest.param([["1", "3", "5"], "2-4"], 2, id="alternating-selection"), + pytest.param(["even"], 1, id="even-only"), + pytest.param(["9-2"], 1, id="descending-single"), + pytest.param(["odd", "even"], 2, id="odd-and-even"), + ], +) +def test_live_split_pdf_page_groups( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], + page_groups: list[PdfPageSelection], + expected_count: int, +) -> None: + split_source, _ = uploaded_live_pdfs + + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + total_pages = _fetch_page_count(client, split_source) + + response = client.split_pdf( + split_source, + page_groups=page_groups, + output_prefix="live-split", + ) + + assert len(response.output_files) == expected_count + + output_infos = [ + client.query_pdf_info(output_file) for output_file in response.output_files + ] + + assert all( + output_file.name.startswith("live-split") + and output_file.name.endswith(".pdf") + and output_file.type == "application/pdf" + and output_file.size > 0 + for output_file in response.output_files + ) + page_counts_optional = [info.page_count for info in output_infos] + assert all(count is not None for count in page_counts_optional) + expected_page_counts = [ + len(_expand_page_selection(group, total_pages=total_pages)) + for group in page_groups + ][: len(page_counts_optional)] + page_counts = [ + int(count) for count in page_counts_optional if count is not None + ] + assert page_counts == expected_page_counts + assert str(response.input_id) == str(split_source.id) + + +def test_live_split_pdf_default_outputs( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], +) -> None: + split_source, _ = uploaded_live_pdfs + + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + total_pages = _fetch_page_count(client, split_source) + + response = client.split_pdf( + split_source, + output_prefix="live-split-default", + ) + + assert len(response.output_files) == total_pages + + output_infos = [ + client.query_pdf_info(output_file) for output_file in response.output_files + ] + assert all( + output_file.name.startswith("live-split-default") + and output_file.name.endswith(".pdf") + and output_file.type == "application/pdf" + and output_file.size > 0 + for output_file in response.output_files + ) + assert all(info.page_count == 1 for info in output_infos) + + assert str(response.input_id) == str(split_source.id) + + +def test_live_split_pdf_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], +) -> None: + split_source, _ = uploaded_live_pdfs + + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.split_pdf( + split_source, + page_groups=["1-2"], + extra_body={"pages": ["0"]}, + ) + + +def test_live_merge_pdfs_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], +) -> None: + split_source, merge_partner = uploaded_live_pdfs + sources: list[PdfMergeInput] = [ + {"file": split_source, "pages": "odd"}, + {"file": split_source, "pages": "even"}, + {"file": merge_partner, "pages": "1"}, + ] + + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + source_infos = { + str(candidate.id): _fetch_page_count(client, candidate) + for candidate in (split_source, merge_partner) + } + + response = client.merge_pdfs( + sources, + output_prefix="live-merge", + ) + + assert len(response.input_ids) == len(sources) + + expected_total_pages = sum( + len( + _expand_page_selection( + selection, total_pages=source_infos[str(file.id)] + ) + ) + for file, selection in (_extract_merge_entry(entry) for entry in sources) + ) + + output_file = response.output_file + assert output_file.name.startswith("live-merge") + assert output_file.name.endswith(".pdf") + assert output_file.type == "application/pdf" + assert output_file.size > 0 + + output_info = client.query_pdf_info(output_file) + assert output_info.page_count == expected_total_pages + + +def test_live_merge_pdfs_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], +) -> None: + split_source, merge_partner = uploaded_live_pdfs + sources: list[PdfMergeInput] = [ + {"file": split_source, "pages": "even"}, + {"file": merge_partner, "pages": "1"}, + ] + + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.merge_pdfs( + sources, + output_prefix="live-merge-invalid", + extra_body={"pages": ["even", "0"]}, + ) + + +@pytest.mark.asyncio +async def test_live_async_merge_pdfs( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], +) -> None: + split_source, merge_partner = uploaded_live_pdfs + sources: list[PdfMergeInput] = [ + {"file": split_source, "pages": "9-2"}, + {"file": merge_partner, "pages": "1"}, + ] + + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + split_page_count = await _fetch_page_count_async(client, split_source) + partner_page_count = await _fetch_page_count_async(client, merge_partner) + + response = await client.merge_pdfs( + sources, + output_prefix="live-async-merge", + ) + + source_page_counts = { + str(split_source.id): split_page_count, + str(merge_partner.id): partner_page_count, + } + expected_total_pages = sum( + len( + _expand_page_selection( + selection, total_pages=source_page_counts[str(file.id)] + ) + ) + for file, selection in (_extract_merge_entry(entry) for entry in sources) + ) + + output_file = response.output_file + assert output_file.name.startswith("live-async-merge") + assert output_file.name.endswith(".pdf") + assert output_file.type == "application/pdf" + assert output_file.size > 0 + + output_info = await client.query_pdf_info(output_file) + assert output_info.page_count == expected_total_pages + + +SPLIT_RANGE_CASES = [ + pytest.param("3", True, False, id="single-str"), + pytest.param(3, True, False, id="single-int"), + pytest.param("2-5", True, False, id="ascending-range"), + pytest.param("5-2", True, False, id="descending-range"), + pytest.param("even", True, False, id="even"), + pytest.param("odd", True, False, id="odd"), + pytest.param("2-last", True, False, id="to-last"), + pytest.param("last-2", True, False, id="last-desc"), + pytest.param("last", False, True, id="last"), +] + + +@pytest.mark.parametrize( + ("selection", "expect_success", "requires_override"), SPLIT_RANGE_CASES +) +def test_live_split_pdf_page_range_variants( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], + selection: PdfPageSelection, + expect_success: bool, + requires_override: bool, + request: pytest.FixtureRequest, +) -> None: + split_source, _ = uploaded_live_pdfs + case_id = request.node.callspec.id + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + total_pages = _fetch_page_count(client, split_source) + override_body = None + if requires_override: + override_body = {"pages": [str(selection)]} + + if expect_success: + response = client.split_pdf( + split_source, + page_groups=[selection if not requires_override else "1"], + output_prefix=f"live-split-range-{case_id}", + extra_body=override_body, + ) + expected_pages = _expand_page_selection(selection, total_pages=total_pages) + output_pages = client.query_pdf_info(response.output_files[0]).page_count + assert output_pages == len(expected_pages) + else: + with pytest.raises(PdfRestApiError): + client.split_pdf( + split_source, + page_groups=[selection if not requires_override else "1"], + output_prefix=f"live-split-range-{case_id}", + extra_body=override_body, + ) + + +MERGE_RANGE_CASES = [ + pytest.param("3", True, False, id="single-str"), + pytest.param(3, True, False, id="single-int"), + pytest.param("2-5", True, False, id="ascending-range"), + pytest.param("5-2", True, False, id="descending-range"), + pytest.param("even", True, False, id="even"), + pytest.param("odd", True, False, id="odd"), + pytest.param("2-last", True, False, id="to-last"), + pytest.param("last-2", True, False, id="last-desc"), + pytest.param("last", False, False, id="last"), +] + + +@pytest.mark.parametrize( + ("selection", "expect_success", "requires_override"), MERGE_RANGE_CASES +) +def test_live_merge_pdf_page_range_variants( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], + selection: PdfPageSelection, + expect_success: bool, + requires_override: bool, + request: pytest.FixtureRequest, +) -> None: + split_source, merge_partner = uploaded_live_pdfs + case_id = request.node.callspec.id + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + source_page_counts = { + str(split_source.id): _fetch_page_count(client, split_source), + str(merge_partner.id): _fetch_page_count(client, merge_partner), + } + sources: list[PdfMergeInput] = [ + { + "file": split_source, + "pages": selection if not requires_override else "1", + }, + {"file": merge_partner, "pages": "1"}, + ] + override_body = {"pages": [str(selection), "1"]} if requires_override else None + + if expect_success: + response = client.merge_pdfs( + sources, + output_prefix=f"live-merge-range-{case_id}", + extra_body=override_body, + ) + expected_total_pages = sum( + len( + _expand_page_selection( + chosen_selection, + total_pages=source_page_counts[str(file.id)], + ) + ) + for file, chosen_selection in ( + _extract_merge_entry(entry) for entry in sources + ) + ) + output_info = client.query_pdf_info(response.output_file) + assert output_info.page_count == expected_total_pages + else: + with pytest.raises(PdfRestApiError): + client.merge_pdfs( + sources, + output_prefix=f"live-merge-range-{case_id}", + extra_body=override_body, + ) diff --git a/tests/test_merge_pdfs.py b/tests/test_merge_pdfs.py new file mode 100644 index 00000000..64755a9f --- /dev/null +++ b/tests/test_merge_pdfs.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfMergePayload +from pdfrest.types import PdfMergeInput + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_merge_pdfs_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source_a = make_pdf_file(PdfRestFileID.generate(1), name="a.pdf") + source_b = make_pdf_file(PdfRestFileID.generate(1), name="b.pdf") + source_c = make_pdf_file(PdfRestFileID.generate(1), name="c.pdf") + output_id = str(PdfRestFileID.generate()) + + merge_sources: list[PdfMergeInput] = [ + {"file": source_a, "pages": "even"}, + source_b, + (source_c, ("9-2", "odd")), + ] + + pdf_merge_payload = PdfMergePayload.model_validate( + { + "sources": merge_sources, + "output_prefix": "merged-output", + } + ) + request_payload = pdf_merge_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/merged-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == request_payload + return httpx.Response( + 200, + json={ + "inputId": [source_a.id, source_b.id, source_c.id], + "outputId": output_id, + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "merged-output.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.merge_pdfs(merge_sources, output_prefix="merged-output") + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "merged-output.pdf" + assert response.output_file.type == "application/pdf" + assert len(response.input_ids) == 3 + assert {str(input_id) for input_id in response.input_ids} == { + str(source_a.id), + str(source_b.id), + str(source_c.id), + } + assert response.warning is None + + +def test_merge_pdfs_requires_multiple_sources(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + single_source = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="at least 2"), + ): + client.merge_pdfs([single_source]) + + +def test_merge_pdfs_invalid_page_range(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source_a = make_pdf_file(PdfRestFileID.generate(1)) + source_b = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="greater than or equal to 1"), + ): + client.merge_pdfs([source_a, (source_b, 0)]) + + +@pytest.mark.asyncio +async def test_async_merge_pdfs(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source_a = make_pdf_file(PdfRestFileID.generate(1)) + source_b = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + request_payload = PdfMergePayload.model_validate( + { + "sources": [source_a, {"file": source_b, "pages": "2-last"}], + "output_prefix": "async-merge", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/merged-pdf": + payload = json.loads(request.content.decode("utf-8")) + assert payload == request_payload + return httpx.Response( + 200, + json={ + "inputId": [source_a.id, source_b.id], + "outputId": output_id, + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-merge.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.merge_pdfs( + [source_a, {"file": source_b, "pages": "2-last"}], + output_prefix="async-merge", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-merge.pdf" diff --git a/tests/test_split_pdf.py b/tests/test_split_pdf.py new file mode 100644 index 00000000..2afd4c6f --- /dev/null +++ b/tests/test_split_pdf.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfSplitPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_split_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_ids = [str(PdfRestFileID.generate()) for _ in range(4)] + page_groups: list[str | list[int | str]] = [ + ["1", "2-4", 5, "6-last"], + "even", + "9-2", + "odd", + ] + + request_payload = PdfSplitPayload.model_validate( + { + "files": [input_file], + "page_groups": page_groups, + "output_prefix": "split-output", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/split-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == request_payload + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": output_ids, + }, + ) + if request.method == "GET" and request.url.path in { + f"/resource/{identifier}" for identifier in output_ids + }: + seen["get"] += 1 + file_id = request.url.path.split("/")[-1] + index = output_ids.index(file_id) + 1 + name = f"split-output-{index:03d}.pdf" + return httpx.Response( + 200, + json=build_file_info_payload(file_id, name, "application/pdf"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.split_pdf( + input_file, + page_groups=page_groups, + output_prefix="split-output", + ) + + assert seen["post"] == 1 + assert seen["get"] == len(output_ids) + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.output_files) == len(output_ids) + expected_names = [ + f"split-output-{idx:03d}.pdf" for idx in range(1, len(output_ids) + 1) + ] + assert [output_file.name for output_file in response.output_files] == expected_names + assert [output_file.type for output_file in response.output_files] == [ + "application/pdf" + ] * len(output_ids) + assert str(response.input_id) == str(input_file.id) + assert response.warning is None + + +def test_split_pdf_without_page_groups(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_ids = [str(PdfRestFileID.generate()) for _ in range(3)] + + request_payload = PdfSplitPayload.model_validate( + { + "files": [input_file], + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/split-pdf": + payload = json.loads(request.content.decode("utf-8")) + assert payload == request_payload + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": output_ids, + }, + ) + if request.method == "GET" and request.url.path in { + f"/resource/{identifier}" for identifier in output_ids + }: + file_id = request.url.path.split("/")[-1] + index = output_ids.index(file_id) + 1 + return httpx.Response( + 200, + json=build_file_info_payload( + file_id, + f"auto-split-{index:03d}.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.split_pdf(input_file) + + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.output_files) == len(output_ids) + assert [file.name for file in response.output_files] == [ + f"auto-split-{idx:03d}.pdf" for idx in range(1, len(output_ids) + 1) + ] + assert all(file.type == "application/pdf" for file in response.output_files) + + +def test_split_pdf_invalid_page_group(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="greater than or equal to 1"), + ): + client.split_pdf(input_file, page_groups=["0"]) + + +@pytest.mark.asyncio +async def test_async_split_pdf(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + request_payload = PdfSplitPayload.model_validate( + { + "files": [input_file], + "output_prefix": "async-split", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/split-pdf": + payload = json.loads(request.content.decode("utf-8")) + assert payload == request_payload + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-split-001.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.split_pdf( + input_file, + output_prefix="async-split", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files[0].name == "async-split-001.pdf" From 72b2c101cb13751d036c441c7e48ba69d77510d0 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 10 Nov 2025 13:20:14 -0600 Subject: [PATCH 51/51] Update Agents.md for split/merge and better tests - Enhanced `AGENTS.md` with rich type usage, updated live tests, and reproducible fixtures. Assisted-by: Codex --- AGENTS.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 996c35cf..cd47344b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,22 @@ instances (optionally sequences) rather than raw IDs or strings; use the `files` client helpers to resolve file IDs before invoking conversion or metadata routes. +- For document splitting and merging, expose rich Python types on the client + surface (`PdfPageSelection`, `PdfMergeInput`) and validate them through the + `PdfSplitPayload`/`PdfMergePayload` models. Normalize per-output page groups + with the shared page-range validator, default merge items without explicit + ranges to `"1-last"`, and serialize merge requests into the parallel `id`, + `pages`, and `type` arrays that pdfRest expects (always emitting `"id"` for + `type[]`). Split/merge payloads accept descending ranges (e.g., `"9-2"`) and + the `"even"`/`"odd"` selectors; graphic conversions remain limited to positive + numbers, `"last"`, and ascending ranges to match the live API behaviour. +- Favor declarative Pydantic validation over bespoke “normalize” helpers: define + nested models, unions, and annotated tuples that parse complex strings into + typed structures (as with the split/merge page-range tuples) and let small + validators enforce the constraints (`BeforeValidator` for parsing, + `AfterValidator` for relational checks). Reserve standalone normalization + functions for behaviour that cannot live on the schema—simpler models produce + clearer errors and are easier for new contributors to understand. - When adding new services, provide per-endpoint test modules mirroring PNG’s coverage: parameterized successes for every allowed literal value, request customization (sync + async), validation failures, and multi-file guards. Add @@ -146,6 +162,11 @@ exception surfaced by the client). When test fixtures produce deterministic results (e.g., `tests/resources/report.pdf`), assert the concrete values returned by pdfRest rather than only checking for presence or type. +- Use `tests/resources/20-pages.pdf` for high-page-count scenarios such as split + and merge endpoints so boundary coverage (multi-output splits, staggered page + selections) remains reproducible. Parameterize live split/merge tests to cover + multiple page-group patterns, and pair each success case with an invalid input + that reaches the server by overriding the JSON body via `extra_body`. - Developers can load a pdfRest API key from `.env` during ad-hoc exploration. The repo includes `python-dotenv`; call `load_dotenv()` (optionally pointing to `.env`) in temporary scripts to drive the in-flight client against live