From 215651cb97160bf024448c56923ca8a8e3aa9f5a Mon Sep 17 00:00:00 2001
From: Sina
Date: Sat, 4 Apr 2026 19:26:18 +0000
Subject: [PATCH 01/14] Switch to API-based implementation around core
functionalities
---
.example.env | 46 -
.gitignore | 178 +-
CONTRIBUTING.md | 45 +
MANIFEST.in | 9 +
README.md | 873 ++--
REPO_WORKFLOWS.md | 95 +
__init__.py | 0
args.py | 145 -
churro_transformers_infer.py | 192 -
cli/__init__.py | 6 -
cli/__main__.py | 9 -
cli/benchmark.py | 105 -
cli/docs_to_images.py | 180 -
cli/helpers.py | 46 -
cli/infer.py | 320 --
cli/main.py | 493 ---
cli/text_to_historical_doc_xml.py | 319 --
config/__init__.py | 11 -
config/settings.py | 155 -
conftest.py | 53 +-
evaluation/__init__.py | 20 -
evaluation/evaluate_page.py | 206 -
evaluation/historical_doc.xsd | 605 ---
evaluation/metrics.py | 129 -
evaluation/normalization.py | 146 -
evaluation/repetition.py | 42 -
evaluation/xml_utils.py | 151 -
page/__init__.py | 14 -
page/page.py | 246 --
page/page_object.py | 240 --
page/visualization.py | 91 -
page_boundary/__init__.py | 9 -
page_boundary/_constants.py | 73 -
page_boundary/_image_processing.py | 123 -
page_boundary/_models.py | 101 -
page_boundary/_pipeline.py | 203 -
page_boundary/_serialization.py | 62 -
page_boundary/cli.py | 103 -
page_boundary/detector.py | 65 -
pixi.lock | 3596 +++++++----------
pixi.toml | 92 -
pyproject.toml | 158 +-
scripts/package_check.py | 308 ++
sitecustomize.py | 13 +
src/churro_ocr/__init__.py | 45 +
src/churro_ocr/__main__.py | 6 +
src/churro_ocr/_internal/__init__.py | 1 +
src/churro_ocr/_internal/image.py | 61 +
src/churro_ocr/_internal/litellm.py | 356 ++
src/churro_ocr/_internal/logging.py | 64 +
src/churro_ocr/_internal/pdf.py | 44 +
src/churro_ocr/_internal/prompt_logging.py | 84 +
src/churro_ocr/_internal/runtime.py | 24 +
src/churro_ocr/cli.py | 235 ++
src/churro_ocr/document.py | 168 +
src/churro_ocr/errors.py | 15 +
src/churro_ocr/ocr.py | 139 +
src/churro_ocr/page_detection.py | 294 ++
src/churro_ocr/prompts/__init__.py | 21 +
src/churro_ocr/prompts/layout.py | 175 +
src/churro_ocr/prompts/ocr.py | 54 +
src/churro_ocr/providers/__init__.py | 93 +
src/churro_ocr/providers/_shared.py | 93 +
src/churro_ocr/providers/builder.py | 280 ++
src/churro_ocr/providers/hf.py | 459 +++
src/churro_ocr/providers/ocr.py | 262 ++
src/churro_ocr/providers/page_detection.py | 1471 +++++++
src/churro_ocr/providers/specs.py | 224 +
src/churro_ocr/providers/vllm.py | 183 +
src/churro_ocr/py.typed | 1 +
src/churro_ocr/templates/__init__.py | 33 +
src/churro_ocr/templates/base.py | 45 +
src/churro_ocr/templates/hf.py | 40 +
src/churro_ocr/templates/presets.py | 33 +
systems/__init__.py | 52 -
systems/azure_ocr.py | 33 -
systems/base_ocr.py | 71 -
systems/detect_layout.py | 541 ---
systems/finetuned_ocr.py | 57 -
systems/llm_improver.py | 142 -
systems/llm_ocr.py | 150 -
systems/mistral_ocr.py | 58 -
systems/ocr_factory.py | 83 -
tests/{ => assets}/minimal-document.pdf | Bin
tests/churro_dataset_sample_1.jpeg | Bin 701122 -> 0 bytes
tests/churro_dataset_sample_2.jpeg | Bin 565986 -> 0 bytes
tests/conftest.py | 11 +
tests/test_args_module.py | 85 -
tests/test_cli.py | 222 +
tests/test_cli_benchmark_unit.py | 234 --
tests/test_cli_contract.py | 245 ++
tests/test_cli_end_to_end.py | 142 -
tests/test_cli_infer_unit.py | 348 --
tests/test_detect_layout_cache.py | 81 -
tests/test_document_pipeline.py | 100 +
tests/test_evaluation_normalization_unit.py | 38 -
tests/test_hf_ocr.py | 798 ++++
tests/test_hf_ocr_integration.py | 68 +
tests/test_internal_helpers.py | 388 ++
tests/test_layout_api.py | 69 +
tests/test_llm_async.py | 53 -
tests/test_llm_ocr.py | 16 -
tests/test_logging.py | 41 +
tests/test_model_map.py | 36 -
tests/test_ocr_api.py | 98 +
tests/test_page_detection_integration.py | 151 +
tests/test_page_detection_provider_helpers.py | 427 ++
tests/test_parallel_executor.py | 72 -
tests/test_pdf_pipeline.py | 163 -
tests/test_pdf_pipeline_integration.py | 179 -
tests/test_process_one_pdf_file.py | 93 -
tests/test_provider_api_contracts.py | 145 +
tests/test_providers.py | 1071 +++++
tests/test_public_api_contracts.py | 203 +
tests/test_settings.py | 90 -
tests/test_tooling_benchmark.py | 758 ++++
tests/test_tooling_evaluate_page.py | 99 +
...etrics_unit.py => test_tooling_metrics.py} | 90 +-
tests/test_tooling_support.py | 320 ++
tests/test_vllm_config.py | 70 -
tooling/__init__.py | 5 +
tooling/benchmarking/__init__.py | 1 +
tooling/benchmarking/benchmark.py | 476 +++
tooling/benchmarking/dataset.py | 133 +
tooling/evaluation/__init__.py | 25 +
tooling/evaluation/evaluate_page.py | 227 ++
tooling/evaluation/metrics.py | 129 +
tooling/evaluation/normalization.py | 107 +
tooling/evaluation/repetition.py | 32 +
tooling/evaluation/types.py | 66 +
tooling/evaluation/xml_utils.py | 53 +
utils/__init__.py | 5 -
utils/concurrency.py | 212 -
utils/docker/__init__.py | 51 -
utils/docker/container.py | 95 -
utils/docker/errors.py | 16 -
utils/docker/logging_utils.py | 56 -
utils/docker/operations.py | 315 --
utils/docker/sdk.py | 107 -
utils/docker/servers.py | 177 -
utils/docker/vllm.py | 138 -
utils/image/binarizer.py | 793 ----
utils/image/io.py | 22 -
utils/image/transform.py | 85 -
utils/llm/__init__.py | 48 -
utils/llm/config.py | 104 -
utils/llm/core.py | 142 -
utils/llm/cost.py | 40 -
utils/llm/messages.py | 141 -
utils/llm/models.py | 582 ---
utils/llm/shutdown.py | 94 -
utils/llm/types.py | 26 -
utils/llm/utils.py | 61 -
utils/log_utils.py | 58 -
utils/pdf/__init__.py | 21 -
utils/pdf/pdfs_to_images.py | 112 -
utils/pdf/runner.py | 694 ----
workdir | 1 +
158 files changed, 14287 insertions(+), 14433 deletions(-)
delete mode 100644 .example.env
create mode 100644 CONTRIBUTING.md
create mode 100644 MANIFEST.in
create mode 100644 REPO_WORKFLOWS.md
delete mode 100644 __init__.py
delete mode 100644 args.py
delete mode 100644 churro_transformers_infer.py
delete mode 100644 cli/__init__.py
delete mode 100644 cli/__main__.py
delete mode 100644 cli/benchmark.py
delete mode 100644 cli/docs_to_images.py
delete mode 100644 cli/helpers.py
delete mode 100644 cli/infer.py
delete mode 100644 cli/main.py
delete mode 100644 cli/text_to_historical_doc_xml.py
delete mode 100644 config/__init__.py
delete mode 100644 config/settings.py
delete mode 100644 evaluation/__init__.py
delete mode 100644 evaluation/evaluate_page.py
delete mode 100644 evaluation/historical_doc.xsd
delete mode 100644 evaluation/metrics.py
delete mode 100644 evaluation/normalization.py
delete mode 100644 evaluation/repetition.py
delete mode 100644 evaluation/xml_utils.py
delete mode 100644 page/__init__.py
delete mode 100644 page/page.py
delete mode 100644 page/page_object.py
delete mode 100644 page/visualization.py
delete mode 100644 page_boundary/__init__.py
delete mode 100644 page_boundary/_constants.py
delete mode 100644 page_boundary/_image_processing.py
delete mode 100644 page_boundary/_models.py
delete mode 100644 page_boundary/_pipeline.py
delete mode 100644 page_boundary/_serialization.py
delete mode 100644 page_boundary/cli.py
delete mode 100644 page_boundary/detector.py
delete mode 100644 pixi.toml
create mode 100644 scripts/package_check.py
create mode 100644 sitecustomize.py
create mode 100644 src/churro_ocr/__init__.py
create mode 100644 src/churro_ocr/__main__.py
create mode 100644 src/churro_ocr/_internal/__init__.py
create mode 100644 src/churro_ocr/_internal/image.py
create mode 100644 src/churro_ocr/_internal/litellm.py
create mode 100644 src/churro_ocr/_internal/logging.py
create mode 100644 src/churro_ocr/_internal/pdf.py
create mode 100644 src/churro_ocr/_internal/prompt_logging.py
create mode 100644 src/churro_ocr/_internal/runtime.py
create mode 100644 src/churro_ocr/cli.py
create mode 100644 src/churro_ocr/document.py
create mode 100644 src/churro_ocr/errors.py
create mode 100644 src/churro_ocr/ocr.py
create mode 100644 src/churro_ocr/page_detection.py
create mode 100644 src/churro_ocr/prompts/__init__.py
create mode 100644 src/churro_ocr/prompts/layout.py
create mode 100644 src/churro_ocr/prompts/ocr.py
create mode 100644 src/churro_ocr/providers/__init__.py
create mode 100644 src/churro_ocr/providers/_shared.py
create mode 100644 src/churro_ocr/providers/builder.py
create mode 100644 src/churro_ocr/providers/hf.py
create mode 100644 src/churro_ocr/providers/ocr.py
create mode 100644 src/churro_ocr/providers/page_detection.py
create mode 100644 src/churro_ocr/providers/specs.py
create mode 100644 src/churro_ocr/providers/vllm.py
create mode 100644 src/churro_ocr/py.typed
create mode 100644 src/churro_ocr/templates/__init__.py
create mode 100644 src/churro_ocr/templates/base.py
create mode 100644 src/churro_ocr/templates/hf.py
create mode 100644 src/churro_ocr/templates/presets.py
delete mode 100644 systems/__init__.py
delete mode 100644 systems/azure_ocr.py
delete mode 100644 systems/base_ocr.py
delete mode 100644 systems/detect_layout.py
delete mode 100644 systems/finetuned_ocr.py
delete mode 100644 systems/llm_improver.py
delete mode 100644 systems/llm_ocr.py
delete mode 100644 systems/mistral_ocr.py
delete mode 100644 systems/ocr_factory.py
rename tests/{ => assets}/minimal-document.pdf (100%)
delete mode 100644 tests/churro_dataset_sample_1.jpeg
delete mode 100644 tests/churro_dataset_sample_2.jpeg
create mode 100644 tests/conftest.py
delete mode 100644 tests/test_args_module.py
create mode 100644 tests/test_cli.py
delete mode 100644 tests/test_cli_benchmark_unit.py
create mode 100644 tests/test_cli_contract.py
delete mode 100644 tests/test_cli_end_to_end.py
delete mode 100644 tests/test_cli_infer_unit.py
delete mode 100644 tests/test_detect_layout_cache.py
create mode 100644 tests/test_document_pipeline.py
delete mode 100644 tests/test_evaluation_normalization_unit.py
create mode 100644 tests/test_hf_ocr.py
create mode 100644 tests/test_hf_ocr_integration.py
create mode 100644 tests/test_internal_helpers.py
create mode 100644 tests/test_layout_api.py
delete mode 100644 tests/test_llm_async.py
delete mode 100644 tests/test_llm_ocr.py
create mode 100644 tests/test_logging.py
delete mode 100644 tests/test_model_map.py
create mode 100644 tests/test_ocr_api.py
create mode 100644 tests/test_page_detection_integration.py
create mode 100644 tests/test_page_detection_provider_helpers.py
delete mode 100644 tests/test_parallel_executor.py
delete mode 100644 tests/test_pdf_pipeline.py
delete mode 100644 tests/test_pdf_pipeline_integration.py
delete mode 100644 tests/test_process_one_pdf_file.py
create mode 100644 tests/test_provider_api_contracts.py
create mode 100644 tests/test_providers.py
create mode 100644 tests/test_public_api_contracts.py
delete mode 100644 tests/test_settings.py
create mode 100644 tests/test_tooling_benchmark.py
create mode 100644 tests/test_tooling_evaluate_page.py
rename tests/{test_evaluation_metrics_unit.py => test_tooling_metrics.py} (53%)
create mode 100644 tests/test_tooling_support.py
delete mode 100644 tests/test_vllm_config.py
create mode 100644 tooling/__init__.py
create mode 100644 tooling/benchmarking/__init__.py
create mode 100644 tooling/benchmarking/benchmark.py
create mode 100644 tooling/benchmarking/dataset.py
create mode 100644 tooling/evaluation/__init__.py
create mode 100644 tooling/evaluation/evaluate_page.py
create mode 100644 tooling/evaluation/metrics.py
create mode 100644 tooling/evaluation/normalization.py
create mode 100644 tooling/evaluation/repetition.py
create mode 100644 tooling/evaluation/types.py
create mode 100644 tooling/evaluation/xml_utils.py
delete mode 100644 utils/__init__.py
delete mode 100644 utils/concurrency.py
delete mode 100644 utils/docker/__init__.py
delete mode 100644 utils/docker/container.py
delete mode 100644 utils/docker/errors.py
delete mode 100644 utils/docker/logging_utils.py
delete mode 100644 utils/docker/operations.py
delete mode 100644 utils/docker/sdk.py
delete mode 100644 utils/docker/servers.py
delete mode 100644 utils/docker/vllm.py
delete mode 100644 utils/image/binarizer.py
delete mode 100644 utils/image/io.py
delete mode 100644 utils/image/transform.py
delete mode 100644 utils/llm/__init__.py
delete mode 100644 utils/llm/config.py
delete mode 100644 utils/llm/core.py
delete mode 100644 utils/llm/cost.py
delete mode 100644 utils/llm/messages.py
delete mode 100644 utils/llm/models.py
delete mode 100644 utils/llm/shutdown.py
delete mode 100644 utils/llm/types.py
delete mode 100644 utils/llm/utils.py
delete mode 100644 utils/log_utils.py
delete mode 100644 utils/pdf/__init__.py
delete mode 100644 utils/pdf/pdfs_to_images.py
delete mode 100644 utils/pdf/runner.py
create mode 120000 workdir
diff --git a/.example.env b/.example.env
deleted file mode 100644
index 22d7cd2..0000000
--- a/.example.env
+++ /dev/null
@@ -1,46 +0,0 @@
-######################################################################
-# Example Environment Configuration (DO NOT PUT REAL SECRETS) #
-# Copy to `.env` (git-ignored) and replace placeholder values. #
-# #
-# NEVER commit real API keys or tokens. #
-# Loaded automatically via python-dotenv. #
-# Only populate the sections relevant to the providers you use. #
-######################################################################
-
-#############################
-# Azure OpenAI / OpenAI API #
-#############################
-# Base URL of your Azure OpenAI resource (Ends with .openai.azure.com/)
-AZURE_API_BASE=https://.openai.azure.com/
-# One of the two keys under Azure OpenAI: Portal > Resource > Develop > Keys & Endpoint
-AZURE_OPENAI_API_KEY=
-# API version (update only if you intentionally target a newer preview/stable)
-AZURE_API_VERSION=2025-04-01-preview
-
-# Direct OpenAI key (use only if calling OpenAI instead of Azure deployment routing)
-OPENAI_API_KEY=
-
-# Mistral key from https://console.mistral.ai (Workspace > API keys)
-MISTRAL_API_KEY=
-
-#############################
-# Local models through vLLM and HuggingFace #
-#############################
-# Port where your local vLLM server will be exposed. Any free port larger than 1024
-# and less than 65535 should work.
-LOCAL_VLLM_PORT=9000
-
-# Hugging Face access token (Settings > Access Tokens). Needed for private/gated models
-HF_TOKEN=
-
-#######################################
-# Azure Document Intelligence, if you are using Azure OCR
-#######################################
-# Endpoint of your Document Intelligence resource
-AZURE_DI_ENDPOINT=https://.cognitiveservices.azure.com
-# One of the two keys under that resource (Keys & Endpoint)
-AZURE_DOC_KEY=
-
-# Vertex AI settings
-# Location of your Vertex AI resources, e.g. us-central1, us-east5, global
-VERTEX_AI_LOCATION=
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 313ed49..33414bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,173 +1,23 @@
-# Byte-compiled / optimized / DLL files
+# Python caches
__pycache__/
*.py[cod]
-*$py.class
-
-# C extensions
*.so
+*.pyd
+.mypy_cache/
+.pytest_cache/
+.ruff_cache/
+.litellm_cache/
+.cache/
-# Distribution / packaging
-.Python
+# Local environments and build artifacts
+.pixi/
+.coverage
build/
-develop-eggs/
dist/
-downloads/
-eggs/
-.eggs/
-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
-
-# pdm
-# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
-#pdm.lock
-# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
-# in version control.
-# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
-.pdm.toml
-.pdm-python
-.pdm-build/
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
-__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/
-
-.vscode
-
-# pixi environments
-.pixi/
-
-# Runtime artifacts
+# Local data and logs
workdir/
-workdir
-prompt_logs.jsonl
-.litellm_cache/
-.diskcache/
\ No newline at end of file
+debug.log
+debug_logs*
+.env
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..71fe6d0
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,45 @@
+# Contributing to Churro
+
+Use Pixi for local development. Treat this checkout as the repo root.
+
+## Setup
+
+Install Pixi, then create the default environment from the checkout root:
+
+```bash
+pixi install
+```
+
+## Common Commands
+
+Run these from the checkout root:
+
+```bash
+pixi run format
+pixi run lint
+pixi run typecheck
+pixi run test
+pixi run coverage
+pixi run package-check
+```
+
+What they do:
+- `format`: run Ruff formatting on `src/` and `tests/`
+- `lint`: run Ruff lint checks on `src/` and `tests/`
+- `typecheck`: run `ty` on `src/` and `tests/`
+- `test`: run the unit and offline test suite
+- `coverage`: run the full coverage report used for release audits
+- `package-check`: build and audit the wheel and sdist that would be published to PyPI
+
+## Benchmarking and Release Checks
+
+Repo-only benchmarking, evaluation outputs, and the package audit are documented in `REPO_WORKFLOWS.md`.
+
+## Live Integration Tests
+
+Live provider integration tests are skipped by default.
+
+- `tests/test_page_detection_integration.py` uses `CHURRO_RUN_LIVE_VERTEX_TESTS`
+- `tests/test_hf_ocr_integration.py` uses `CHURRO_RUN_LIVE_HF_TESTS`
+
+These tests may require credentials, external services, and billable APIs. Do not enable them in routine local runs unless you intend to use those services.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..914b011
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,9 @@
+include LICENSE
+include README.md
+include pyproject.toml
+graft src/churro_ocr
+prune scripts
+prune tests
+prune tooling
+global-exclude __pycache__
+global-exclude *.py[cod]
diff --git a/README.md b/README.md
index 3dcb8af..f4117f7 100644
--- a/README.md
+++ b/README.md
@@ -1,305 +1,708 @@
-
-
-
CHURRO: Making History Readable with an Open-Weight Large Vision-Language Model for High-Accuracy, Low-Cost Historical Text Recognition
-
-
-
-
-
-
-
-
-
- Handwritten and printed text recognition across 22 centuries and 46 language clusters, including historical and dead languages.
-
-
-
-
-
- Cost vs. accuracy: CHURRO (3B) achieves higher accuracy than much larger commercial and open-weight VLMs while being substantially cheaper.
-
-
----
-## Table of Contents
-1. [Overview](#overview)
-2. [Quick Start](#quick-start)
-3. [Installing the Full Package](#installing-the-full-package)
- - [System Packages](#system-packages)
- - [Docker (recommended for local models)](#docker-recommended-for-local-models)
- - [Environment Setup](#environment-setup)
- - [Configure Providers](#configure-providers)
-4. [CLI Workflows](#cli-workflows)
- - [Inference](#inference)
- - [Preprocess PDFs and Images](#preprocess-pdfs-and-images)
- - [Benchmark on CHURRO-DS](#benchmark-on-churro-ds)
- - [LLM Improver](#llm-improver)
- - [Backup Engines](#backup-engines)
- - [Local vLLM Container Notes](#local-vllm-container-notes)
-5. [Adding a New OCR System](#adding-a-new-ocr-system)
-6. [HistoricalDocument XML](#historicaldocument-xml)
- - [Generate HistoricalDocument XML](#generate-historicaldocument-xml)
-7. [Citation](#citation)
-8. [License](#license)
-
----
-
-## Overview
-**CHURRO** is a 3B-parameter open-weight vision-language model (VLM) for historical document transcription. It is trained on **CHURRO-DS**, a curated dataset of ~100K pages from 155 historical collections spanning 22 centuries and 46 language clusters.
-
-On the CHURRO-DS test set, CHURRO delivers **15.5× lower cost than Gemini 2.5 Pro while exceeding its accuracy**.
-
-## Quick Start
-
-Want a minimal demo? The following will install `transformers` and `torch` only:
+# churro-ocr
+
+`churro-ocr` is a Python toolkit for OCR and page detection on historical documents.
+
+It gives you one consistent interface whether you are using:
+- hosted multimodal models through LiteLLM
+- a local OpenAI-compatible server
+- local Hugging Face or vLLM models
+- Azure Document Intelligence
+- Mistral OCR
+
+If you are new to the library, the shortest path is:
+1. Install the extra for the backend you want.
+2. Create an `OCRBackendSpec`.
+3. Build it with `build_ocr_backend(...)`.
+4. Use `OCRClient` for one page or `DocumentOCRPipeline` for a full document.
+
+The PyPI package name is `churro-ocr`. The Python import package is `churro_ocr`.
+
+## Setup
+
+Install only the pieces you need:
+
```bash
-git clone https://github.com/stanford-oval/churro.git
-cd churro
-curl -fsSL https://pixi.sh/install.sh | bash
-pixi shell -e minimal
+pip install churro-ocr
+pip install "churro-ocr[llm]"
+pip install "churro-ocr[local]"
+pip install "churro-ocr[hf]"
+pip install "churro-ocr[huggingface]"
+pip install "churro-ocr[vllm]"
+pip install "churro-ocr[azure]"
+pip install "churro-ocr[mistral]"
+pip install "churro-ocr[pdf]"
+pip install "churro-ocr[all]"
```
-Then run:
-```bash
-python churro_transformers_infer.py tests/churro_dataset_sample_1.jpeg --max-new-tokens 40
+What each extra is for:
+- `llm`: hosted multimodal OCR and LLM-based page detection through LiteLLM
+- `local`: OpenAI-compatible OCR servers
+- `hf` and `huggingface`: equivalent extras for local Hugging Face Transformers OCR
+- `vllm`: local vLLM OCR
+- `azure`: Azure Document Intelligence OCR and page detection
+- `mistral`: Mistral OCR
+- `pdf`: PDF rasterization via pypdfium2
+- `all`: everything above
+
+Credential setup depends on the provider you choose:
+- LiteLLM-backed models use LiteLLM's normal authentication flow.
+- OpenAI-compatible servers usually need `api_base` and `api_key`.
+- Azure Document Intelligence needs an endpoint and API key.
+- Mistral needs an API key.
+
+When you need to pass connection details directly, use `LiteLLMTransportConfig` or the provider-specific options dataclasses shown below.
+
+If you are working from a repo checkout instead of installing from PyPI, use Pixi from the checkout root. The public workflow is `pixi run lint`, `pixi run test`, and `pixi run package-check`, not the internal Nx file.
+
+## Core Idea
+
+`churro-ocr` has a small set of concepts:
+
+- `DocumentPage`: one page image plus optional OCR text and metadata
+- `OCRBackendSpec`: a declarative description of which OCR backend to build
+- `build_ocr_backend(...)`: the factory that turns a spec into a runnable OCR backend
+- `OCRClient`: OCR for one image or one `DocumentPage`
+- `DocumentPageDetector`: page detection only
+- `DocumentOCRPipeline`: page detection plus OCR for a complete image or PDF flow, with bounded OCR concurrency
+
+Most users do not need to think about prompts or preprocessing directly. `churro-ocr` ships built-in OCR model profiles and automatically applies the right prompt and output cleanup for:
+- generic OCR models
+- `stanford-oval/churro-3B`
+- `kristaller486/dots.ocr-1.5`
+
+## Which API Should I Use?
+
+| Goal | API |
+| --- | --- |
+| OCR one page or one image | `OCRClient` |
+| Detect page crops only | `DocumentPageDetector` |
+| Run an end-to-end image/PDF OCR workflow | `DocumentOCRPipeline` |
+| Try things from the shell | `churro-ocr` CLI |
+| Tune backend/provider options directly | `build_ocr_backend(...)` + `OCRBackendSpec` |
+
+## Which OCR Backend Should I Use?
+
+| Provider | Install extra | Good default when |
+| --- | --- | --- |
+| `litellm` | `llm` | you want to use a hosted multimodal model through LiteLLM |
+| `openai-compatible` | `local` | you have a local or self-hosted OpenAI-style server |
+| `hf` | `hf` or `huggingface` | you want local Transformers inference in-process |
+| `vllm` | `vllm` | you want higher-throughput local serving |
+| `azure` | `azure` | you want Azure Document Intelligence OCR |
+| `mistral` | `mistral` | you want Mistral's OCR API |
+
+All of these backends use the same builder:
+
+```python
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+)
```
-Expected output begins with:
+## Quick Start: OCR One Image
+
+Use `OCRClient` when your input is already one page per image.
+
+```python
+from churro_ocr.ocr import OCRClient
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+)
-```xml
-
-
- German
- ltr
- [!WARNING]
-> This codebase has been tested on Ubuntu 20.04+. Using other operating systems may require tinkering with and troubleshooting system dependencies.
+```python
+import asyncio
-### System Packages
-```bash
-sudo apt-get update && sudo apt-get install -y \
- libtiff5-dev libjpeg8-dev libopenjp2-7-dev zlib1g-dev libfreetype6-dev \
- liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python3-tk libharfbuzz-dev \
- libfribidi-dev libxcb1-dev
+from churro_ocr.ocr import OCRClient
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+
+async def main() -> None:
+ backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+ )
+ page = await OCRClient(backend).aocr_image(
+ image_path="scan.png",
+ page_index=3,
+ source_index=7,
+ metadata={"job_id": "demo"},
+ )
+ print(page.text)
+ print(page.metadata)
+
+
+asyncio.run(main())
```
-### Docker (recommended for local models)
-- Install Docker: https://docs.docker.com/engine/install/
-- GPU users: add the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html).
-- CPU-only machines can still run local models, but expect significantly slower throughput.
+Async page detection for images or PDFs:
-### Environment Setup
-We use [Pixi](https://pixi.sh/) to manage Python environments and dependencies. If you are familiar with [Conda](https://docs.conda.io/), you can think of Pixi as a much faster alternative. The following commands set up a Pixi shell with all required packages. Make sure the environment is active before running any Python code.
-```bash
-git clone https://github.com/stanford-oval/churro.git
-cd churro
-curl -fsSL https://pixi.sh/install.sh | bash
-pixi shell # create and enter the managed environment
+```python
+import asyncio
+
+from churro_ocr.page_detection import DocumentPageDetector, PageDetectionRequest
+
+
+async def main() -> None:
+ detector = DocumentPageDetector()
+ image_result = await detector.detect_image(
+ PageDetectionRequest(image_path="spread.jpg", trim_margin=20)
+ )
+ pdf_result = await detector.detect_pdf("document.pdf", dpi=300, trim_margin=20)
+ print(image_result.source_type, len(image_result.pages))
+ print(pdf_result.source_type, len(pdf_result.pages))
+
+
+asyncio.run(main())
```
-Sanity check the install with:
-```bash
-pixi run python -m churro.cli --help
+Async end-to-end document OCR:
+
+```python
+import asyncio
+
+from churro_ocr import DocumentOCRPipeline, PageDetectionRequest
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+
+async def main() -> None:
+ pipeline = DocumentOCRPipeline(
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+ ),
+ max_concurrency=4,
+ )
+ image_result = await pipeline.process_image(
+ PageDetectionRequest(image_path="spread.jpg", trim_margin=20),
+ ocr_metadata={"job_id": "demo-image"},
+ )
+ pdf_result = await pipeline.process_pdf(
+ "document.pdf",
+ dpi=300,
+ trim_margin=20,
+ ocr_metadata={"job_id": "demo-pdf"},
+ )
+ print(image_result.texts())
+ print(pdf_result.as_ocr_results()[0].metadata)
+
+
+asyncio.run(main())
+```
+
+## Full Example: Detect Pages and OCR a Photographed Spread
+
+This example shows the complete flow for a single image that may contain multiple pages.
+It uses an LLM page detector to find page crops, then OCRs each crop with the same model family.
+
+```python
+from pathlib import Path
+
+from churro_ocr import DocumentOCRPipeline, PageDetectionRequest
+from churro_ocr.providers import (
+ LLMPageDetector,
+ LiteLLMTransportConfig,
+ OCRBackendSpec,
+ build_ocr_backend,
+)
+
+INPUT_IMAGE = Path("spread.jpg")
+OUTPUT_DIR = Path("output")
+MODEL = "vertex_ai/gemini-2.5-flash"
+
+transport = LiteLLMTransportConfig()
+
+ocr_backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model=MODEL,
+ transport=transport,
+ )
+)
+
+pipeline = DocumentOCRPipeline(
+ ocr_backend,
+ detection_backend=LLMPageDetector(
+ model=MODEL,
+ transport=transport,
+ ),
+ max_concurrency=4,
+)
+
+result = pipeline.process_image_sync(
+ PageDetectionRequest(
+ image_path=INPUT_IMAGE,
+ trim_margin=20,
+ )
+)
+
+OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+for page in result.pages:
+ image_path = OUTPUT_DIR / f"page_{page.page_index:04d}.png"
+ text_path = OUTPUT_DIR / f"page_{page.page_index:04d}.txt"
+
+ page.image.save(image_path)
+ text_path.write_text(page.text or "", encoding="utf-8")
+
+ print(
+ f"page={page.page_index} "
+ f"provider={page.provider_name} "
+ f"model={page.model_name} "
+ f"text_file={text_path}"
+ )
```
-### Configure Providers
+If your input is already one page per image, skip the `detection_backend` and use `OCRClient` instead.
-Copy the example environment file:
-```bash
-cp .example.env .env
+## Backend Recipes
+
+### LiteLLM
+
+Use this for hosted multimodal models routed through LiteLLM.
+
+```python
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+)
+```
+
+If you need to override connection details or completion settings:
+
+```python
+from churro_ocr.providers import LiteLLMTransportConfig, OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="gpt-4.1-mini",
+ transport=LiteLLMTransportConfig(
+ api_base="https://example.invalid/v1",
+ api_key="secret",
+ api_version="2025-01-01-preview",
+ completion_kwargs={"temperature": 0},
+ ),
+ )
+)
```
-Populate only the variables you need in `.env`.
-All environment variables live in `.env` and are autoloaded via `python-dotenv`. Use the table below as a quick reference to decide which credentials you must supply.
-| Workflow | Required providers | Key variables |
-|----------|-------------------|---------------|
-| Azure Document Intelligence OCR (`--system azure`) or if using `docs-to-images` command without `--no-trim` | Azure Document Intelligence | `AZURE_DI_ENDPOINT`, `AZURE_DOC_KEY` |
-| LLM-based OCR against Vertex AI deployments | Google Vertex AI | `VERTEX_AI_LOCATION` |
-| LLM-based OCR against Azure/OpenAI deployments | Azure OpenAI or OpenAI | `AZURE_API_BASE`, `AZURE_OPENAI_API_KEY` (or `OPENAI_API_KEY`), `AZURE_API_VERSION` |
-| Mistral OCR (`--system mistral_ocr`) | Mistral | `MISTRAL_API_KEY` |
-| Local vLLM models (`--system finetuned` or `llm` with engines backed by `vllm/`) | Docker + Hugging Face | `LOCAL_VLLM_PORT`, `HF_TOKEN` (only if using private models) |
+### OpenAI-Compatible Servers
+
+Use this for local or self-hosted servers that expose an OpenAI-style API.
+
+```python
+from churro_ocr.providers import (
+ LiteLLMTransportConfig,
+ OCRBackendSpec,
+ build_ocr_backend,
+)
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="openai-compatible",
+ model="local-model",
+ transport=LiteLLMTransportConfig(
+ api_base="http://127.0.0.1:8000/v1",
+ api_key="dummy",
+ ),
+ )
+)
+```
-When a workflow does not need a provider, leave the corresponding variables blank. See `.example.env` for full documentation of each field.
-For Vertex AI usage, additionally ensure that the Google Cloud SDK is installed and authenticated: https://cloud.google.com/sdk/docs/install
+### Hugging Face
-Note that for all API LLM calls, the outputs are cached in `.litellm_cache/`, so subsequent runs with the same inputs will be much faster and free.
+Use this for local Transformers inference inside the current Python process.
-## CLI Workflows
-The unified Typer CLI lives under `churro/cli`. All examples below assume you are inside a `pixi shell` or prefix commands with `pixi run`.
+```python
+from churro_ocr.providers import HuggingFaceOptions, OCRBackendSpec, build_ocr_backend
-### Inference
-Single image (local CHURRO model hosted via vLLM):
-```bash
-pixi run python -m churro.cli infer \
- --system finetuned \
- --engine churro \
- --image tests/churro_dataset_sample_1.jpeg
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="hf",
+ model="stanford-oval/churro-3B",
+ options=HuggingFaceOptions(
+ model_kwargs={"device_map": "auto", "torch_dtype": "auto"},
+ ),
+ )
+)
```
-`finetuned` system returns HistoricalDocument XML by default; add `--strip-xml` to output plain text instead. See [HistoricalDocument XML](#historicaldocument-xml) for schema details and parsing tips.
+### vLLM
-Optionally, add `--binarize` to pre-process each page with the bundled neural image binarizer before sending it to OCR. This can improve OCR accuracy on degraded documents.
-The first run downloads the `stanford-oval/eynollah_binarizer_onnx` model.
+Use this for higher-throughput local inference.
-Batch directory with filtered suffixes and output files:
-```bash
-pixi run python -m churro.cli infer \
- --system finetuned \
- --engine churro \
- --image-dir path/to/images \
- --suffix png --suffix jpeg \
- --recursive \
- --output-dir workdir/texts/ \
- --skip-existing \
- --max-concurrency 8
+```python
+from churro_ocr.providers import OCRBackendSpec, VLLMOptions, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="vllm",
+ model="stanford-oval/churro-3B",
+ options=VLLMOptions(),
+ )
+)
```
-Use `pixi run python -m churro.cli infer --help` to see every option, including how to use other LLMs via `--system llm --engine ` arguments.
+### Azure Document Intelligence
+
+Use this when you want Azure's OCR stack instead of a general multimodal model.
+
+```python
+from churro_ocr.providers import (
+ AzureDocumentIntelligenceOptions,
+ OCRBackendSpec,
+ build_ocr_backend,
+)
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="azure",
+ options=AzureDocumentIntelligenceOptions(
+ endpoint="https://.cognitiveservices.azure.com/",
+ api_key="",
+ ),
+ )
+)
+```
-### Preprocess PDFs and Images
-If you have raw PDF scans or image directories, first use the `docs-to-images` command to convert them into page-aligned PNGs ready for OCR.
-`docs-to-images` normalizes PDF scans and image directories into page-aligned PNGs. The default engine `gemini-2.5-pro-low` calls a Vertex AI model to detect double-page spreads, then calls Azure Document Intelligence to detect page boundaries and trim margins.
+### Mistral OCR
-Single PDF:
-```bash
-pixi run python -m churro.cli docs-to-images \
- --input-file path/to/file.pdf \
- --output-dir workdir/images/
+```python
+from churro_ocr.providers import MistralOptions, OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="mistral",
+ model="mistral-ocr-latest",
+ options=MistralOptions(api_key=""),
+ )
+)
```
-Mixed directory with custom suffix filters and dry run:
-```bash
-pixi run python -m churro.cli docs-to-images \
- --input-dir path/to/scans \
- --suffix pdf --suffix tif --suffix png \
- --recursive \
- --output-dir workdir/images/ \
- --dry-run
+## Backend Spec Reference
+
+`OCRBackendSpec` is the builder input shared across all OCR providers.
+
+| Field | Meaning |
+| --- | --- |
+| `provider` | One of `litellm`, `openai-compatible`, `azure`, `mistral`, `hf`, or `vllm`. |
+| `model` | Required for `litellm`, `openai-compatible`, `hf`, and `vllm`. Optional for `azure`. Defaults to `mistral-ocr-latest` when omitted for `mistral`. |
+| `profile` | `None`, a built-in profile name, or a custom `OCRModelProfile`. If omitted, `resolve_ocr_profile(...)` picks the built-in profile for the model id when available, otherwise the generic default profile. |
+| `transport` | Shared request transport config for LiteLLM-based providers. Use this for `litellm`, `openai-compatible`, or LLM page detection. |
+| `options` | Provider-specific dataclass. Use the options type that matches `provider`. |
+
+Provider option dataclasses:
+
+| Type | Used by | Required fields | Defaults and notes |
+| --- | --- | --- | --- |
+| `LiteLLMTransportConfig` | `litellm`, `openai-compatible`, `LLMPageDetector` | None at the dataclass level. `openai-compatible` usually needs `api_base` and `api_key`. | `completion_kwargs={}`, `cache_dir=None`, `image_detail=None`, `api_version=None`. |
+| `OpenAICompatibleOptions` | `openai-compatible` | None | `model_prefix=None`. Use it when your local server expects a provider prefix before the model id. |
+| `HuggingFaceOptions` | `hf` | None | `trust_remote_code=None`, `processor_kwargs={}`, `model_kwargs={}`, `generation_kwargs={}`, `vision_input_builder=None`, `backend_variant=None`. |
+| `VLLMOptions` | `vllm` | None | `trust_remote_code=None`, `processor_kwargs={}`, `llm_kwargs={}`, `sampling_kwargs={}`, `limit_mm_per_prompt={}`. |
+| `AzureDocumentIntelligenceOptions` | `azure` | `endpoint`, `api_key` | `model` is optional for Azure OCR in `OCRBackendSpec`. |
+| `MistralOptions` | `mistral` | `api_key` | If `OCRBackendSpec.model` is omitted, the backend defaults to `mistral-ocr-latest`. |
+
+The library also exports `DEFAULT_OCR_MAX_TOKENS` from `churro_ocr.providers` for profile and backend integrations that need a shared OCR token budget.
+
+## Page Detection Only
+
+Use `DocumentPageDetector` when you want page crops without OCR.
+
+The default detector treats the whole image as one page:
+
+```python
+from churro_ocr.page_detection import DocumentPageDetector, PageDetectionRequest
+
+result = DocumentPageDetector().detect_image_sync(
+ PageDetectionRequest(image_path="scan.png")
+)
+
+for page in result.pages:
+ print(page.page_index, page.image.size)
```
-Here is how this pipeline works:
-- An LLM estimates whether a rasterized page contains a two-page spread. Provide `--engine ` to swap to a different splitter if you do not have Vertex AI access.
-- Margin trimming is enabled by default via Azure Document Intelligence. Use `--no-trim` to disable this stage.
-- `--batch-pages`, `--queue-maxsize`, `--raster-workers`, `--page-workers`, and `--llm-concurrency-limit` balance CPU-bound rasterization and LLM throughput.
-- Pages are written as `_page_XXXX.png`, even when spreads split into multiple images.
+For Azure-backed page detection:
-### Benchmark on CHURRO-DS
-Run end-to-end evaluation against the CHURRO dataset. The command automatically initializes any required local vLLM server before processing.
-```bash
-pixi run python -m churro.cli benchmark \
- --system finetuned \
- --engine churro \
- --dataset-split test \
- --input-size 0 \
- --max-concurrency 32
+```python
+from churro_ocr.page_detection import DocumentPageDetector, PageDetectionRequest
+from churro_ocr.providers import AzurePageDetector
+
+detector = DocumentPageDetector(
+ backend=AzurePageDetector(
+ endpoint="https://.cognitiveservices.azure.com/",
+ api_key="",
+ )
+)
+
+result = detector.detect_image_sync(
+ PageDetectionRequest(image_path="scan.png", trim_margin=30)
+)
+```
+
+For LLM-based page detection:
+
+```python
+from churro_ocr.page_detection import DocumentPageDetector, PageDetectionRequest
+from churro_ocr.providers import LLMPageDetector, LiteLLMTransportConfig
+
+detector = DocumentPageDetector(
+ backend=LLMPageDetector(
+ model="vertex_ai/gemini-2.5-flash",
+ transport=LiteLLMTransportConfig(),
+ )
+)
+
+result = detector.detect_image_sync(
+ PageDetectionRequest(image_path="spread.jpg", trim_margin=20)
+)
+```
+
+## PDF OCR
+
+If you install the `pdf` extra, `DocumentOCRPipeline` can rasterize PDFs and OCR each page.
+
+```python
+from churro_ocr import DocumentOCRPipeline
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+pipeline = DocumentOCRPipeline(
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+ ),
+ max_concurrency=4,
+)
+
+result = pipeline.process_pdf_sync("document.pdf", dpi=300, trim_margin=30)
+
+for page in result.pages:
+ print(page.page_index, page.text)
```
-Important options:
-- `--system {azure,mistral_ocr,llm,finetuned}` determines which OCR backend to use.
-- `--engine ` is required for `llm` and `finetuned` systems; see `churro/utils/llm/models.py` for the full `MODEL_MAP` of logical keys (GPT-4/5, Claude, Gemini, Qwen 2.5, MiniCPM, CHURRO, and more).
-- `--tensor-parallel-size` / `--data-parallel-size` tune vLLM scaling for local engines.
-- `--resize ` optionally resizes large images before inference.
+## Result Objects
+
+The main result types are stable public interfaces:
+
+| Type | Returned by | Important fields |
+| --- | --- | --- |
+| `DocumentPage` | `OCRClient`, `DocumentPageDetector`, `DocumentOCRPipeline` | `image`, `text`, `provider_name`, `model_name`, `metadata`, `ocr_metadata`, `page_index`, `source_index`, `bbox`, `polygon` |
+| `OCRResult` | low-level OCR backend calls and `DocumentOCRResult.as_ocr_results()` | `text`, `provider_name`, `model_name`, `metadata` |
+| `PageDetectionResult` | `DocumentPageDetector.detect_*` | `pages`, `source_type`, `metadata` |
+| `DocumentOCRResult` | `DocumentOCRPipeline.process_*` | `pages`, `source_type`, `metadata`, `texts()`, `as_ocr_results()` |
+
+Field meanings:
+- `metadata` is caller-side or detector-side metadata attached to the page or result object.
+- `ocr_metadata` is provider-returned OCR metadata for one page. `DocumentOCRResult.as_ocr_results()` copies it into each `OCRResult.metadata`.
+- `page_index` is the page position within the current detection or OCR result.
+- `source_index` is the index in the original input source. For single-image flows it is usually `0`. For `detect_pdf(...)` and `process_pdf(...)`, it is the rasterized PDF page index the crop came from.
+- `source_type` is `"image"` or `"pdf"` on `PageDetectionResult` and `DocumentOCRResult`.
+
+## Advanced: Custom Prompt Template for a Hugging Face Model
+
+Most users should rely on the built-in model profiles. If you need to override prompt rendering for a custom model, create a custom `OCRModelProfile`.
+
+```python
+from churro_ocr import HFChatTemplate
+from churro_ocr.providers import (
+ HuggingFaceOptions,
+ OCRBackendSpec,
+ OCRModelProfile,
+ build_ocr_backend,
+)
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="hf",
+ model="your-org/your-vlm",
+ profile=OCRModelProfile(
+ profile_name="custom",
+ template=HFChatTemplate(
+ system_message="Transcribe the page exactly.",
+ user_prompt=None,
+ ),
+ ),
+ options=HuggingFaceOptions(model_kwargs={"device_map": "auto"}),
+ )
+)
+```
-Optionally, add `--binarize` to pre-process each dataset page with a neural image binarizer before OCR.
+## Prompt and Template Exports
-Outputs land under `workdir/results//_/` (the engine suffix is omitted for `azure` and `mistral_ocr`).
+Most users should start with the built-in profiles and templates rather than building prompts from scratch.
+Built-in OCR profiles resolved by `resolve_ocr_profile(...)`:
+- the generic default profile for unknown models
+- `stanford-oval/churro-3B`, which uses `CHURRO_3B_XML_TEMPLATE`
+- `kristaller486/dots.ocr-1.5`, which uses `DOTS_OCR_1_5_OCR_TEMPLATE`
-### LLM Improver
-The Churro CLI supports optional post-processing with the `LLMImprover`, enabled via `--use-improver`. Pair it with `--improver-engine`. Improver can help fix OCR errors, and improve the formatting of complex documents' Markdown.
+Useful public template exports:
-### Backup Engines
-You can supply a backup engine for LLM-based OCR systems using `--backup-engine`, and for LLM improvers using `--improver-backup-engine`.
-Both backup options allow the pipeline to retry with a secondary model if the first call fails. For example, when a provider's content filter incorrectly flags historical material or when a transient outage interrupts inference.
+| Export | Module | Use case |
+| --- | --- | --- |
+| `HFChatTemplate` | `churro_ocr.templates` | Build a Hugging Face chat-style multimodal prompt with optional system text, user prompt text, and image inclusion. |
+| `DEFAULT_OCR_TEMPLATE` | `churro_ocr.templates` | Generic OCR prompt template used by the default model profile. |
+| `CHURRO_3B_XML_TEMPLATE` | `churro_ocr.templates` | Built-in template for `stanford-oval/churro-3B`. |
+| `DOTS_OCR_1_5_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `kristaller486/dots.ocr-1.5`. |
+| `OCRPromptTemplate` | `churro_ocr.templates` | Base template protocol/type for custom profile integration. |
+Useful public prompt exports:
-### Local vLLM Container Notes
-When you run `infer` or `benchmark` with an `llm` or `finetuned` system whose engine has an `hf_repo` entry, the CLI will:
-- Read `LOCAL_VLLM_PORT` and `HF_TOKEN` from your environment (`churro/utils/docker/vllm.py`).
-- Pull the corresponding Hugging Face repository on first launch. Expect multi-gigabyte downloads.
-- Start a Docker container exposing an OpenAI-compatible API at `http://localhost:/v1`.
-- Stop the container automatically when the command exits or crashes.
+| Export | Module | Use case |
+| --- | --- | --- |
+| `DEFAULT_OCR_SYSTEM_PROMPT` | `churro_ocr.prompts` | Default system instruction for generic OCR prompting. |
+| `DEFAULT_OCR_USER_PROMPT` | `churro_ocr.prompts` | Default user prompt for plain OCR output. |
+| `DEFAULT_MARKDOWN_OCR_USER_PROMPT` | `churro_ocr.prompts` | Default user prompt when markdown-style OCR output is preferred. |
+| `DEFAULT_OCR_OUTPUT_TAG` | `churro_ocr.prompts` | Shared tag name used by the default OCR postprocessor. |
+| `DEFAULT_BOUNDARY_DETECTION_PROMPT` | `churro_ocr.prompts` | Default prompt used by LLM-based page and text-block boundary detection helpers. |
+| `strip_ocr_output_tag(...)` | `churro_ocr.prompts` | Remove the default OCR wrapper tag from model output before downstream evaluation or postprocessing. |
-Make sure the chosen port is free and that Docker is running. GPU acceleration is optional but dramatically improves throughput.
+If you need prompt overrides without replacing the entire backend, pass a custom `OCRModelProfile` through `OCRBackendSpec(profile=...)` and keep the provider-specific options unchanged.
-## Adding a New OCR System
-Pull requests for new VLMs and OCR backends are welcome.
+## CLI
-If adding a new LLM, simply add it to `utils/llm/models.py` (`MODEL_MAP`). Include an `hf_repo` for vLLM-served models.
+Use the CLI when you want a quick sanity check before writing Python code.
-For entirely new OCR systems, follow all steps:
-1. Register the system in `churro/systems/ocr_factory.py` so the CLI can instantiate it.
-2. Implement `process_image` and `get_system_name` in a subclass of `BaseOCR`.
-3. Use `--system ` with the CLI or import the factory in your own scripts.
+Use `churro-ocr --help` or `python -m churro_ocr --help` to see the top-level commands.
-## HistoricalDocument XML
-`HistoricalDocument` is the XML schema we use in the CHURRO dataset and model for rich transcriptions. It is specifically designed to capture complex layouts, scribal edits, and missing text, which are all common in historical documents, while preserving reading order.
+`churro-ocr extract-pages` writes one PNG file per detected page into `--output-dir`.
+Files are named sequentially like `page_0000.png`, `page_0001.png`, and so on.
+The command also prints each written file path to stdout.
-Each response contains a root `` element with optional `` details (languages, scripts, writing direction, notes) followed by one or more `` blocks. A page combines optional `` and `` regions with a required `` that nests structural tags such as ``, ``, ``, and ``. Inline markup like ``, ``, ` `, and `` captures scribal edits or missing text while preserving reading order.
+OCR one image:
-```xml
-
-
- lat
-
-
-
-
-
-
- In nomine domini amen.
- nos notarii subscripsimus.
-
-
-
-
+```bash
+churro-ocr transcribe \
+ --image scan.png \
+ --backend litellm \
+ --model vertex_ai/gemini-2.5-flash
```
-The complete definition lives in `churro/evaluation/historical_doc.xsd`. The inference CLI's `--strip-xml` flag and the evaluation helpers call `churro.evaluation.xml_utils.extract_actual_text_from_xml()` to remove all XML tags and flatten the content into plain text when you do not need the markup.
+Extract pages from an image:
+
+```bash
+churro-ocr extract-pages \
+ --image spread.jpg \
+ --output-dir pages/
+```
-### Generate HistoricalDocument XML
-If you are adding a new dataset to Churro, you may want to convert your transcriptions to the `HistoricalDocument` XML format.
-Convert a directory full of PNG/TXT pairs into `HistoricalDocument` XML using this CLI tool. This conversion uses an LLM prompt to structure the text according to the schema.
+This creates files like:
+
+```text
+pages/page_0000.png
+pages/page_0001.png
+```
+
+Extract pages with Azure page detection:
```bash
-pixi run python -m churro.cli text-to-historical-doc-xml \
- path/to/pairs/dir \
- --corpus-description "Basque newspaper corpus" \
- --max-concurrency 8
+churro-ocr extract-pages \
+ --image spread.jpg \
+ --output-dir pages/ \
+ --page-detector azure \
+ --endpoint https://.cognitiveservices.azure.com/ \
+ --api-key
```
-Place your data in matched `X.png` and `X.txt` files within the input directory; each pair yields an `X.xml`. The tool:
-- Validates LLM output against `historical_doc.xsd` and prettifies XML prior to saving.
-- Skips files that already have XML unless you pass `--overwrite`.
-- Accepts any logical engine key from `MODEL_MAP` via `--engine` (defaults to `gemini-2.5-pro-medium`).
-- Adds optional corpus context to prompts through `--corpus-description`.
+OCR with a local OpenAI-compatible server:
----
+```bash
+churro-ocr transcribe \
+ --image scan.png \
+ --backend openai-compatible \
+ --model local-model \
+ --base-url http://127.0.0.1:8000/v1 \
+ --api-key dummy
+```
-## Citation
-If you use CHURRO or CHURRO-DS, please cite:
+Extract pages from a PDF:
-```bibtex
-@inproceedings{semnani2025churro,
- title = {{CHURRO}: Making History Readable with an Open-Weight Large Vision-Language Model for High-Accuracy, Low-Cost Historical Text Recognition},
- author = {Semnani, Sina J. and Zhang, Han and He, Xinyan and Tekg{"u}rler, Merve and Lam, Monica S.},
- booktitle = {Proceedings of the 2025 Conference on Empirical Methods in Natural Language Processing (EMNLP 2025)},
- year = {2025}
-}
+```bash
+churro-ocr extract-pages \
+ --pdf document.pdf \
+ --output-dir pages/ \
+ --dpi 300 \
+ --trim-margin 30
```
----
+CLI contract:
+
+`transcribe` backends:
+
+| `--backend` value | Required flags | Notes |
+| --- | --- | --- |
+| `litellm` | `--model` | Uses LiteLLM credentials and routing. `--base-url`, `--api-key`, and `--api-version` are optional transport overrides. |
+| `openai-compatible` | `--model`, `--base-url`, `--api-key` | For local or self-hosted OpenAI-style servers. |
+| `azure` | `--endpoint`, `--api-key` | `--model` is optional. |
+| `mistral` | `--api-key` | `--model` defaults to `mistral-ocr-latest`. |
+| `hf` | `--model` | Local Transformers OCR. |
+| `vllm` | `--model` | Local vLLM OCR. |
+
+`extract-pages` page detectors:
+
+| `--page-detector` value | Required flags | Notes |
+| --- | --- | --- |
+| `none` | none | Default behavior. Treats the whole image or rasterized PDF page as one page crop. |
+| `llm` | `--model` | Uses `LLMPageDetector`. `--base-url`, `--api-key`, and `--api-version` are optional transport overrides. |
+| `azure` | `--endpoint`, `--api-key` | Uses Azure Document Intelligence layout detection. |
+
+Additional CLI rules:
+- `transcribe` requires exactly one `--image`.
+- `extract-pages` requires exactly one of `--image` or `--pdf`.
+- `--dpi` only affects the `--pdf` path because PDFs are rasterized before page detection.
+- `--trim-margin` expands each detected bounding box or polygon crop by that many pixels, clipped to the image bounds. With `--page-detector none`, that usually leaves the full image unchanged.
+
+## Public Modules
+
+The main public modules are:
+- `churro_ocr`
+- `churro_ocr.document`
+- `churro_ocr.ocr`
+- `churro_ocr.page_detection`
+- `churro_ocr.providers`
+- `churro_ocr.templates`
+- `churro_ocr.prompts`
+
+## Repo-only Workflows
+
+Benchmarking, contributor commands, and the pre-publish package audit live in the repository workflow guide at `REPO_WORKFLOWS.md`.
+Contributor setup lives in `CONTRIBUTING.md`.
+These commands require a repo checkout and are not part of the published `churro-ocr` package.
## License
-- Model Weights: Qwen research license (see HF model card)
-- Dataset: Due to licensing restrictions on the original datasets used in Churro, use is permitted for research purposes only.
-- Code: Apache 2.0
+
+Apache-2.0
diff --git a/REPO_WORKFLOWS.md b/REPO_WORKFLOWS.md
new file mode 100644
index 0000000..bd7613f
--- /dev/null
+++ b/REPO_WORKFLOWS.md
@@ -0,0 +1,95 @@
+# Churro Repo Workflows
+
+This guide is for repository checkouts of Churro. Run commands from the repo root.
+
+## Benchmarking on CHURRO-DS
+
+The benchmark runner lives in this repo at `tooling.benchmarking.benchmark`. Run it from the checkout root.
+
+The smallest useful benchmark command looks like this:
+
+```bash
+pixi run python -m tooling.benchmarking.benchmark \
+ --backend litellm \
+ --dataset-split test \
+ --model vertex_ai/gemini-2.5-pro
+```
+
+Useful flags:
+- `--dataset-split dev|test`: choose the CHURRO-DS split
+- `--input-size N`: benchmark only the first `N` selected pages
+- `--offset N`: skip the first `N` selected pages
+- `--language` and `--document-type`: filter the benchmark subset before slicing
+- `--output-dir PATH`: override the default results directory
+- `--max-concurrency N`: cap the number of in-flight OCR requests
+- `--vllm-gpu-memory-utilization` and `--vllm-cpu-offload-gb`: pass through selected vLLM runtime knobs
+
+By default, results are written under `workdir/results//`.
+
+Example commands for different models:
+
+| Model | Model ID | Backend | Full command |
+| --- | --- | --- | --- |
+| Gemini 2.5 Pro | `vertex_ai/gemini-2.5-pro` | `litellm` | `pixi run python -m tooling.benchmarking.benchmark --backend litellm --dataset-split test --model vertex_ai/gemini-2.5-pro --output-dir workdir/results/test/litellm_vertex_ai_gemini-2.5-pro` |
+| Qwen 3.5-0.8B | `Qwen/Qwen3.5-0.8B` | `vllm` | `pixi run python -m tooling.benchmarking.benchmark --backend vllm --dataset-split test --model Qwen/Qwen3.5-0.8B --output-dir workdir/results/test/vllm_Qwen_Qwen3.5-0.8B` |
+
+## Evaluation Outputs
+
+Each benchmark run writes one result directory. The important files are:
+- `outputs.json`: one row per evaluated page with the raw predicted text, gold text, and page-level metrics.
+- `all_metrics.json`: aggregate metrics grouped across the full run, by main language, by document type, and by the language/type combination.
+
+`outputs.json` stores page-level metric values as raw fractions. `all_metrics.json` converts aggregate values to percentages and rounds them to one decimal place.
+
+The current aggregate metrics include:
+- `normalized_levenshtein_similarity`: character-level similarity after OCR cleanup and text normalization.
+- `bleu`: BLEU score for non-empty predictions.
+- `repetition`: whether the normalized prediction shows long repeated suffix patterns.
+- `is_empty`: whether the normalized prediction is empty after cleanup.
+- `llm_cost ($)`, `azure_cost ($)`, and `elapsed_time (s)`: run-level summary fields added after aggregation.
+
+Normalization in evaluation strips the default OCR wrapper tag, extracts text from supported XML-like OCR output, normalizes whitespace and punctuation, and applies additional Arabic normalization for Arabic and Persian examples.
+
+## Filtering and Slicing
+
+Subset filters are applied before offset and limit:
+- `--language` filters on `main_language`
+- `--document-type` filters on `document_type`
+- `--offset` skips rows after filtering
+- `--input-size` limits rows after filtering and offset
+
+That means `--language Arabic --offset 100 --input-size 50` selects rows 101 to 150 from the Arabic-only subset, not from the full split.
+
+## Development in This Repo
+
+The public contributor entrypoints are Pixi tasks:
+
+```bash
+pixi run format
+pixi run lint
+pixi run typecheck
+pixi run test
+pixi run package-check
+```
+
+Coverage from a repo checkout:
+
+```bash
+pixi run coverage
+```
+
+## Package Check
+
+`pixi run package-check` runs the repo-local publish gate defined in `scripts/package_check.py`. It validates the built artifacts, not the editable checkout.
+
+The current package check does all of the following:
+- removes stale `build/`, `dist/`, and generated egg-info directories
+- builds a fresh wheel and sdist
+- runs `twine check` on both artifacts
+- verifies the wheel metadata, including project URLs, extras, and the `churro-ocr` console entry point
+- verifies that repo-only content such as `tests/`, `tooling/`, `scripts/`, `REPO_WORKFLOWS.md`, and `PYPI_AUDIT.md` do not ship inside the artifacts
+- smoke-installs the base wheel and base sdist in clean virtual environments and checks `import churro_ocr`, `import churro_ocr.providers`, and `python -m churro_ocr --help`
+- smoke-installs the lightweight `local` and `pdf` extras from the built wheel
+- audits direct dependency licenses for incompatible or unknown licenses
+
+If `package-check` fails, treat that as a release blocker until the artifact or documentation contract is fixed.
diff --git a/__init__.py b/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/args.py b/args.py
deleted file mode 100644
index d48b395..0000000
--- a/args.py
+++ /dev/null
@@ -1,145 +0,0 @@
-import argparse
-from pathlib import Path
-import shutil
-import sys
-
-from churro.utils.llm.models import MODEL_MAP
-from churro.utils.log_utils import logger
-
-
-# Central place to define canonical HF identifiers
-CHURRO_DATASET_ID: str = "stanford-oval/churro-dataset"
-
-
-def build_parser(*, add_help: bool = True) -> argparse.ArgumentParser:
- """Create the benchmark CLI argument parser without parsing."""
- parser = argparse.ArgumentParser(add_help=add_help)
-
- parser.add_argument(
- "--system",
- required=True,
- choices=[
- "azure",
- "mistral_ocr",
- "llm",
- "finetuned",
- ],
- help="Specify the system to run.",
- )
- parser.add_argument(
- "--engine",
- type=str,
- default=None,
- help="For LLM baseline, specify the LLM to use.",
- )
- parser.add_argument(
- "--tensor-parallel-size",
- type=int,
- default=1,
- help="Tensor parallel size. Only used for local models.",
- )
- parser.add_argument(
- "--data-parallel-size",
- type=int,
- default=1,
- help="Data parallel size. Only used for local models.",
- )
- parser.add_argument(
- "--resize",
- type=int,
- default=None,
- help="If set, will resize large images to fit inside a square of this size (in pixels). ",
- )
- parser.add_argument(
- "--max-concurrency",
- type=int,
- default=50,
- help="Maximum number of LLM requests to allow at once.",
- )
- parser.add_argument(
- "--input-size",
- type=int,
- default=0,
- help="Number of images to process. 0 means all images.",
- )
- parser.add_argument(
- "--dataset-split",
- required=True,
- type=str,
- choices=["dev", "test"],
- help="Data split to use.",
- )
- parser.add_argument("--offset", type=int, default=0, help="Offset for the input images.")
-
- return parser
-
-
-def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
- """Parse command line arguments and validate."""
- parser = build_parser()
- args = parser.parse_args(argv)
- _validate_args(args)
- return args
-
-
-def _validate_args(args: argparse.Namespace) -> None:
- """Validate argument combinations."""
- if args.system == "llm":
- assert args.engine is not None, "LLM engine must be specified for LLM baseline."
- valid_engines = ", ".join(sorted(MODEL_MAP.keys()))
- assert args.engine in MODEL_MAP, (
- f"Invalid engine: {args.engine}. Possible values are: {valid_engines}"
- )
-
-
-def create_output_prefix(args: argparse.Namespace) -> str:
- """Create output directory path based on arguments."""
- base_workdir = Path(__file__).resolve().parent / "workdir"
- output_dir = base_workdir / "results" / args.dataset_split / args.system
-
- if args.system not in ["azure", "mistral_ocr"]:
- engine_name = args.engine
- if "/" in engine_name:
- engine_name = args.engine.split("/")
- engine_name = [p for p in engine_name if "workdir" not in p and p]
- engine_name = "_".join(engine_name)
- output_dir = output_dir.parent / f"{output_dir.name}_{engine_name}"
-
- # If directory exists already, warn user and exit to avoid overwriting prior results.
- if output_dir.exists():
- try:
- # Abort (after confirmation) only if directory exists AND is not empty.
- if any(output_dir.iterdir()):
- if not sys.stdin.isatty():
- logger.warning(
- f"Output directory '{output_dir}' already exists and is not empty, "
- "but cannot prompt in non-interactive mode. Aborting to avoid overwrite."
- )
- raise SystemExit(1)
- response = (
- input(
- f"Output directory '{output_dir}' already exists and is not empty. "
- "Overwrite (this will delete existing contents)? [y/N]: "
- )
- .strip()
- .lower()
- )
- if response in {"y", "yes"}:
- try:
- shutil.rmtree(output_dir)
- output_dir.mkdir(parents=True, exist_ok=True)
- logger.info(f"Overwrote existing directory '{output_dir}'.")
- except OSError as exc:
- logger.error(f"Failed to overwrite directory '{output_dir}': {exc}")
- raise SystemExit(1) from exc
- else:
- logger.info("User declined overwrite. Aborting.")
- raise SystemExit(1)
- # Directory exists but is empty: reuse it.
- except OSError as exc:
- logger.warning(f"Could not inspect contents of '{output_dir}'. Aborting for safety.")
- raise SystemExit(1) from exc
- else:
- output_dir.mkdir(parents=True, exist_ok=True)
-
- return str(output_dir)
diff --git a/churro_transformers_infer.py b/churro_transformers_infer.py
deleted file mode 100644
index 14552f4..0000000
--- a/churro_transformers_infer.py
+++ /dev/null
@@ -1,192 +0,0 @@
-#!/usr/bin/env python3
-"""Run the fine-tuned Churro VLM on a single image using only Transformers and Pytorch.
-
-This script is a lightweight fallback for environments that cannot install the
-full Churro package. It loads the `stanford-oval/churro-3B` model from the
-Hugging Face Hub and transcribes a document page to XML.
-"""
-
-from __future__ import annotations
-
-import argparse
-from pathlib import Path
-from typing import Any
-
-from PIL import Image
-import torch
-from transformers import AutoModelForImageTextToText, AutoProcessor
-from transformers.image_utils import load_image
-
-
-DEFAULT_MODEL_ID = "stanford-oval/churro-3B"
-DEFAULT_SYSTEM_MESSAGE = "Transcribe the entiretly of this historical documents to XML format."
-MAX_IMAGE_DIM = 2500
-MIN_PIXELS = 512 * 28 * 28
-MAX_PIXELS = 5120 * 28 * 28
-
-
-def _parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Standalone Churro OCR inference")
- parser.add_argument("image", type=Path, help="Path to the page image (PNG, JPG, or WebP)")
- parser.add_argument(
- "--model-id",
- default=DEFAULT_MODEL_ID,
- help="Hugging Face model ID to load (defaults to stanford-oval/churro-3B)",
- )
- parser.add_argument(
- "--system-message",
- default=DEFAULT_SYSTEM_MESSAGE,
- help="System prompt to prepend before presenting the image",
- )
- parser.add_argument(
- "--max-new-tokens",
- type=int,
- default=20_000,
- help="Maximum number of tokens to generate",
- )
- parser.add_argument(
- "--temperature",
- type=float,
- default=0.6,
- help="Sampling temperature",
- )
- parser.add_argument(
- "--device",
- default="auto",
- choices=["auto", "cpu", "cuda"],
- help="Computation device. 'auto' picks CUDA when available",
- )
- return parser.parse_args()
-
-
-def _resize_image_to_fit(image: Image.Image, max_width: int, max_height: int) -> Image.Image:
- """Match Churro's LLM preprocessing guard (<=2500px on the longest side)."""
- width, height = image.size
- if width <= max_width and height <= max_height:
- return image
-
- scale = min(max_width / width, max_height / height)
- new_size = (max(1, int(width * scale)), max(1, int(height * scale)))
- if hasattr(Image, "Resampling"):
- resample_filter = Image.Resampling.LANCZOS
- else: # pragma: no cover - Pillow < 10 fallback
- resample_filter = Image.LANCZOS # type: ignore[attr-defined]
- return image.resize(new_size, resample=resample_filter)
-
-
-def _load_processor(model_id: str) -> AutoProcessor:
- """Instantiate the processor with the same pixel bounds used during fine-tuning."""
- processor_kwargs: dict[str, Any] = {"trust_remote_code": True}
- return AutoProcessor.from_pretrained(
- model_id,
- min_pixels=MIN_PIXELS,
- max_pixels=MAX_PIXELS,
- **processor_kwargs,
- )
-
-
-def _select_device(preference: str) -> torch.device:
- if preference == "cpu":
- return torch.device("cpu")
- if preference == "cuda":
- if not torch.cuda.is_available():
- raise RuntimeError("CUDA requested but no GPU is available")
- return torch.device("cuda")
- return torch.device("cuda" if torch.cuda.is_available() else "cpu")
-
-
-def _prepare_inputs(
- processor: AutoProcessor,
- image_path: Path,
- system_message: str,
- device: torch.device,
-) -> dict[str, Any]:
- image = load_image(str(image_path))
- if not isinstance(image, Image.Image): # pragma: no cover - defensive
- raise TypeError(f"Unexpected image type: {type(image)!r}")
- image = image.convert("RGB")
- image = _resize_image_to_fit(image, MAX_IMAGE_DIM, MAX_IMAGE_DIM)
- conversation = [
- {"role": "system", "content": [{"type": "text", "text": system_message}]},
- {"role": "user", "content": [{"type": "image", "image": image}]},
- ]
- prompt = processor.apply_chat_template(
- conversation,
- tokenize=False,
- add_generation_prompt=True,
- )
- encoded = processor(
- text=[prompt],
- images=[image],
- return_tensors="pt",
- )
- encoded = {
- key: value.to(device) for key, value in encoded.items() if isinstance(value, torch.Tensor)
- }
- encoded["prompt_text"] = prompt
- encoded["conversation"] = conversation
- return encoded
-
-
-def _run_generation(
- model: AutoModelForImageTextToText,
- processor: AutoProcessor,
- inputs: dict[str, Any],
- max_new_tokens: int,
- temperature: float,
-) -> str:
- input_ids = inputs["input_ids"]
- input_length = input_ids.shape[1]
- generation_kwargs: dict[str, Any] = {
- "max_new_tokens": max_new_tokens,
- "do_sample": temperature > 0,
- }
- if temperature > 0:
- generation_kwargs["temperature"] = temperature
- if processor.tokenizer.pad_token_id is not None:
- generation_kwargs.setdefault("pad_token_id", processor.tokenizer.pad_token_id)
- if processor.tokenizer.eos_token_id is not None:
- generation_kwargs.setdefault("eos_token_id", processor.tokenizer.eos_token_id)
-
- with torch.inference_mode():
- generated = model.generate(
- **{k: v for k, v in inputs.items() if isinstance(v, torch.Tensor)}, **generation_kwargs
- )
-
- new_tokens = generated[0, input_length:]
- transcription = processor.tokenizer.decode(new_tokens, skip_special_tokens=True)
- return transcription.strip()
-
-
-def main() -> None:
- args = _parse_args()
- if not args.image.exists():
- raise FileNotFoundError(f"Image not found: {args.image}")
-
- device = _select_device(args.device)
- dtype = torch.bfloat16 if device.type == "cuda" else torch.float32
-
- processor = _load_processor(args.model_id)
- model = AutoModelForImageTextToText.from_pretrained(
- args.model_id,
- dtype=dtype,
- trust_remote_code=True,
- low_cpu_mem_usage=True,
- )
- model.to(device)
- model.eval()
-
- inputs = _prepare_inputs(processor, args.image, args.system_message, device)
- transcription = _run_generation(
- model,
- processor,
- inputs,
- max_new_tokens=args.max_new_tokens,
- temperature=args.temperature,
- )
-
- print(transcription)
-
-
-if __name__ == "__main__":
- main()
diff --git a/cli/__init__.py b/cli/__init__.py
deleted file mode 100644
index 37e6436..0000000
--- a/cli/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Unified command-line interface for OCR workflows."""
-
-from .main import main
-
-
-__all__ = ["main"]
diff --git a/cli/__main__.py b/cli/__main__.py
deleted file mode 100644
index f55b584..0000000
--- a/cli/__main__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from __future__ import annotations
-
-import sys
-
-from .main import main
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/cli/benchmark.py b/cli/benchmark.py
deleted file mode 100644
index 150ca3d..0000000
--- a/cli/benchmark.py
+++ /dev/null
@@ -1,105 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from time import time
-
-from datasets import load_dataset
-
-from churro.args import CHURRO_DATASET_ID, create_output_prefix
-from churro.evaluation.metrics import compute_metrics
-from churro.systems.ocr_factory import OCRFactory
-from churro.utils.image.binarizer import ImageBinarizer
-from churro.utils.llm.models import MODEL_MAP
-from churro.utils.log_utils import logger
-
-from .helpers import managed_vllm_container
-
-
-VALID_DATASET_SPLITS = {"dev", "test"}
-
-
-@dataclass(slots=True)
-class BenchmarkOptions:
- system: str
- engine: str | None
- tensor_parallel_size: int
- data_parallel_size: int
- resize: int | None
- max_concurrency: int
- input_size: int
- dataset_split: str
- offset: int
- binarize: bool = False
-
-
-def _validate_options(options: BenchmarkOptions) -> int:
- if options.system == "llm":
- if not options.engine:
- logger.error("LLM engine must be specified for the LLM baseline.")
- return 1
- if options.engine not in MODEL_MAP:
- valid_engines = ", ".join(sorted(MODEL_MAP.keys()))
- logger.error(f"Invalid engine: {options.engine}. Possible values are: {valid_engines}")
- return 1
- if options.dataset_split not in VALID_DATASET_SPLITS:
- valid = ", ".join(sorted(VALID_DATASET_SPLITS))
- logger.error(f"Invalid dataset split '{options.dataset_split}'. Choose from: {valid}.")
- return 1
- return 0
-
-
-async def run(options: BenchmarkOptions) -> int:
- """Execute the benchmark workflow using the provided options."""
- validation_status = _validate_options(options)
- if validation_status != 0:
- return validation_status
-
- output_prefix = create_output_prefix(options) # type: ignore[arg-type]
-
- start_index = options.offset
- end_index = options.offset + options.input_size if options.input_size > 0 else None
-
- logger.info(
- f"Loading dataset slice: split={options.dataset_split}, offset={options.offset}, "
- f"limit={options.input_size if end_index is None else options.input_size}"
- )
- dataset = list(load_dataset(CHURRO_DATASET_ID, split=options.dataset_split, streaming=True))
- dataset = dataset[start_index:end_index]
-
- elapsed_time = 0.0
- binarizer: ImageBinarizer | None = None
- if options.binarize:
- logger.info("Binarizing dataset images prior to benchmarking.")
- try:
- binarizer = ImageBinarizer()
- except Exception as exc: # pragma: no cover - defensive guard
- logger.error(f"Failed to initialize image binarizer: {exc}")
- return 1
- with managed_vllm_container(
- engine=options.engine,
- backup_engine=None,
- system=options.system,
- tensor_parallel_size=options.tensor_parallel_size,
- data_parallel_size=options.data_parallel_size,
- ):
- ocr_system = OCRFactory.create_ocr_system(options) # type: ignore[arg-type]
- start_time = time()
- images = [example["image"] for example in dataset]
- if binarizer is not None:
- try:
- images = binarizer.binarize_pil_batch(images)
- except Exception as exc: # pragma: no cover - defensive guard
- logger.error(f"Binarizer batch inference failed: {exc}")
- return 1
- predicted_texts = await ocr_system.process_images(
- images,
- max_concurrency=options.max_concurrency,
- )
- elapsed_time = time() - start_time
-
- assert len(dataset) == len(predicted_texts), (
- f"Mismatch in number of examples ({len(dataset)}) and predicted texts ({len(predicted_texts)})."
- )
-
- compute_metrics(dataset, predicted_texts, output_prefix, elapsed_time)
- return 0
diff --git a/cli/docs_to_images.py b/cli/docs_to_images.py
deleted file mode 100644
index 9fc07b7..0000000
--- a/cli/docs_to_images.py
+++ /dev/null
@@ -1,180 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Sequence
-from dataclasses import dataclass
-from pathlib import Path
-
-import typer
-
-from churro.utils.concurrency import TqdmProgressReporter
-from churro.utils.llm import shutdown_llm_clients
-from churro.utils.llm.models import MODEL_MAP
-from churro.utils.log_utils import logger
-from churro.utils.pdf import run_pdf_pipeline
-from churro.utils.pdf.runner import (
- SUPPORTED_IMAGE_EXTENSIONS,
- default_splitter_factory,
- default_trimmer_factory,
-)
-
-
-DEFAULT_EXTENSIONS: tuple[str, ...] = (".pdf", ".jpg", ".jpeg", ".png", ".tiff", ".tif")
-DEFAULT_ENGINE = "gemini-2.5-pro-low"
-DEFAULT_PATTERN = "*"
-# Fallback DPI used when native page resolution cannot be determined.
-DEFAULT_DPI = 300
-DEFAULT_BATCH_PAGES = 16
-DEFAULT_QUEUE_MAXSIZE = 64
-DEFAULT_LLM_CONCURRENCY_LIMIT = 64
-
-
-@dataclass(slots=True)
-class DocsToImagesOptions:
- input_dir: Path | None
- input_file: Path | None
- recursive: bool
- pattern: str
- extensions: Sequence[str]
- output_dir: Path
- engine: str
- dpi: int | None
- batch_pages: int
- queue_maxsize: int
- raster_workers: int | None
- page_workers: int | None
- llm_concurrency_limit: int
- no_trim: bool
- dry_run: bool
-
-
-def _normalise_extensions(raw_exts: Sequence[str]) -> list[str]:
- cleaned: list[str] = []
- seen: set[str] = set()
- for ext in raw_exts:
- ext = ext.strip().lower()
- if not ext:
- continue
- if not ext.startswith("."):
- ext = f".{ext}"
- if ext in seen:
- continue
- seen.add(ext)
- cleaned.append(ext)
- return cleaned
-
-
-def _collect_inputs(
- input_dir: Path | None,
- input_file: Path | None,
- recursive: bool,
- pattern: str,
- extensions: list[str],
-) -> tuple[list[str], list[str]]:
- pdfs: list[str] = []
- images: list[str] = []
-
- def _add_path(path: Path) -> None:
- ext = path.suffix.lower()
- if ext == ".pdf":
- pdfs.append(str(path))
- elif ext in SUPPORTED_IMAGE_EXTENSIONS:
- images.append(str(path))
- else:
- logger.warning(f"Skipping unsupported extension '{ext}' for file {path}")
-
- if input_file:
- if input_file.is_file():
- if input_file.suffix.lower() in extensions:
- _add_path(input_file)
- else:
- logger.warning(
- f"--input-file {input_file} does not match the provided extensions; ignoring."
- )
- else:
- logger.warning(f"--input-file {input_file} is not a file; ignoring.")
-
- if input_dir:
- glob_pattern = f"**/{pattern}" if recursive else pattern
- for path in input_dir.glob(glob_pattern):
- if not path.is_file():
- continue
- if path.suffix.lower() not in extensions:
- continue
- _add_path(path)
-
- def _dedupe(values: list[str]) -> list[str]:
- seen: set[str] = set()
- ordered: list[str] = []
- for value in values:
- if value in seen:
- continue
- seen.add(value)
- ordered.append(value)
- return ordered
-
- return _dedupe(pdfs), _dedupe(images)
-
-
-def _validate_engine(engine: str) -> None:
- if engine not in MODEL_MAP:
- keys_preview = ", ".join(list(MODEL_MAP.keys())[:10])
- logger.error(
- f"Engine '{engine}' not found in MODEL_MAP. Available keys (first 10): {keys_preview} ..."
- )
- raise typer.Exit(code=2)
-
-
-async def run(options: DocsToImagesOptions) -> int:
- _validate_engine(options.engine)
-
- extensions = _normalise_extensions(options.extensions)
- if not extensions:
- logger.warning("No valid extensions provided.")
- return 0
-
- supported_extensions = [
- ext for ext in extensions if ext == ".pdf" or ext in SUPPORTED_IMAGE_EXTENSIONS
- ]
- if not supported_extensions:
- logger.warning("No supported extensions remain after filtering.")
- return 0
-
- pdf_files, image_files = _collect_inputs(
- input_dir=options.input_dir,
- input_file=options.input_file,
- recursive=options.recursive,
- pattern=options.pattern,
- extensions=supported_extensions,
- )
-
- if options.dry_run:
- for path in pdf_files + image_files:
- logger.info(f"DRY RUN: {path}")
- return 0
-
- if not pdf_files and not image_files:
- logger.warning("No valid PDF or image inputs provided to pipeline.")
- return 0
-
- progress = TqdmProgressReporter("docs-to-images")
- try:
- await run_pdf_pipeline(
- pdf_paths=pdf_files,
- output_dir=str(options.output_dir),
- engine=options.engine,
- dpi=options.dpi,
- batch_pages=options.batch_pages,
- queue_maxsize=options.queue_maxsize,
- raster_workers=options.raster_workers,
- page_workers=options.page_workers,
- llm_concurrency_limit=options.llm_concurrency_limit,
- trim=not options.no_trim,
- image_paths=image_files,
- splitter_factory=default_splitter_factory,
- trimmer_factory=default_trimmer_factory,
- progress_reporter=progress,
- )
- finally:
- progress.close()
- await shutdown_llm_clients()
- return 0
diff --git a/cli/helpers.py b/cli/helpers.py
deleted file mode 100644
index af42411..0000000
--- a/cli/helpers.py
+++ /dev/null
@@ -1,46 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Generator
-import contextlib
-from typing import Any
-
-from churro.utils.docker.vllm import maybe_start_vllm_server_for_engine
-from churro.utils.log_utils import logger
-
-
-@contextlib.contextmanager
-def managed_vllm_container(
- *,
- engine: str | None,
- backup_engine: str | None,
- system: str,
- tensor_parallel_size: int,
- data_parallel_size: int,
-) -> Generator[Any, None, None]:
- """Start a vLLM container when needed and ensure it stops on exit."""
- container = maybe_start_vllm_server_for_engine(
- engine=engine,
- system=system,
- tensor_parallel_size=tensor_parallel_size,
- data_parallel_size=data_parallel_size,
- )
- backup_container: Any | None = None
- if backup_engine and backup_engine != engine:
- backup_container = maybe_start_vllm_server_for_engine(
- engine=backup_engine,
- system=system,
- tensor_parallel_size=tensor_parallel_size,
- data_parallel_size=data_parallel_size,
- log_prefix="[backup]",
- )
- try:
- yield container
- finally:
- if backup_container is not None:
- logger.info("Stopping backup vLLM container...")
- with contextlib.suppress(Exception):
- backup_container.stop()
- if container is not None:
- logger.info("Stopping vLLM container...")
- with contextlib.suppress(Exception):
- container.stop()
diff --git a/cli/infer.py b/cli/infer.py
deleted file mode 100644
index 2459c8c..0000000
--- a/cli/infer.py
+++ /dev/null
@@ -1,320 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-import json
-from pathlib import Path
-import re
-
-from PIL import Image
-
-from churro.systems.llm_improver import LLMImprover
-from churro.systems.ocr_factory import OCRFactory
-from churro.utils.image.binarizer import ImageBinarizer
-from churro.utils.llm.models import MODEL_MAP
-from churro.utils.log_utils import logger
-
-from .helpers import managed_vllm_container
-
-
-ALLOWED_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
-SYSTEM_CHOICES = ["azure", "mistral_ocr", "llm", "finetuned"]
-DEFAULT_PATTERN = "*.png"
-DEFAULT_SUFFIXES: list[str] = [".png"]
-DEFAULT_MAX_CONCURRENCY = 64
-DEFAULT_IMPROVER_ENGINE = "gemini-2.5-pro-low"
-DEFAULT_IMPROVER_BACKUP_ENGINE = "gpt-5-low"
-
-
-@dataclass(slots=True)
-class InferOptions:
- system: str
- engine: str | None
- backup_engine: str | None
- tensor_parallel_size: int
- data_parallel_size: int
- image: Path | None
- image_dir: Path | None
- pattern: str
- suffixes: list[str]
- recursive: bool
- output_dir: Path | None
- skip_existing: bool
- max_concurrency: int
- strip_xml: bool = False
- use_improver: bool = False
- improver_engine: str | None = None
- improver_backup_engine: str | None = None
- improver_resize: int | None = None
- output_markdown: bool = False
- binarize: bool = False
-
-
-def _validate_options(options: InferOptions) -> int:
- systems_requiring_engine = {"llm", "finetuned"}
- valid_engines_string: str = json.dumps(sorted(MODEL_MAP.keys()), indent=2)
- if options.system in systems_requiring_engine:
- if not options.engine:
- logger.error(f"--engine is required when using system '{options.system}'.")
- return 1
- if options.engine not in MODEL_MAP:
- logger.error(
- f"Invalid --engine '{options.engine}'. Available options: {valid_engines_string}."
- )
- return 1
- elif options.engine and options.engine not in MODEL_MAP:
- logger.warning(
- f"--engine '{options.engine}' not found in MODEL_MAP; continuing without vLLM validation."
- )
- if options.backup_engine and options.backup_engine not in MODEL_MAP:
- logger.error(
- f"Invalid --backup-engine '{options.backup_engine}'. Available options: {valid_engines_string}."
- )
- return 1
- if options.strip_xml and options.system != "finetuned":
- logger.warning("--strip-xml only affects the 'finetuned' system.")
- if options.output_markdown and options.system != "llm":
- logger.error("--output-markdown is only supported when --system llm.")
- return 1
- improver_fields_provided = any(
- [
- options.improver_engine,
- options.improver_backup_engine,
- options.improver_resize is not None,
- ]
- )
- if options.use_improver:
- if not options.improver_engine:
- options.improver_engine = DEFAULT_IMPROVER_ENGINE
- logger.info(
- f"No --improver-engine provided; defaulting to '{DEFAULT_IMPROVER_ENGINE}'."
- )
- if options.improver_engine not in MODEL_MAP:
- logger.error(
- f"Invalid --improver-engine '{options.improver_engine}'. "
- f"Available options: {valid_engines_string}."
- )
- return 1
- if not options.improver_backup_engine:
- options.improver_backup_engine = DEFAULT_IMPROVER_BACKUP_ENGINE
- logger.info(
- f"No --improver-backup-engine provided; defaulting to '{DEFAULT_IMPROVER_BACKUP_ENGINE}'."
- )
- if options.improver_backup_engine and options.improver_backup_engine not in MODEL_MAP:
- logger.error(
- f"Invalid --improver-backup-engine '{options.improver_backup_engine}'. "
- f"Available options: {valid_engines_string}."
- )
- return 1
- elif improver_fields_provided:
- logger.warning(
- "Improver options provided without --use-improver; OCR outputs will not be post-processed."
- )
- invalid_suffixes = [s for s in options.suffixes if s not in ALLOWED_IMAGE_EXTS]
- if invalid_suffixes:
- preview = ", ".join(invalid_suffixes)
- logger.warning(f"Ignoring unsupported suffixes for infer command: {preview}")
- options.suffixes = [s for s in options.suffixes if s in ALLOWED_IMAGE_EXTS]
- if not options.suffixes:
- logger.error("No valid suffixes remain after filtering unsupported values.")
- return 1
- return 0
-
-
-def _collect_images(
- image: Path | None,
- image_dir: Path | None,
- suffixes: list[str],
- recursive: bool,
-) -> list[Path]:
- images: list[Path] = []
- if image and image.is_file():
- if image.suffix.lower() in ALLOWED_IMAGE_EXTS:
- images.append(image)
- else: # pragma: no cover - defensive
- logger.warning(f"--image {image} does not have a supported extension; skipping.")
- if image_dir and image_dir.is_dir():
- suffix_filter = {s.lower() for s in suffixes}
- iterator = image_dir.rglob("*") if recursive else image_dir.iterdir()
- for path in iterator:
- if not path.is_file():
- continue
- ext = path.suffix.lower()
- if ext in ALLOWED_IMAGE_EXTS and ext in suffix_filter:
- images.append(path)
- seen: set[str] = set()
- ordered: list[Path] = []
- for path in images:
- key = str(path)
- if key not in seen:
- seen.add(key)
- ordered.append(path)
- return ordered
-
-
-def _natural_key(path: Path) -> tuple[object, ...]:
- parts = re.split(r"(\d+)", path.name)
- return tuple(int(p) if p.isdigit() else p.lower() for p in parts)
-
-
-def _write_or_print_output(
- *,
- img_path: Path,
- text: str,
- output_dir: Path | None,
- skip_existing: bool,
- multi_mode: bool,
-) -> None:
- out_path: Path | None = None
- if output_dir:
- out_path = output_dir / (img_path.stem + ".txt")
- if skip_existing and out_path.exists():
- logger.info(f"Skipping existing {out_path.name}")
- return
- if out_path is not None:
- out_path.write_text(text)
- logger.info(f"Wrote {out_path}")
- else:
- if multi_mode:
- header = f"===== {img_path} ====="
- print(header)
- print(text)
-
-
-async def run(options: InferOptions) -> int:
- """Execute the ad-hoc inference workflow."""
- validation_status = _validate_options(options)
- if validation_status != 0:
- return validation_status
-
- if not options.image and not options.image_dir:
- logger.error("Either --image or --image-dir must be provided.")
- return 1
- if options.image and options.image_dir:
- logger.error("Specify only one of --image or --image-dir.")
- return 1
-
- images = _collect_images(
- options.image,
- options.image_dir,
- options.suffixes,
- options.recursive,
- )
- images = sorted(images, key=_natural_key)
- if not images:
- logger.error("No images found to process.")
- return 1
-
- original_image_paths = [str(path) for path in images]
- binarized_images: list[Image.Image] | None = None
- opened_originals: list[Image.Image] = []
- try:
- if options.binarize:
- logger.info(f"Binarizing {len(images)} input image(s) prior to OCR.")
- try:
- binarizer = ImageBinarizer()
- except Exception as exc: # pragma: no cover - defensive guard
- logger.error(f"Failed to initialize image binarizer: {exc}")
- return 1
- for img_path in images:
- try:
- opened_image = Image.open(img_path)
- except Exception as exc: # pragma: no cover - defensive guard
- logger.error(f"Failed to open {img_path} for binarization: {exc}")
- for image in opened_originals:
- image.close()
- return 1
- opened_originals.append(opened_image)
-
- try:
- binarized_images = binarizer.binarize_pil_batch(opened_originals)
- except Exception as exc: # pragma: no cover - defensive guard
- logger.error(f"Binarizer batch inference failed: {exc}")
- return 1
-
- if len(binarized_images) != len(images): # pragma: no cover - defensive guard
- logger.error(
- f"Binarizer returned {len(binarized_images)} outputs for {len(images)} inputs."
- )
- for image in binarized_images:
- image.close()
- return 1
-
- multi_mode = len(images) > 1
- if options.output_dir:
- options.output_dir.mkdir(parents=True, exist_ok=True)
-
- max_concurrency = options.max_concurrency if options.max_concurrency > 0 else 1
- if max_concurrency != options.max_concurrency:
- logger.warning("--max-concurrency < 1 ignored; defaulting to 1")
-
- with managed_vllm_container(
- engine=options.engine,
- backup_engine=options.backup_engine,
- system=options.system,
- tensor_parallel_size=options.tensor_parallel_size,
- data_parallel_size=options.data_parallel_size,
- ):
- ocr_system = OCRFactory.create_ocr_system(options) # type: ignore[arg-type]
- try:
- if binarized_images is not None:
- processed_outputs = await ocr_system.process_images(
- binarized_images,
- max_concurrency=max_concurrency,
- )
- else:
- processed_outputs = await ocr_system.process_images_from_files(
- original_image_paths,
- max_concurrency,
- )
- except RuntimeError as exc:
- logger.error(f"OCR processing failed: {exc}")
- return 1
- outputs: list[str] = processed_outputs
-
- if options.use_improver:
- improver_engine = options.improver_engine
- if not improver_engine:
- logger.error("Internal error: --use-improver enabled without --improver-engine.")
- return 1
- backup_suffix = (
- f" and backup engine '{options.improver_backup_engine}'"
- if options.improver_backup_engine
- else ""
- )
- logger.info(f"Running LLMImprover with engine '{improver_engine}'{backup_suffix}.")
- improver = LLMImprover(
- engine=improver_engine,
- backup_engine=options.improver_backup_engine,
- resize=options.improver_resize,
- image_fidelity="high",
- )
- outputs = await improver.process_batch_inputs(
- image_paths=original_image_paths,
- texts=outputs,
- max_concurrency=max_concurrency,
- )
-
- for img_path, text in zip(images, outputs, strict=False):
- _write_or_print_output(
- img_path=img_path,
- text=text,
- output_dir=options.output_dir,
- skip_existing=options.skip_existing,
- multi_mode=multi_mode,
- )
-
- if multi_mode:
- logger.info(f"Processed {len(images)} image(s).")
- return 0
- finally:
- if binarized_images is not None:
- for image in binarized_images:
- try:
- image.close()
- except Exception: # pragma: no cover - best effort cleanup
- pass
- for image in opened_originals:
- try:
- image.close()
- except Exception: # pragma: no cover - best effort cleanup
- pass
diff --git a/cli/main.py b/cli/main.py
deleted file mode 100644
index 85f7f8c..0000000
--- a/cli/main.py
+++ /dev/null
@@ -1,493 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-from collections.abc import Callable, Coroutine, Sequence
-from functools import wraps
-import os
-from pathlib import Path
-from typing import Any, ParamSpec, TypeVar
-
-import typer # type: ignore[import]
-
-from churro.systems.detect_layout import (
- log_total_azure_cost,
- log_total_google_document_ai_cost,
-)
-from churro.systems.ocr_factory import OCRFactory
-from churro.utils.llm import log_total_llm_cost
-from churro.utils.log_utils import logger
-
-from . import benchmark, docs_to_images, infer, text_to_historical_doc_xml
-
-
-os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
-
-
-app = typer.Typer(
- help="Churro OCR command-line interface",
-)
-
-
-_P = ParamSpec("_P")
-_T = TypeVar("_T")
-
-
-def _normalize_suffixes(suffixes: list[str] | None, *, default: Sequence[str]) -> list[str]:
- """Convert bare suffix tokens (e.g. 'pdf') into dotted extensions."""
- if not suffixes:
- return list(default)
- cleaned: list[str] = []
- seen: set[str] = set()
- for suffix in suffixes:
- token = suffix.strip().lower()
- if not token:
- continue
- if token.startswith("."):
- token = token[1:]
- ext = f".{token}"
- if ext in seen:
- continue
- seen.add(ext)
- cleaned.append(ext)
- return cleaned or list(default)
-
-
-def _synchronous(handler: Callable[_P, Coroutine[Any, Any, _T]]) -> Callable[_P, _T]:
- @wraps(handler)
- def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
- try:
- return asyncio.run(handler(*args, **kwargs))
- except KeyboardInterrupt as err:
- logger.info("Interrupted by user")
- raise typer.Exit(code=130) from err
-
- return wrapper
-
-
-@app.command("docs-to-images")
-@_synchronous
-async def docs_to_images_command(
- input_dir: Path | None = typer.Option(
- None,
- "--input-dir",
- help="Directory containing input files.",
- exists=True,
- file_okay=False,
- dir_okay=True,
- readable=True,
- ),
- input_file: Path | None = typer.Option(
- None,
- "--input-file",
- help="Single file to process.",
- exists=True,
- file_okay=True,
- dir_okay=False,
- readable=True,
- ),
- recursive: bool = typer.Option(
- False,
- "--recursive",
- help="Recurse into subdirectories when using --input-dir.",
- ),
- suffix: list[str] | None = typer.Option(
- None,
- "--suffix",
- "-s",
- help="File suffix without dot (e.g. pdf, png). Repeat to add more.",
- ),
- output_dir: Path = typer.Option(
- ...,
- "--output-dir",
- help="Destination directory for output PNG files (created if missing).",
- file_okay=False,
- dir_okay=True,
- writable=True,
- ),
- engine: str = typer.Option(
- docs_to_images.DEFAULT_ENGINE,
- "--engine",
- help="Logical model key for page splitting (MODEL_MAP).",
- show_default=True,
- ),
- dpi: int | None = typer.Option(
- None,
- "--dpi",
- help="Rasterization DPI. Defaults to native page DPI when available.",
- show_default=False,
- ),
- batch_pages: int = typer.Option(
- docs_to_images.DEFAULT_BATCH_PAGES,
- "--batch-pages",
- help="Number of PDF pages per raster batch.",
- show_default=True,
- ),
- queue_maxsize: int = typer.Option(
- docs_to_images.DEFAULT_QUEUE_MAXSIZE,
- "--queue-maxsize",
- help="Maximum queue size for raster & processed queues.",
- show_default=True,
- ),
- raster_workers: int | None = typer.Option(
- None,
- "--raster-workers",
- help="Override number of raster process pool workers.",
- ),
- page_workers: int | None = typer.Option(
- None,
- "--page-workers",
- help="Override number of async page processing workers.",
- ),
- llm_concurrency_limit: int = typer.Option(
- docs_to_images.DEFAULT_LLM_CONCURRENCY_LIMIT,
- "--llm-concurrency-limit",
- help="Max simultaneous LLM calls for page splitting.",
- show_default=True,
- ),
- no_trim: bool = typer.Option(
- False,
- "--no-trim",
- help="Disable layout-based margin trimming.",
- ),
- dry_run: bool = typer.Option(
- False,
- "--dry-run",
- help="List the PDFs that would be processed then exit without running pipeline.",
- ),
-) -> int:
- if (input_dir is None) == (input_file is None):
- raise typer.BadParameter(
- "Provide exactly one of --input-dir or --input-file.",
- param_hint="--input-dir/--input-file",
- )
-
- options = docs_to_images.DocsToImagesOptions(
- input_dir=input_dir,
- input_file=input_file,
- recursive=recursive,
- pattern=docs_to_images.DEFAULT_PATTERN,
- extensions=_normalize_suffixes(suffix, default=docs_to_images.DEFAULT_EXTENSIONS),
- output_dir=output_dir,
- engine=engine,
- dpi=dpi,
- batch_pages=batch_pages,
- queue_maxsize=queue_maxsize,
- raster_workers=raster_workers,
- page_workers=page_workers,
- llm_concurrency_limit=llm_concurrency_limit,
- no_trim=no_trim,
- dry_run=dry_run,
- )
- result = await docs_to_images.run(options)
- log_total_llm_cost()
- log_total_azure_cost()
- log_total_google_document_ai_cost()
- if result != 0:
- raise typer.Exit(code=result)
- return result
-
-
-@app.command("infer")
-@_synchronous
-async def infer_command(
- system: str = typer.Option(
- ...,
- "--system",
- help=f"OCR system identifier. Choices: {', '.join(infer.SYSTEM_CHOICES)}.",
- ),
- engine: str | None = typer.Option(
- None,
- "--engine",
- help="Logical engine key (MODEL_MAP) when the system requires an LLM backend.",
- ),
- backup_engine: str | None = typer.Option(
- None,
- "--backup-engine",
- help="Optional backup engine key (MODEL_MAP) for the OCR system.",
- ),
- tensor_parallel_size: int = typer.Option(
- 1,
- "--tensor-parallel-size",
- help="(vLLM only) Tensor parallel size for launched container.",
- show_default=True,
- ),
- data_parallel_size: int = typer.Option(
- 1,
- "--data-parallel-size",
- help="(vLLM only) Data parallel size for launched container.",
- show_default=True,
- ),
- image: Path | None = typer.Option(
- None,
- "--image",
- help="Path to a page image. If you have a PDF, rasterize first.",
- exists=True,
- file_okay=True,
- dir_okay=False,
- readable=True,
- ),
- image_dir: Path | None = typer.Option(
- None,
- "--image-dir",
- help="Directory containing images to batch transcribe.",
- exists=True,
- file_okay=False,
- dir_okay=True,
- readable=True,
- ),
- suffix: list[str] | None = typer.Option(
- None,
- "--suffix",
- "-s",
- help="Image suffix without dot (e.g. png). Repeat to add more.",
- ),
- recursive: bool = typer.Option(
- False,
- "--recursive",
- help="Recurse into subdirectories when using --image-dir.",
- ),
- output_dir: Path | None = typer.Option(
- None,
- "--output-dir",
- help="Write transcriptions to this directory as .txt (defaults to --image-dir when omitted).",
- file_okay=False,
- dir_okay=True,
- writable=True,
- ),
- skip_existing: bool = typer.Option(
- False,
- "--skip-existing",
- help="Skip images that already have a corresponding output .txt in --output-dir.",
- ),
- max_concurrency: int = typer.Option(
- infer.DEFAULT_MAX_CONCURRENCY,
- "--max-concurrency",
- help="Max concurrent image requests to vLLM server.",
- show_default=True,
- ),
- binarize: bool = typer.Option(
- False,
- "--binarize",
- help="Binarize input images with a neural model before OCR.",
- ),
- strip_xml: bool = typer.Option(
- False,
- "--strip-xml",
- help="Finetuned system outputs HistoricalDocument XML by default. Set this flag to output plain text instead.",
- ),
- output_markdown: bool = typer.Option(
- False,
- "--output-markdown",
- help="Instruct the LLM to output Markdown instead of plain text (LLM system only).",
- ),
- use_improver: bool = typer.Option(
- False,
- "--use-improver",
- help="Post-process OCR text with the LLMImprover.",
- ),
- improver_engine: str = typer.Option(
- infer.DEFAULT_IMPROVER_ENGINE,
- "--improver-engine",
- help="Logical engine key (MODEL_MAP) for the LLM improver.",
- show_default=True,
- ),
- improver_backup_engine: str = typer.Option(
- infer.DEFAULT_IMPROVER_BACKUP_ENGINE,
- "--improver-backup-engine",
- help="Optional backup engine key for the LLM improver.",
- show_default=True,
- ),
- improver_resize: int | None = typer.Option(
- None,
- "--improver-resize",
- help="Resize longest image side to this many pixels before improvement.",
- ),
-) -> int:
- if image is None and image_dir is None:
- raise typer.BadParameter(
- "Provide either --image or --image-dir.",
- param_hint="--image/--image-dir",
- )
- if image and image_dir:
- raise typer.BadParameter(
- "Specify only one of --image or --image-dir.",
- param_hint="--image/--image-dir",
- )
-
- system_key = system.lower()
-
- resolved_output_dir = output_dir
- if resolved_output_dir is None and image_dir is not None:
- resolved_output_dir = image_dir
- logger.info(
- "No --output-dir provided; using --image-dir as output destination.",
- )
-
- options = infer.InferOptions(
- system=system_key,
- engine=engine,
- backup_engine=backup_engine,
- tensor_parallel_size=tensor_parallel_size,
- data_parallel_size=data_parallel_size,
- image=image,
- image_dir=image_dir,
- pattern=infer.DEFAULT_PATTERN,
- suffixes=_normalize_suffixes(suffix, default=infer.DEFAULT_SUFFIXES),
- recursive=recursive,
- output_dir=resolved_output_dir,
- skip_existing=skip_existing,
- max_concurrency=max_concurrency,
- strip_xml=strip_xml,
- output_markdown=output_markdown,
- use_improver=use_improver,
- improver_engine=improver_engine if use_improver else None,
- improver_backup_engine=improver_backup_engine if use_improver else None,
- improver_resize=improver_resize,
- binarize=binarize,
- )
- result = await infer.run(options)
- log_total_llm_cost()
- log_total_azure_cost()
- log_total_google_document_ai_cost()
- if result != 0:
- raise typer.Exit(code=result)
- return result
-
-
-@app.command("text-to-historical-doc-xml")
-@_synchronous
-async def text_to_historical_doc_xml_command(
- input_dir: Path = typer.Argument(
- ...,
- exists=True,
- file_okay=False,
- dir_okay=True,
- readable=True,
- help="Directory containing matched PNG/TXT pairs.",
- ),
- engine: str = typer.Option(
- text_to_historical_doc_xml.DEFAULT_ENGINE,
- "--engine",
- help="Logical model key to use for XML generation.",
- show_default=True,
- ),
- max_concurrency: int = typer.Option(
- text_to_historical_doc_xml.DEFAULT_MAX_CONCURRENCY,
- "--max-concurrency",
- help="Maximum number of concurrent LLM calls.",
- show_default=True,
- ),
- corpus_description: str = typer.Option(
- "",
- "--corpus-description",
- help="Optional corpus description to include in prompts.",
- ),
- overwrite: bool = typer.Option(
- False,
- "--overwrite",
- help="Regenerate XML even if output already exists.",
- ),
-) -> None:
- await text_to_historical_doc_xml.run_text_to_historical_doc_xml(
- input_dir,
- engine,
- max_concurrency,
- corpus_description,
- overwrite,
- )
- log_total_llm_cost()
-
-
-@app.command("benchmark")
-@_synchronous
-async def benchmark_command(
- system: str = typer.Option(
- ...,
- "--system",
- help=f"Specify the system to run. Choices: {', '.join(OCRFactory.get_available_systems())}.",
- ),
- engine: str | None = typer.Option(
- None,
- "--engine",
- help="For LLM baseline, specify the LLM to use.",
- ),
- tensor_parallel_size: int = typer.Option(
- 1,
- "--tensor-parallel-size",
- help="Tensor parallel size. Only used for local models.",
- show_default=True,
- ),
- data_parallel_size: int = typer.Option(
- 1,
- "--data-parallel-size",
- help="Data parallel size. Only used for local models.",
- show_default=True,
- ),
- resize: int | None = typer.Option(
- None,
- "--resize",
- help="If set, resize large images to fit inside a square of this size (in pixels).",
- ),
- binarize: bool = typer.Option(
- False,
- "--binarize",
- help="Binarize dataset images with the ONNX model before benchmarking.",
- ),
- max_concurrency: int = typer.Option(
- 50,
- "--max-concurrency",
- help="Maximum number of LLM requests to allow at once.",
- show_default=True,
- ),
- input_size: int = typer.Option(
- 0,
- "--input-size",
- help="Number of images to process. 0 means all images.",
- show_default=True,
- ),
- dataset_split: str = typer.Option(
- ...,
- "--dataset-split",
- help="Data split to use (dev or test).",
- ),
- offset: int = typer.Option(
- 0,
- "--offset",
- help="Offset for the input images.",
- show_default=True,
- ),
-) -> int:
- system_key = system.lower()
- dataset_split_value = dataset_split.lower()
-
- options = benchmark.BenchmarkOptions(
- system=system_key,
- engine=engine,
- tensor_parallel_size=tensor_parallel_size,
- data_parallel_size=data_parallel_size,
- resize=resize,
- max_concurrency=max_concurrency,
- input_size=input_size,
- dataset_split=dataset_split_value,
- offset=offset,
- binarize=binarize,
- )
- result = await benchmark.run(options)
- log_total_llm_cost()
- log_total_azure_cost()
- log_total_google_document_ai_cost()
- if result != 0:
- raise typer.Exit(code=result)
- return result
-
-
-def main(argv: Sequence[str] | None = None) -> int:
- try:
- app(args=list(argv) if argv is not None else None, standalone_mode=False)
- return 0
- except SystemExit as exc:
- return int(exc.code or 0)
-
-
-if __name__ == "__main__": # pragma: no cover
- app()
diff --git a/cli/text_to_historical_doc_xml.py b/cli/text_to_historical_doc_xml.py
deleted file mode 100644
index db64c40..0000000
--- a/cli/text_to_historical_doc_xml.py
+++ /dev/null
@@ -1,319 +0,0 @@
-from __future__ import annotations
-
-import argparse
-import asyncio
-from dataclasses import dataclass
-from functools import partial
-from pathlib import Path
-import re
-
-from lxml import etree # type: ignore[import]
-import xmlschema # type: ignore[import]
-
-from churro.utils.concurrency import run_async_in_parallel
-from churro.utils.image.io import load_image_async
-from churro.utils.llm import log_total_llm_cost, run_llm_async
-from churro.utils.log_utils import logger
-
-
-SCHEMA_PATH = Path(__file__).resolve().parent.parent / "evaluation" / "historical_doc.xsd"
-_SCHEMA: xmlschema.XMLSchema | None = None
-_SCHEMA_TEXT: str | None = None
-
-DEFAULT_ENGINE = "gemini-2.5-pro-medium"
-DEFAULT_MAX_CONCURRENCY = 64
-
-
-@dataclass(frozen=True)
-class DocumentExample:
- """Container for paired image/text inputs and the target XML output path."""
-
- stem: str
- image_path: Path
- text_path: Path
- xml_path: Path
-
-
-def _load_schema() -> xmlschema.XMLSchema:
- global _SCHEMA
- if _SCHEMA is None:
- _SCHEMA = xmlschema.XMLSchema(str(SCHEMA_PATH))
- return _SCHEMA
-
-
-def get_historical_doc_xml_schema() -> str:
- global _SCHEMA_TEXT
- if _SCHEMA_TEXT is None:
- _SCHEMA_TEXT = SCHEMA_PATH.read_text(encoding="utf-8")
- return _SCHEMA_TEXT
-
-
-def get_historical_doc_xml_errors(xml: str) -> str | None:
- try:
- parser = etree.XMLParser(remove_blank_text=True)
- root = etree.fromstring(xml.encode("utf-8"), parser=parser)
- except etree.XMLSyntaxError as exc: # XML is malformed before schema validation
- return f"XMLSyntaxError: {exc}"
-
- schema = _load_schema()
- if schema.is_valid(root):
- return None
-
- try:
- schema.validate(root)
- except xmlschema.validators.exceptions.XMLSchemaValidationError as exc:
- return str(exc)
- except Exception as exc: # Defensive guard for unexpected validation issues
- return f"ValidationError: {exc}"
-
- return "Unknown validation failure"
-
-
-def normalize_xml_string(xml: str) -> str:
- xml = xml.strip()
- if not xml:
- return xml
- try:
- parser = etree.XMLParser(remove_blank_text=True)
- root = etree.fromstring(xml.encode("utf-8"), parser=parser)
- return etree.tostring(root, encoding="unicode")
- except etree.XMLSyntaxError:
- return xml
-
-
-def prettify_xml(xml: str) -> str:
- """Return formatted XML with consistent indentation."""
- try:
- parser = etree.XMLParser(remove_blank_text=True)
- root = etree.fromstring(xml.encode("utf-8"), parser=parser)
- return etree.tostring(root, encoding="unicode", pretty_print=True)
- except etree.XMLSyntaxError:
- return xml
-
-
-def strip_xml_tag(xml: str) -> str:
- if "```xml" in xml:
- last_xml = xml.rfind("```xml")
- end_xml = xml.find("```", last_xml + 1)
- if end_xml == -1:
- end_xml = len(xml)
- xml = xml[last_xml + 6 : end_xml].strip()
-
- xml = re.sub(r"", "", xml, flags=re.DOTALL).strip()
- xml = xml.replace(
- "",
- '',
- )
- return normalize_xml_string(xml)
-
-
-async def llm_fix_xml_syntax(xml: str, example: DocumentExample, engine: str) -> str:
- xml_errors = get_historical_doc_xml_errors(xml)
- if xml_errors is None:
- return xml
-
- logger.info(
- f"XML errors found for '{example.xml_path}': {xml_errors}. Fixing with another LLM call."
- )
- user_message = (
- f"You will be given an invalid XML with the following error: {xml_errors}.\n"
- "Minimally modify the XML so that it matches the provided schema while preserving the original content.\n\n"
- f"Schema:\n{get_historical_doc_xml_schema()}\n"
- f"Invalid XML:\n{xml}"
- )
-
- corrected_xml = await run_llm_async(
- model=engine,
- system_prompt_text="Output the entire fixed XML, do not output any other text.",
- user_message_text=user_message,
- user_message_image=None,
- )
- return strip_xml_tag(corrected_xml)
-
-
-async def llm_transcribe(
- example: DocumentExample,
- engine: str,
- corpus_description: str,
-) -> str:
- ocr_text = example.text_path.read_text(encoding="utf-8")
- if not ocr_text.strip():
- logger.warning(f"OCR text is empty in {example.text_path.name}")
-
- schema_text = get_historical_doc_xml_schema()
- corpus_line = (
- f"The document belongs to the corpus {corpus_description}.\n\n"
- if corpus_description
- else ""
- )
-
- with await load_image_async(example.image_path) as image:
- llm_output = await run_llm_async(
- model=engine,
- system_prompt_text=(
- "You are an expert XML generator. Output a single, valid XML document that adheres to "
- "the HistoricalDocument schema. Do not output explanations or markdown fences.\n\n"
- f"XSD Schema:\n{schema_text}"
- ),
- user_message_text=(
- "You are provided with a scanned historical document image and an OCR transcription.\n"
- "Produce a diplomatic transcription that preserves original spellings, punctuation, "
- "capitalization, and abbreviations. Correct OCR errors using the image as reference, "
- "add any missing but legible text, and follow the reading order of the document.\n\n"
- f"{corpus_line}"
- f"OCR Transcription:\n{ocr_text}\n\n"
- "Return the complete XML string that validates against the schema."
- ),
- user_message_image=image,
- )
-
- xml_content = strip_xml_tag(llm_output)
- if not xml_content:
- logger.warning(
- f"Output for {example.image_path} is empty after stripping formatting. Original LLM output: {llm_output}"
- )
- return ""
-
- xml_content = await llm_fix_xml_syntax(xml_content, example, engine)
-
- if not xml_content:
- logger.error(f"Failed to obtain valid XML for {example.stem} after LLM retries.")
- return ""
-
- if get_historical_doc_xml_errors(xml_content):
- logger.error(f"Final XML for {example.stem} still fails schema validation after retries.")
- return ""
-
- example.xml_path.write_text(prettify_xml(xml_content), encoding="utf-8")
- return xml_content
-
-
-def collect_document_examples(input_dir: Path) -> list[DocumentExample]:
- if not input_dir.exists() or not input_dir.is_dir():
- raise FileNotFoundError(f"Input directory does not exist: {input_dir}")
-
- buckets: dict[str, dict[str, Path]] = {}
- for path in input_dir.iterdir():
- if not path.is_file():
- continue
- suffix = path.suffix.lower()
- if suffix not in {".png", ".txt"}:
- continue
- bucket = buckets.setdefault(path.stem, {})
- if suffix == ".png":
- bucket["image"] = path
- else:
- bucket["text"] = path
-
- examples: list[DocumentExample] = []
- missing_components: list[str] = []
-
- for stem, files in sorted(buckets.items()):
- image_path = files.get("image")
- text_path = files.get("text")
- if image_path and text_path:
- xml_path = image_path.with_suffix(".xml")
- examples.append(DocumentExample(stem, image_path, text_path, xml_path))
- else:
- missing_components.append(stem)
-
- if missing_components:
- logger.warning(
- f"Skipping {len(missing_components)} unmatched file pair(s): {', '.join(missing_components)}"
- )
-
- logger.info(f"Found {len(examples)} complete PNG/TXT pair(s) in {input_dir}")
- return examples
-
-
-async def process_examples(
- examples: list[DocumentExample],
- engine: str,
- max_concurrency: int,
- corpus_description: str,
-) -> None:
- if not examples:
- logger.warning("No document pairs found to process.")
- return
-
- results = await run_async_in_parallel(
- partial(llm_transcribe, engine=engine, corpus_description=corpus_description),
- examples,
- max_concurrency=max_concurrency,
- desc="Generating XML",
- )
-
- success_count = sum(1 for result in results if isinstance(result, str) and result)
- logger.info(f"Successfully generated XML for {success_count}/{len(examples)} document(s)")
-
-
-async def run_text_to_historical_doc_xml(
- input_dir: Path,
- engine: str,
- max_concurrency: int,
- corpus_description: str,
- overwrite: bool,
-) -> None:
- examples = collect_document_examples(input_dir)
-
- if not overwrite:
- before = len(examples)
- examples = [example for example in examples if not example.xml_path.exists()]
- skipped = before - len(examples)
- if skipped:
- logger.info("Skipping %d existing XML file(s). Use --overwrite to regenerate.", skipped)
-
- await process_examples(examples, engine, max_concurrency, corpus_description)
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(
- description="Generate HistoricalDocument XML for PNG/TXT pairs."
- )
- parser.add_argument(
- "--input-dir",
- required=True,
- help="Directory containing paired X.png and X.txt files.",
- )
- parser.add_argument(
- "--engine",
- default=DEFAULT_ENGINE,
- help="Logical model key to use for LLM transcription.",
- )
- parser.add_argument(
- "--max-concurrency",
- type=int,
- default=DEFAULT_MAX_CONCURRENCY,
- help="Maximum number of concurrent LLM calls.",
- )
- parser.add_argument(
- "--corpus-description",
- default="",
- help="Optional free-form description of the corpus."
- " Included in the LLM prompt for additional context.",
- )
- parser.add_argument(
- "--overwrite",
- action="store_true",
- help="Regenerate XML even when an output file already exists.",
- )
- return parser.parse_args()
-
-
-def main() -> None:
- args = parse_args()
- input_dir = Path(args.input_dir)
- asyncio.run(
- run_text_to_historical_doc_xml(
- input_dir,
- args.engine,
- args.max_concurrency,
- args.corpus_description,
- args.overwrite,
- )
- )
- log_total_llm_cost()
-
-
-if __name__ == "__main__":
- main()
diff --git a/config/__init__.py b/config/__init__.py
deleted file mode 100644
index dca69f8..0000000
--- a/config/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Configuration helpers for Churro.
-
-Expose `get_settings` as the canonical accessor for environment-driven
-configuration. Modules should avoid loading `.env` directly and instead
-import from this package to retrieve typed snapshots.
-"""
-
-from .settings import ChurroSettings, get_settings
-
-
-__all__ = ["ChurroSettings", "get_settings"]
diff --git a/config/settings.py b/config/settings.py
deleted file mode 100644
index ac23d6d..0000000
--- a/config/settings.py
+++ /dev/null
@@ -1,155 +0,0 @@
-"""Centralised environment configuration for Churro.
-
-This module ensures `.env` loading happens in one place and exposes a
-typed snapshot of provider credentials, runtime ports, and tuning knobs.
-Downstream modules call `get_settings()` instead of touching `os.environ`
-directly, making it easier to validate values and override behaviour in
-tests.
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from functools import lru_cache
-import os
-from pathlib import Path
-
-from dotenv import load_dotenv
-
-
-_DEFAULT_ENV_PATH = Path(__file__).resolve().parents[1] / ".env"
-
-
-def _coerce_int(value: str | None) -> int | None:
- if value is None or value == "":
- return None
- try:
- return int(value)
- except ValueError:
- return None
-
-
-def _coerce_float(value: str | None) -> float | None:
- if value is None or value == "":
- return None
- try:
- return float(value)
- except ValueError:
- return None
-
-
-@dataclass(frozen=True)
-class AzureOpenAISettings:
- api_base: str | None
- api_version: str | None
- api_key: str | None
-
-
-@dataclass(frozen=True)
-class AzureDocumentIntelligenceSettings:
- endpoint: str | None
- api_key: str | None
-
-
-@dataclass(frozen=True)
-class VertexAISettings:
- project_id: str | None
- location: str
- document_ai_location: str
- ocr_processor_id: str | None
- ocr_processor_version: str | None
-
-
-@dataclass(frozen=True)
-class LocalRuntimeSettings:
- vllm_port: int | None
- huggingface_token: str | None
-
-
-@dataclass(frozen=True)
-class TokenSettings:
- openai: str | None
- mistral: str | None
-
-
-@dataclass(frozen=True)
-class ChurroSettings:
- """Top-level snapshot of configuration values."""
-
- env_file: Path
- azure_openai: AzureOpenAISettings
- azure_document_intelligence: AzureDocumentIntelligenceSettings
- vertex_ai: VertexAISettings
- google_cloud_project: str | None
- local: LocalRuntimeSettings
- tokens: TokenSettings
-
-
-def _resolve_env_path(env_file: os.PathLike[str] | str | None) -> Path:
- if env_file is None:
- return _DEFAULT_ENV_PATH
- return Path(env_file).resolve()
-
-
-@lru_cache(maxsize=4)
-def _load_settings(env_path: Path) -> ChurroSettings:
- # Load the environment file once per unique path. We avoid override=True so
- # that existing environment variables take precedence over `.env` defaults.
- load_dotenv(dotenv_path=env_path, override=False)
-
- azure_openai = AzureOpenAISettings(
- api_base=os.getenv("AZURE_API_BASE"),
- api_version=os.getenv("AZURE_API_VERSION"),
- api_key=os.getenv("AZURE_OPENAI_API_KEY") or os.getenv("OPENAI_API_KEY"),
- )
- azure_di = AzureDocumentIntelligenceSettings(
- endpoint=os.getenv("AZURE_DI_ENDPOINT"),
- api_key=os.getenv("AZURE_DOC_KEY"),
- )
-
- combined_project_id = os.getenv("VERTEX_AI_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT")
- vertex_ai = VertexAISettings(
- project_id=combined_project_id,
- location=os.getenv("VERTEX_AI_LOCATION", "us-east5"),
- document_ai_location=os.getenv("DOCUMENT_VERTEX_AI_LOCATION", "us"),
- ocr_processor_id=os.getenv("VERTEX_AI_OCR_PROCESSOR_ID"),
- ocr_processor_version=os.getenv("VERTEX_AI_OCR_PROCESSOR_VERSION"),
- )
-
- local_runtime = LocalRuntimeSettings(
- vllm_port=_coerce_int(os.getenv("LOCAL_VLLM_PORT") or "9000"),
- huggingface_token=os.getenv("HF_TOKEN"),
- )
-
- tokens = TokenSettings(
- openai=os.getenv("OPENAI_API_KEY"),
- mistral=os.getenv("MISTRAL_API_KEY"),
- )
-
- return ChurroSettings(
- env_file=env_path,
- azure_openai=azure_openai,
- azure_document_intelligence=azure_di,
- vertex_ai=vertex_ai,
- google_cloud_project=combined_project_id,
- local=local_runtime,
- tokens=tokens,
- )
-
-
-def get_settings(
- env_file: os.PathLike[str] | str | None = None,
- *,
- reload: bool = False,
-) -> ChurroSettings:
- """Return the cached settings snapshot.
-
- Args:
- env_file: Optional explicit path to a `.env` file. When omitted the repo
- root `.env` file is used.
- reload: When True the cached snapshot is cleared before loading.
- """
- env_path = _resolve_env_path(env_file)
- if reload:
- _load_settings.cache_clear()
- return _load_settings(env_path)
diff --git a/conftest.py b/conftest.py
index eb29332..ecc4636 100644
--- a/conftest.py
+++ b/conftest.py
@@ -1,61 +1,18 @@
-# Test configuration utilities.
-# Ensures the repository root is on sys.path so that 'churro.utils', etc. can be imported
-# when running pytest without installing the package.
+"""Pytest configuration for the src-based churro-ocr package."""
+
from __future__ import annotations
import asyncio
from collections.abc import Generator
-import gc
from pathlib import Path
import sys
import pytest
-from churro.utils.llm.shutdown import shutdown_llm_clients
-
-
ROOT = Path(__file__).parent.resolve()
-if str(ROOT) not in sys.path:
- sys.path.insert(0, str(ROOT))
-
-
-@pytest.fixture(scope="session", autouse=True)
-def cleanup_async_clients() -> Generator[None, None, None]:
- """Ensure aiohttp sessions opened via litellm are closed after tests."""
- yield
-
- async def _close_async_resources() -> None:
- await shutdown_llm_clients()
-
- try:
- asyncio.run(_close_async_resources())
- except RuntimeError:
- loop = asyncio.new_event_loop()
- try:
- loop.run_until_complete(_close_async_resources())
- finally:
- loop.run_until_complete(loop.shutdown_asyncgens())
- loop.close()
-
- policy = asyncio.get_event_loop_policy()
- try:
- loop = policy.get_event_loop()
- except RuntimeError:
- loop = None
- if loop and not loop.is_closed():
- loop.run_until_complete(loop.shutdown_asyncgens())
- loop.close()
-
- for obj in gc.get_objects():
- if isinstance(obj, asyncio.AbstractEventLoop) and not obj.is_closed():
- try:
- obj.run_until_complete(obj.shutdown_asyncgens())
- except Exception:
- pass
- try:
- obj.close()
- except Exception:
- pass
+SRC = ROOT / "src"
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
@pytest.fixture
diff --git a/evaluation/__init__.py b/evaluation/__init__.py
deleted file mode 100644
index e754b4b..0000000
--- a/evaluation/__init__.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""Evaluation utilities for OCR outputs."""
-
-from .evaluate_page import (
- batch_evaluate,
- calculate_metrics,
- calculate_metrics_from_text,
- evaluate_page,
- initialize_metrics,
-)
-from .metrics import compute_metrics
-
-
-__all__ = [
- "batch_evaluate",
- "calculate_metrics",
- "calculate_metrics_from_text",
- "compute_metrics",
- "evaluate_page",
- "initialize_metrics",
-]
diff --git a/evaluation/evaluate_page.py b/evaluation/evaluate_page.py
deleted file mode 100644
index b1ceb1f..0000000
--- a/evaluation/evaluate_page.py
+++ /dev/null
@@ -1,206 +0,0 @@
-import multiprocessing
-from typing import Any
-
-import evaluate
-from rapidfuzz import distance as rf_distance
-from tqdm import tqdm
-
-from churro.utils.log_utils import logger
-
-from .normalization import normalize_text_for_evaluation
-from .repetition import has_long_repetition
-from .xml_utils import extract_actual_text_from_xml
-
-
-bleu_metric: Any | None = None
-
-
-def initialize_metrics() -> None:
- """Lazily load BLEU metric and required NLTK resources."""
- global bleu_metric
- if not bleu_metric:
- import nltk
-
- nltk.download("wordnet", quiet=True)
- nltk.download("punkt_tab", quiet=True)
- nltk.download("omw-1.4", quiet=True)
-
- bleu_metric = evaluate.load("bleu")
-
-
-def levenshtein_distance(a: str, b: str, max_cost: int | None = None) -> int:
- """Compute Levenshtein distance with optional cutoff.
-
- Stops early if `max_cost` is exceeded (using RapidFuzz score cutoff).
- """
- if max_cost is not None:
- return rf_distance.Levenshtein.distance(a, b, score_cutoff=max_cost)
- return rf_distance.Levenshtein.distance(a, b)
-
-
-def evaluate_page(
- _input: tuple,
-) -> dict[str, Any]:
- """Evaluate predicted text vs. gold page returning a metrics dict."""
- example, predicted_text = _input
-
- metrics_dict = calculate_metrics((example, predicted_text))
-
- metrics_dict["file_name"] = example["file_name"]
- metrics_dict["predicted_text"] = predicted_text
- metrics_dict["gold_text"] = example["transcription"]
- metrics_dict["main_language"] = example["main_language"]
- metrics_dict["main_script"] = example["main_script"]
- metrics_dict["document_type"] = example["document_type"]
- metrics_dict["dataset_id"] = example["dataset_id"]
-
- return metrics_dict
-
-
-def _compute_text_metrics_core(
- predicted_text: str,
- gold_text: str,
- language: str,
- script: str,
-) -> dict[str, Any]:
- """Core text metric computation independent of ChurroExample objects.
-
- Args:
- predicted_text: The model predicted transcription (raw or XML).
- gold_text: The gold transcription (raw or XML).
- language: Primary language label (affects normalization rules).
- script: Primary script label.
-
- Returns:
- Dictionary containing evaluation metrics and normalized texts.
- """
- global bleu_metric
-
- bleu_result: float = 0.0
- normalized_levenshtein_similarity: float = 0.0
- has_repetition_flag: bool = False
- is_empty: float = 0.0
-
- try:
- predicted_text = extract_actual_text_from_xml(predicted_text)
- predicted_text = normalize_text_for_evaluation(
- predicted_text, normalize_arabic=(language in ["Arabic", "Persian"])
- )
- is_empty = 1.0 if not predicted_text.strip() else 0.0
-
- gold_text = extract_actual_text_from_xml(gold_text)
- gold_text_lines: list[str] = gold_text.splitlines()
- gold_text_lines = [normalize_text_for_evaluation(line) for line in gold_text_lines]
-
- gold_text = normalize_text_for_evaluation(
- gold_text, normalize_arabic=(language in ["Arabic", "Persian"])
- )
-
- denom: int = max(len(predicted_text), len(gold_text))
- if denom == 0:
- normalized_levenshtein_similarity = 1.0
- else:
- normalized_levenshtein_similarity = (
- 1 - levenshtein_distance(predicted_text, gold_text) / denom
- )
-
- has_repetition_flag = has_long_repetition(predicted_text)
-
- if is_empty == 1.0:
- bleu_result = 0.0
- else:
- bleu_result = bleu_metric.compute( # type: ignore
- predictions=[predicted_text],
- references=[[gold_text]],
- )["bleu"]
- except Exception as e:
- logger.error(f"Error in metric computation: {e}")
-
- return {
- "normalized_levenshtein_similarity": normalized_levenshtein_similarity,
- "repetition": float(has_repetition_flag),
- "is_empty": is_empty,
- "bleu": bleu_result,
- "normalized_predicted_text": predicted_text,
- "normalized_gold_text": gold_text,
- "main_language": language,
- "main_script": script,
- }
-
-
-def calculate_metrics_from_text(
- predicted_text: str,
- gold_text: str,
- language: str,
- script: str,
-) -> dict[str, Any]:
- """Evaluate metrics from raw predicted and gold text inputs."""
- return _compute_text_metrics_core(predicted_text, gold_text, language, script)
-
-
-def calculate_metrics(_input: tuple) -> dict[str, Any]:
- """Wrapper for multiprocessing: (ChurroExample, predicted_text) -> metrics."""
- example, predicted_text = _input
- try:
- metrics = _compute_text_metrics_core(
- predicted_text=predicted_text,
- gold_text=example["transcription"],
- language=example["main_language"],
- script=example["main_script"],
- )
- except Exception as e:
- logger.error(f"Error in evaluation of {example['file_name']}: {e}")
- metrics = {
- "normalized_levenshtein_similarity": 0.0,
- "repetition": 0.0,
- "is_empty": 1.0,
- "bleu": 0.0,
- "normalized_predicted_text": predicted_text,
- "normalized_gold_text": example.transcription,
- "main_language": example.main_language,
- "main_script": example.main_script,
- }
- return metrics
-
-
-def aggregate_results(
- results: list[dict[str, Any]],
-) -> tuple[dict[str, float], list[dict[str, Any]]]:
- """Average numeric metrics across page-level results (bools treated as 0/1)."""
- if not results:
- return {}, []
-
- # Initialize all numeric keys to 0.0
- aggregated_metrics: dict[str, float] = {}
- for k, v in results[0].items():
- if isinstance(v, int | float | bool):
- aggregated_metrics[k] = 0.0
-
- # Sum numeric values (treat bool as int -> 0/1)
- for m in results:
- for k, v in m.items():
- if isinstance(v, int | float | bool) and k in aggregated_metrics:
- aggregated_metrics[k] += float(v)
-
- # Average
- aggregated_metrics = {k: v / len(results) for k, v in aggregated_metrics.items()}
- return aggregated_metrics, results
-
-
-def batch_evaluate(
- dataset: list[dict[str, Any]],
- predicted_texts: list[str],
-) -> tuple[dict[str, float], list[dict[str, Any]]]:
- """Evaluate pages in parallel and return (aggregated_metrics, per_page)."""
- initialize_metrics()
- results = []
- with multiprocessing.Pool(processes=8) as pool:
- results = list(
- tqdm(
- pool.imap(evaluate_page, zip(dataset, predicted_texts, strict=False)),
- total=len(dataset),
- mininterval=0.5, # Update at most twice a second
- )
- )
-
- return aggregate_results(results)
diff --git a/evaluation/historical_doc.xsd b/evaluation/historical_doc.xsd
deleted file mode 100644
index 75a46c4..0000000
--- a/evaluation/historical_doc.xsd
+++ /dev/null
@@ -1,605 +0,0 @@
-
-
-
-
-
- Schema for encoding transcriptions of historical documents, incorporating structural elements, metadata, and detailed inline features.
-
-
-
-
-
- The root element for a single transcribed historical document.
-
-
-
-
-
-
-
-
-
-
-
- Contains metadata about the historical document and the transcription.
-
-
-
-
-
- The language(s) present in the document, using ISO 639 codes (e.g., 'eng', 'lat'). List all significant languages.
-
-
-
-
- The script(s) used in the document, using ISO 15924 codes (e.g., 'Latn', 'Cyrl', 'Arab'). List all significant scripts.
-
-
-
-
- The primary writing direction: ltr (left-to-right), rtl (right-to-left), ttb-ltr (top-to-bottom, left-to-right columns), ttb-rtl (top-to-bottom, right-to-left columns).
-
-
-
-
-
-
-
-
-
-
-
-
- Description of the physical object (material, dimensions, condition). E.g., 'Parchment, 1 folio, good condition with minor foxing'.
-
-
-
-
- A general description of the document content. E.g., 'Letter concerning trade routes'.
-
-
-
-
- Notes regarding the transcription process, conventions used, or difficulties encountered.
-
-
-
-
-
-
-
-
- Represents a single page (or folio side) of the document.
-
-
-
-
-
-
-
-
-
-
-
-
- Content typically found in the header area of a page.
-
-
-
-
-
-
-
-
-
-
-
-
- Content typically found in the footer area of a page.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- A page number marker.
-
-
-
-
- A folio number marker (numbering leaves instead of pages).
-
-
-
-
- The first word(s) of the next page, printed at the bottom of the current page.
-
-
-
-
- A mark (letter or number) indicating the gathering in early printed books.
-
-
-
-
-
- The main content area of the page.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- A paragraph of text.
-
-
-
-
-
-
-
-
-
-
- A note, typically commentary or reference, written in the margin of the page. Contrast with Addition, which represents text added by the original scribe as part of the composition.
-
-
-
-
-
-
-
- Specifies the margin where the note appears (left_margin, right_margin, top_margin, bottom_margin).
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- An inline note, typically commentary or reference, written between the lines. Contrast with Addition. Note that in top-to-bottom languages, interlinear notes appear differently.
-
-
-
-
-
-
-
-
-
-
- Represents tabular data.
-
-
-
-
-
-
-
-
-
-
- A row within a table.
-
-
-
-
-
-
-
-
-
-
- A cell within a table row.
-
-
-
-
-
-
-
- Specifies whether the cell acts as a header or data cell.
-
-
-
-
-
-
-
-
-
-
- Number of columns spanned by the cell.
-
-
-
-
- Number of rows spanned by the cell.
-
-
-
-
-
-
-
- A heading for a document section, chapter, entry, etc.
-
-
-
-
-
-
-
- Specifies the type or level of the heading (e.g., main heading, subheading, running title, heading of a figure, other).
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- A line containing a date, often at the start of a letter or entry.
-
-
-
-
-
-
-
-
- An entry associated with a specific date, like a diary entry.
-
-
-
-
-
-
-
-
-
-
-
- The normalized date of the entry (YYYY-MM-DD).
-
-
-
-
-
-
- An entry in a record book or log.
-
-
-
-
-
-
-
-
-
-
-
-
- The normalized date of the record (YYYY-MM-DD).
-
-
-
-
-
-
-
- A section of quoted text, often indented.
-
-
-
-
-
- Type of quotation (e.g., 'prose', 'verse').
-
-
-
-
-
-
-
- A list of items.
-
-
-
-
-
-
-
- Type of list (e.g., ordered, unordered, glossary).
-
-
-
-
-
-
-
- An item within a list.
-
-
-
-
-
-
-
-
- Represents a figure, illustration, diagram, chart, or other graphical element.
-
-
-
-
-
-
- A general textual description or representation of the figure/illustration's content. For content explicitly transcribed from the document, use Caption instead.
-
-
-
-
-
-
-
-
- A caption associated with an illustration or figure.
-
-
-
-
-
-
-
-
- Represents a seal (e.g., wax seal) on the document.
-
-
-
-
- Description of the seal.
-
-
-
-
-
-
-
- Represents a stamp on the document.
-
-
-
-
- Description or text of the stamp.
-
-
-
-
-
-
-
- Describes a watermark present in the paper.
-
-
-
-
- Description of the watermark.
-
-
-
-
-
-
-
- Represents a mathematical or chemical formula, assumed to be in LaTeX notation.
-
-
-
-
- Specifies the notation system used. Fixed to 'latex'.
-
-
-
-
-
-
-
- Represents musical notation.
-
-
-
-
- A description of the musical notation if not fully encoded.
-
-
-
-
-
-
-
- Represents a gap in the text (missing, illegible, or omitted). Can occur as a block or inline.
-
-
-
-
- The reason for the gap (illegible, missing, omitted, damaged, unknown).
-
-
-
-
-
-
-
-
-
-
-
-
-
- Estimated extent of the gap (e.g., 'ca. 5 letters', '1 line').
-
-
-
-
-
-
-
- Represents a single physical line of text from the source document. Contains text and inline elements.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Text written above the main line.
-
-
-
-
-
-
-
-
-
-
- An initial letter, potentially decorated and/or a drop capital.
-
-
-
-
- Specifies the type of initial letter (simple, decorated, drop, decorated-drop).
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Text that is emphasized in some way (italic, bold, underline, colored, etc.).
-
-
-
-
- The type of emphasis used (italic, bold, underline, colored, other).
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Marks a span of text that cannot be reliably transcribed. Use Deletion/Addition for overwritten text.
-
-
-
-
- The reason why the text is illegible (faded, damaged, blot, scribbled, binding).
-
-
-
-
-
-
-
-
-
-
-
-
-
- Estimated extent of the illegible text (e.g., 'ca. 3 words', '1 character').
-
-
-
-
-
-
-
- Text marked as deleted or struck out in the original. This represents content considered part of the text but marked as non-applicable.
-
-
-
-
-
-
- Text added to the original by the scribe, often interlinearly or marginally, as part of the composition or revision process. Contrast with MarginalNote and InterlinearNote which are typically commentary.
-
-
-
-
-
diff --git a/evaluation/metrics.py b/evaluation/metrics.py
deleted file mode 100644
index 47cf356..0000000
--- a/evaluation/metrics.py
+++ /dev/null
@@ -1,129 +0,0 @@
-from collections import defaultdict
-import json
-from typing import Any
-
-from churro.systems.detect_layout import get_total_azure_cost
-from churro.utils.llm.cost import get_llm_total_cost
-from churro.utils.log_utils import logger
-
-from .evaluate_page import batch_evaluate
-
-
-def to_rounded_percentage(metrics: dict[str, Any]) -> dict[str, Any]:
- """Round numeric metric values to one decimal percentage points."""
- return {
- key: round(value * 100) if isinstance(value, int | float) else value
- for key, value in metrics.items()
- }
-
-
-def round(value: float | int) -> float:
- """Round a float to one decimal place."""
- return float(f"{value:.1f}")
-
-
-def calculate_language_and_type_metrics(
- outputs: list[dict[str, Any]],
- main_metric: str = "normalized_levenshtein_similarity",
-) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
- """Compute averages grouped by language, type, and their combination."""
- main_language_to_metrics = defaultdict(list)
- main_language_and_type_to_metrics = defaultdict(list)
- type_to_metrics = {"print": [], "handwriting": []}
-
- for output in outputs:
- main_language = output["main_language"]
- metric = output[main_metric]
- document_type = output["document_type"]
-
- main_language_to_metrics[main_language].append(metric)
- type_to_metrics[document_type].append(metric)
- main_language_and_type_to_metrics[f"{main_language}_{document_type}"].append(metric)
-
- # Calculate averages
- averaged_main_language: dict[str, float] = {}
- for language, vals in main_language_to_metrics.items():
- averaged_main_language[language] = sum(vals) / len(vals) if vals else 0.0
-
- averaged_type: dict[str, float] = {}
- for doc_type, vals in type_to_metrics.items():
- averaged_type[doc_type] = sum(vals) / len(vals) if vals else 0.0
-
- averaged_lang_type: dict[str, float] = {}
- for lt, vals in main_language_and_type_to_metrics.items():
- averaged_lang_type[lt] = sum(vals) / len(vals) if vals else 0.0
- return averaged_main_language, averaged_type, averaged_lang_type
-
-
-def compute_metrics(
- dataset: list[dict],
- predicted_texts: list[str],
- output_prefix: str,
- elapsed_time: float,
- main_metric: str = "normalized_levenshtein_similarity",
-) -> dict[str, Any]:
- """Compute metrics, save per-example outputs, plots, and summary JSON."""
- # Ensure all predicted texts are strings
- for i in range(len(predicted_texts)):
- if not predicted_texts[i]:
- predicted_texts[i] = ""
-
- # Run evaluation
- aggregate_metrics, per_example_evaluation_outputs = batch_evaluate(dataset, predicted_texts)
-
- # Create outputs with evaluation results
- outputs = []
- for evaluation_output, example in zip(per_example_evaluation_outputs, dataset, strict=False):
- outputs.append(
- {
- "file_name": example["file_name"],
- **evaluation_output,
- }
- )
-
- # Save detailed outputs
- with open(f"{output_prefix}/outputs.json", "w") as f:
- json.dump(outputs, f, indent=2, ensure_ascii=False)
-
- # Calculate metrics by language and type
- language_metrics, type_metrics, lang_type_metrics = calculate_language_and_type_metrics(
- outputs, main_metric
- )
-
- # Round all metrics
- language_metrics = to_rounded_percentage(language_metrics)
- type_metrics = to_rounded_percentage(type_metrics)
- aggregate_metrics = to_rounded_percentage(aggregate_metrics)
- lang_type_metrics = to_rounded_percentage(lang_type_metrics)
- aggregate_metrics["llm_cost ($)"] = round(get_llm_total_cost()) # don't convert to percentage
- aggregate_metrics["azure_cost ($)"] = round(
- get_total_azure_cost()
- ) # don't convert to percentage
- aggregate_metrics["elapsed_time (s)"] = round(elapsed_time) # don't convert to percentage
-
- # Create combined metrics dictionary
- combined_metrics = {
- "main_language_metrics": language_metrics,
- "type_metrics": type_metrics,
- "aggregate_metrics": aggregate_metrics,
- "main_language_and_type_metrics": lang_type_metrics,
- }
-
- # Save all metrics
- with open(f"{output_prefix}/all_metrics.json", "w") as f:
- json.dump(combined_metrics, f, indent=2)
-
- # Log results
- logger.info("Average metrics per document type:")
- logger.info(json.dumps(type_metrics, indent=2))
-
- logger.info("Average metrics per main language:")
- logger.info(json.dumps(language_metrics, indent=2))
-
- logger.info("Average metrics per main language and type:")
- logger.info(json.dumps(lang_type_metrics, indent=2))
-
- logger.info("Aggregated metrics:")
- logger.info(json.dumps(aggregate_metrics, indent=2))
-
- return combined_metrics
diff --git a/evaluation/normalization.py b/evaluation/normalization.py
deleted file mode 100644
index 7fe77fe..0000000
--- a/evaluation/normalization.py
+++ /dev/null
@@ -1,146 +0,0 @@
-import re
-import unicodedata
-
-
-# TODO add more
-subs = {
- "\ueada": "st",
- "\ueec5": "ct",
- "\ueba6": "ss",
- "\ueba2": "si",
- "\ueba7": "ssi",
- "\ueba3": "sl",
- "’": "'",
- "¬": "-",
-}
-
-pattern = re.compile("|".join(map(re.escape, subs.keys())))
-
-
-def normalize_characters(text: str, keep_long_s: bool = True) -> str:
- """Replace special characters with ASCII equivalents.
-
- Inserts spaces before certain fractions, preserves long ſ if requested, and applies
- predefined glyph substitutions.
- """
- # Insert a space before common fraction characters when immediately preceded by a digit.
- text = re.sub(r"(?<=\d)(?=[↉½⅓¼⅕⅙⅐⅛⅑⅒⅔⅖¾⅗⅜⅘⅚⅞])", " ", text)
-
- # Placeholder for long s swap (always define to satisfy type checker)
- placeholder = "\ue000"
- if keep_long_s:
- # Use a placeholder unlikely to appear in normal text.
- text = text.replace("ſ", placeholder)
-
- # Normalize text to convert special fraction characters (e.g., '½') into their ASCII representations (e.g., '1/2')
- text = unicodedata.normalize("NFKC", text)
-
- if keep_long_s:
- text = text.replace(placeholder, "ſ")
-
- # Apply additional substitutions defined elsewhere (subs and pattern should be defined in the module)
- text = pattern.sub(lambda m: subs[m.group(0)], text)
-
- # Make this change to make the text stay close to the image
- # replace = at the end of a line with a ⸗
- # This is an example of https://en.wikipedia.org/wiki/Double_hyphen
- # This is used in for example newseye_finnish to indicate that a word was split across lines.
- # text = re.sub(r"=(?=\n|$)", "⸗", text)
-
- # Remove ~ at the beginning of a line. It is used in for example clarysse.
- text = re.sub(r"(^|\s)~(?=\w)", r"\1", text)
-
- return text
-
-
-def normalize_text_for_evaluation(
- text: str,
- normalize_arabic: bool = False,
-) -> str:
- """Normalize raw OCR/LLM text for evaluation.
-
- Applies case-folding, punctuation & markdown cleanup, dash standardization,
- removal of figure markers, whitespace collapsing, hyphen-merge, optional
- Arabic normalization, and character substitutions.
- """
- if normalize_arabic:
- from pyarabic.araby import (
- normalize_hamza,
- strip_harakat,
- strip_lastharaka,
- strip_tashkeel,
- strip_tatweel,
- )
-
- text = strip_tashkeel(text)
- text = strip_harakat(text)
- text = strip_lastharaka(text)
- text = strip_tatweel(text)
- text = normalize_hamza(text)
-
- # Convert to lowercase.
- text = text.lower()
-
- # Remove markdown symbols and standardize dashes.
- text = re.sub(r"[*_`~#]", "", text)
- text = re.sub(r"[–—−‑‒―‐]", "-", text)
-
- # Remove Markdown image blocks.
- text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
-
- # Remove lines that start with '[' and end with ']'. These are often what LLMs output as extra explanations.
- text = re.sub(r"^\s*\[.*\]\s*$", "", text, flags=re.MULTILINE)
-
- # Remove figure markers like "[figure 1]".
- text = re.sub(r"\[figure\s+\d+\]", "", text)
-
- # Remove blockquote markers at the start of lines.
- text = re.sub(r"^>\s+", "", text, flags=re.MULTILINE)
-
- # Remove sequences of three or more hyphens.
- text = re.sub(r"-{3,}", "", text)
-
- # Remove spaces before punctuation.
- text = re.sub(r"\s+([.,?!;:])", r"\1", text)
-
- # Merge words split by newline hyphenation.
- text = re.sub(r"(\w+)-\s*\n\s*(\w+)", r"\1\2", text)
- text = text.strip("-")
-
- text = normalize_characters(text, keep_long_s=False)
-
- # Collapse multiple whitespace characters into a single space.
- text = re.sub(r"\s+", " ", text).strip()
-
- return text
-
-
-def remove_transcription_tags(text: str) -> str:
- """Remove specialized editorial/marker tags and abbreviation constructs."""
- # Step 1: Convert abbreviated expressions like "Adm.$^r$.Administrador" to "Admr"
- # Pattern:
- # Capture the base abbreviation, a literal period, a marker ($^letters$), another literal period,
- # then the full word (which we ignore)
- # print("input text:", text)
- pattern_abbrev = r"\b([A-Za-z]+)\.\$\^([A-Za-z]+)\$\.[A-Za-z]+"
- text = re.sub(pattern_abbrev, r"\1\2", text)
-
- # Step 2: Convert expressions like "dho$.dicho" to "dho"
- # Pattern explanation:
- # \b([A-Za-z]+) : capture a word before the marker (e.g., "dho")
- # \$\. : literal "$." sequence
- # [A-Za-z]+ : one or more letters (e.g., "dicho")
- pattern_dho = r"\b([A-Za-z]+)\$\.[A-Za-z]+"
- text = re.sub(pattern_dho, r"\1", text)
-
- # Step 3: Remove tags of the form "$tag:" (e.g. "$ant:", "$ofi:", etc.)
- text = re.sub(r"\$[A-Za-z]+:", "", text)
-
- # Remove markers enclosed in $ signs, e.g. "$^r$" or "$dho$"
- text = re.sub(r"\$[\^A-Za-z]+\$", "", text)
-
- # text = text.replace("$-", "-")
- text = text.replace(":$-\n$-", "-\n")
- # print("changed text: ", text)
-
- return text
diff --git a/evaluation/repetition.py b/evaluation/repetition.py
deleted file mode 100644
index af3c56e..0000000
--- a/evaluation/repetition.py
+++ /dev/null
@@ -1,42 +0,0 @@
-def has_long_repetition(s: str) -> bool:
- """Return True iff s = A + B*k (k>=2) with nonempty A,B and len(A) <= 0.8*len(s)."""
- n: int = len(s)
- if n < 2:
- return False
-
- # compute prefix‐function on reversed string
- r: str = s[::-1]
- pi: list[int] = [0] * n
- for i in range(1, n):
- j: int = pi[i - 1]
- while j and r[i] != r[j]:
- j = pi[j - 1]
- if r[i] == r[j]:
- j += 1
- pi[i] = j
-
- max_a: int = int(0.8 * n)
- for a in range(1, max_a + 1):
- rem: int = n - a
- if rem < 2:
- continue
- border: int = pi[rem - 1]
- p: int = rem - border
- # B must repeat at least twice
- if border > 0 and rem % p == 0 and rem // p >= 2:
- return True
-
- return False
-
-
-# def remove_repetition(text: str) -> str:
-# """
-# Remove long repetitions from the text.
-# """
-# if not text:
-# return text
-# result, count = longest_repeated_substring_nonoverlapping(text, 10)
-# ratio = len(result) * count / len(text)
-# if ratio > 0.1:
-# return text.replace(result, "")
-# return text
diff --git a/evaluation/xml_utils.py b/evaluation/xml_utils.py
deleted file mode 100644
index dd618a4..0000000
--- a/evaluation/xml_utils.py
+++ /dev/null
@@ -1,151 +0,0 @@
-from pathlib import Path
-import re
-import xml.etree.ElementTree as ET
-
-from lxml import etree # type: ignore
-import xmlschema
-
-from churro.utils.log_utils import logger
-
-
-historical_doc_schema = None
-allowed_xml_pattern = None
-SCHEMA_PATH = Path(__file__).resolve().parent / "historical_doc.xsd"
-
-
-def _escape_chunk(text: str) -> str:
- # only escape &, <, >
- return text.replace("&", "&").replace("<", "<").replace(">", ">")
-
-
-def _escape_xml(s: str) -> str:
- """Escape ``<``, ``>``, ``&`` except inside allowed XML tags/structures."""
- global allowed_xml_pattern
- # Compile the pattern once and stash it on the function
- if allowed_xml_pattern is None:
- tags = _get_list_of_valid_xml_tags()
- # build a regex that matches:
- # , , , , etc.
- tag_alts = "|".join(re.escape(t) for t in tags)
- allowed_xml_pattern = re.compile(
- rf"(<\?xml.*?\?>|?(?:{tag_alts})(?:\b[^>]*?)?/?>)", re.DOTALL
- )
-
- pat = allowed_xml_pattern
- parts = []
- last = 0
-
- # Iterate through all matches of allowed XML fragments
- for m in pat.finditer(s):
- if m.start() > last:
- parts.append(_escape_chunk(s[last : m.start()]))
- parts.append(m.group(0))
- last = m.end()
-
- # escape any remaining tail
- if last < len(s):
- parts.append(_escape_chunk(s[last:]))
-
- return "".join(parts)
-
-
-def _get_list_of_valid_xml_tags() -> list[str]:
- """Return valid XML tags for the historical document schema."""
- global historical_doc_schema
- if historical_doc_schema is None:
- historical_doc_schema = xmlschema.XMLSchema(str(SCHEMA_PATH))
- return list(historical_doc_schema.elements.keys()) + [
- "PhysicalDescription",
- "Language",
- "Script",
- "PhysicalDescription",
- "Description",
- "WritingDirection",
- "TranscriptionNote",
- "Footer",
- "Header",
- ]
-
-
-def extract_actual_text_from_xml(xml_content: str) -> str:
- """Extract concatenated text from ````, ````, ```` of each page."""
- if "HistoricalDocument" not in xml_content:
- return xml_content
-
- xml_content = _escape_xml(xml_content)
- try:
- xml_content = _remove_tag(
- xml_content, "Description"
- ) # ignore description tags, e.g. in Figure, Stamp, Seal tags etc.
- xml_content = _remove_tag(xml_content, "Deletion")
- xml_content = _remove_tag(xml_content, "Illegible")
- xml_content = _remove_tag(xml_content, "Gap")
-
- parser = etree.XMLParser(recover=True)
- root = etree.fromstring(xml_content.encode("utf-8"), parser)
-
- # Build namespace map & prefix for XPath
- default_ns: str = root.nsmap.get(None) or ""
- xpath_nsmap: dict[str, str] = {}
- prefix: str = ""
- if default_ns:
- prefix = "docns"
- xpath_nsmap[prefix] = default_ns
-
- # Find all elements in document order
- page_query = f".//{prefix + ':' if prefix else ''}Page"
- pages: list[etree._Element] = root.xpath(page_query, namespaces=xpath_nsmap)
-
- text_parts_per_page: list[str] = []
- tags_to_extract: list[str] = ["Header", "Body", "Footer"]
-
- for page in pages:
- page_text_parts: list[str] = []
- for tag_name in tags_to_extract:
- qname = f"{prefix + ':' if prefix else ''}{tag_name}"
- elements: list[etree._Element] = page.xpath(f".//{qname}", namespaces=xpath_nsmap)
- for elem in elements:
- # collect all text inside the element
- lines: list[str] = []
- for t_node in elem.itertext():
- cleaned = re.sub(
- r"<(/)?(lb|br)\s*/?>", "", t_node, flags=re.IGNORECASE
- ).strip()
- if cleaned:
- lines.append(cleaned)
- if lines:
- page_text_parts.append("\n".join(lines))
-
- # join this page's parts if any
- if page_text_parts:
- text_parts_per_page.append("\n".join(page_text_parts))
-
- if not text_parts_per_page:
- logger.warning("No text found in any /,, tags.")
- return ""
-
- full_text = "\n\n".join(text_parts_per_page)
- # cleanup stray
or in text and collapse multiple blank lines
- full_text = re.sub(r"<(/)?(lb|br)\s*/?>", "", full_text, flags=re.IGNORECASE)
- full_text = re.sub(r"\n\s*\n+", "\n\n", full_text)
- return full_text.strip()
-
- except (etree.XMLSyntaxError, ET.ParseError) as e:
- logger.error(f"Failed to parse XML content: {e}")
- return ""
- except Exception as e:
- logger.exception(f"Unexpected error during text extraction: {e}")
- return ""
-
-
-def _remove_tag(xml_content: str, tag_name: str) -> str:
- import re
-
- # Only process if tag present
- if f"<{tag_name}" not in xml_content:
- return xml_content
- # Remove opening to closing tags with any content in between
- xml_content = re.sub(rf"<{tag_name}\b[^>]*>.*?{tag_name}>", "", xml_content, flags=re.DOTALL)
- # Remove self-closing tags
- xml_content = re.sub(rf"<{tag_name}\b[^>]*/>", "", xml_content)
- return xml_content
diff --git a/page/__init__.py b/page/__init__.py
deleted file mode 100644
index bb23309..0000000
--- a/page/__init__.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from .page import Page
-from .page_object import PageObject
-from .visualization import (
- crop_page_objects_from_image,
- extract_polygon_region,
-)
-
-
-__all__ = [
- "PageObject",
- "Page",
- "extract_polygon_region",
- "crop_page_objects_from_image",
-]
diff --git a/page/page.py b/page/page.py
deleted file mode 100644
index 5659ac4..0000000
--- a/page/page.py
+++ /dev/null
@@ -1,246 +0,0 @@
-"""Core page abstractions and helpers for OCR layout post-processing."""
-
-from collections import defaultdict
-import math
-
-from azure.ai.documentintelligence.models import AnalyzeResult
-from pydantic import BaseModel, ConfigDict, Field
-import rtree
-
-from churro.utils.log_utils import logger
-
-from .page_object import PageObject
-
-
-class Page(BaseModel):
- """Represents a single OCR page with its objects, reading order, and metadata.
-
- This model stores detected layout elements (`page_objects`), optional reading order
- (as a directed graph), raw/full text, and auxiliary metadata such as languages.
- Utility methods provide convenience accessors for path-derived attributes and
- transformations (filtering, merging, saving, etc.).
- """
-
- page_objects: list[PageObject] = Field(..., description="List of objects on the page")
-
- model_config = ConfigDict(arbitrary_types_allowed=True)
-
- @staticmethod
- def from_azure_analysis_result(result: AnalyzeResult, skip_paragraphs: bool = False) -> "Page":
- """Build a `Page` instance from an Azure Document Intelligence result."""
- page_objects = []
-
- object_id = 1
-
- paragraph_spans = [] # each span is (offset, length)
- if result.paragraphs and not skip_paragraphs:
- for paragraph in result.paragraphs:
- if not paragraph.bounding_regions or len(paragraph.bounding_regions) != 1:
- continue
- for span in paragraph.spans or []:
- paragraph_spans.append((span.offset, span.length))
- polygon_coords = paragraph.bounding_regions[0].polygon or []
- if not polygon_coords:
- continue
- page_objects.append(
- PageObject(
- object_id=str(object_id),
- coordinates=polygon_coords,
- )
- )
- object_id += 1
- if result.figures:
- for figure in result.figures:
- if not figure.bounding_regions or len(figure.bounding_regions) != 1:
- continue
- fig_coords = figure.bounding_regions[0].polygon or []
- if not fig_coords:
- continue
- page_objects.append(
- PageObject(
- object_id=str(object_id),
- coordinates=fig_coords,
- )
- )
- object_id += 1
-
- assert len(result.pages) == 1, "Expected only one page"
- p = result.pages[0]
-
- # use spans to determine which lines are already included in paragraphs
- lines_added = 0
- for line in p.lines or []:
- for span in line.spans:
- # check if span fully falls within a paragraph
- found = False
- for offset, length in paragraph_spans:
- if span.offset >= offset and span.offset + span.length <= offset + length:
- found = True
- break
- if not found:
- lines_added += 1
- if line.polygon:
- page_objects.append(
- PageObject(
- object_id=str(object_id),
- coordinates=line.polygon,
- )
- )
- object_id += 1
-
- # page objects with weird angles are often mistakes. We remove them here so that they can be added as their individual lines below
- old_size = len(page_objects)
- page_objects = [po for po in page_objects if po.get_top_edge_angle() <= 10]
- if old_size - len(page_objects) > 0:
- logger.info(f"Removed {old_size - len(page_objects)} page objects with weird angles")
-
- if lines_added > 0:
- logger.info(f"Added {lines_added} lines that were not part of any paragraphs to page")
-
- page = Page(
- page_objects=page_objects,
- )
- return page
-
- def remove_subsumed_page_objects(self, coverage_ratio: float = 0.7) -> None:
- """Remove page_objects that are subsumed by other page_objects using an R-tree for efficient spatial queries.
-
- When coverage_ratio is 100.0 (default), an object is removed if it is fully subsumed (using the default
- PageObject.remove_subsumed_objects logic). If a lower ratio is provided, an object is removed if at least
- that ratio of its area is covered by any other page object.
-
- Args:
- coverage_ratio (float): The coverage ratio for determining if a page object is subsumed by another. A value of 0.7 means
- that a page object is considered subsumed if 70% of its area is covered by another page object.
- """
- if not self.page_objects:
- return
- assert 0 <= coverage_ratio <= 1, "coverage_ratio must be between 0 and 1"
-
- # Record (index, page object) pairs.
- enumerated_polygons = [(i, obj) for i, obj in enumerate(self.page_objects)]
- page_objects = [p for _, p in enumerated_polygons]
-
- # Remove subsumed polygons; returns a list of polygon objects in the original order.
- polygons_to_keep = PageObject.remove_subsumed_objects(
- page_objects, tolerance=1 - coverage_ratio
- )
-
- # Map each polygon object back to its original indices.
- polygon_to_indices = defaultdict(list)
- for i, poly in enumerated_polygons:
- polygon_to_indices[poly].append(i)
-
- kept_indices = []
- for poly in polygons_to_keep:
- # Pop the first stored index for this polygon in case of duplicates.
- kept_indices.append(polygon_to_indices[poly].pop(0))
- kept_indices.sort()
- self.page_objects = [self.page_objects[i] for i in kept_indices]
-
- def remove_small_page_objects_in_margins(self) -> None:
- """Drop tiny page objects hugging the margins to reduce noise."""
- sample_count = len(self.page_objects)
- if sample_count < 2:
- return
-
- index = rtree.index.Index()
- # Spatial index accelerates intersection checks against margin bands.
- bounds: list[tuple[float, float, float, float]] = []
- widths: list[float] = []
- heights: list[float] = []
- for idx, obj in enumerate(self.page_objects):
- left, top, right, bottom = obj.bounds
- bounds.append((left, top, right, bottom))
- widths.append(obj.width)
- heights.append(obj.height)
- index.insert(idx, (left, top, right, bottom))
-
- smallest_count = max(1, math.ceil(sample_count * 0.1))
- smallest_width_indices = set(
- sorted(range(sample_count), key=lambda i: widths[i])[:smallest_count]
- )
- smallest_height_indices = set(
- sorted(range(sample_count), key=lambda i: heights[i])[:smallest_count]
- )
-
- global_left = min(b[0] for b in bounds)
- global_top = min(b[1] for b in bounds)
- global_right = max(b[2] for b in bounds)
- global_bottom = max(b[3] for b in bounds)
-
- page_width = max(global_right - global_left, 1.0)
- page_height = max(global_bottom - global_top, 1.0)
- x_tolerance = max(5.0, page_width * 0.02)
- y_tolerance = max(5.0, page_height * 0.02)
-
- def margin_rectangles() -> list[tuple[float, float, float, float]]:
- return [
- (
- max(global_left, global_right - x_tolerance),
- global_top,
- global_right,
- global_bottom,
- ),
- (
- global_left,
- global_top,
- min(global_right, global_left + x_tolerance),
- global_bottom,
- ),
- (
- global_left,
- global_top,
- global_right,
- min(global_bottom, global_top + y_tolerance),
- ),
- (
- global_left,
- max(global_top, global_bottom - y_tolerance),
- global_right,
- global_bottom,
- ),
- ]
-
- def is_on_margin(obj_bounds: tuple[float, float, float, float]) -> bool:
- left, top, right, bottom = obj_bounds
- return (
- (left - global_left) <= x_tolerance
- or (global_right - right) <= x_tolerance
- or (top - global_top) <= y_tolerance
- or (global_bottom - bottom) <= y_tolerance
- )
-
- candidate_indices: set[int] = set()
- for rect in margin_rectangles():
- if rect[0] > rect[2] or rect[1] > rect[3]:
- continue
- candidate_indices.update(index.intersection(rect))
-
- indices_to_remove = {
- idx
- for idx in candidate_indices
- if idx in smallest_width_indices
- and idx in smallest_height_indices
- and is_on_margin(bounds[idx])
- }
- # Keep only elements that are both tiny and sit within a margin band.
-
- if not indices_to_remove:
- return
-
- if len(indices_to_remove) == sample_count:
- candidate_to_keep = max(
- indices_to_remove,
- key=lambda idx_: widths[idx_] * heights[idx_],
- )
- indices_to_remove.remove(candidate_to_keep)
- if not indices_to_remove:
- return
-
- removed_count = len(indices_to_remove)
- self.page_objects = [
- obj for idx, obj in enumerate(self.page_objects) if idx not in indices_to_remove
- ]
-
- logger.info(f"Removed {removed_count} small margin page objects out of {sample_count}")
diff --git a/page/page_object.py b/page/page_object.py
deleted file mode 100644
index 4263f62..0000000
--- a/page/page_object.py
+++ /dev/null
@@ -1,240 +0,0 @@
-from __future__ import annotations
-
-import numpy as np
-from pydantic import BaseModel, ConfigDict, PrivateAttr, model_validator
-import rtree
-from shapely.affinity import translate
-from shapely.geometry import Polygon as ShapelyPolygon
-
-from churro.utils.log_utils import logger
-
-
-def _rotate_point(
- px: float, py: float, angle: float, center: tuple[float, float]
-) -> tuple[float, float]:
- """Rotate (px, py) around center by angle degrees."""
- angle_rad = np.deg2rad(angle)
- cos_a = np.cos(angle_rad)
- sin_a = np.sin(angle_rad)
- ox, oy = center
- dx = px - ox
- dy = py - oy
- qx = ox + cos_a * dx - sin_a * dy
- qy = oy + sin_a * dx + cos_a * dy
- return qx, qy
-
-
-class PageObject(BaseModel):
- object_id: str
- coordinates: list[float]
- text: str | None = None
-
- model_config = ConfigDict(
- extra="forbid", arbitrary_types_allowed=True, validate_assignment=True
- )
-
- _shapely_polygon: ShapelyPolygon = PrivateAttr()
-
- @model_validator(mode="before")
- @classmethod
- def convert_llm_ocr_text(cls, data: dict[str, object]) -> dict[str, object]:
- if isinstance(data, dict) and data.get("text") is None and "llm_ocr_text" in data:
- data["text"] = data.pop("llm_ocr_text")
- return data
-
- @model_validator(mode="after")
- def initialize_polygon(self) -> PageObject:
- self._set_polygon(self.coordinates)
- return self
-
- def _set_polygon(self, coordinates: list[float]) -> None:
- if not coordinates or len(coordinates) % 2 != 0:
- raise ValueError("Coordinates must contain an even number of values.")
- points = [
- (float(coordinates[i]), float(coordinates[i + 1]))
- for i in range(0, len(coordinates), 2)
- ]
- if points[0] != points[-1]:
- points.append(points[0])
-
- poly = ShapelyPolygon(shell=points)
- # Round coordinates for consistency
- int_coords = [(int(round(x)), int(round(y))) for x, y in poly.exterior.coords]
- poly = ShapelyPolygon(shell=int_coords)
-
- self._shapely_polygon = poly
- flattened = [coord for point in poly.exterior.coords for coord in point]
- object.__setattr__(self, "coordinates", flattened)
-
- def update_coordinates(self, coordinates: list[float]) -> None:
- """Replace the polygon coordinates and refresh the cached Shapely polygon."""
- self._set_polygon(coordinates)
-
- @property
- def width(self) -> float:
- minx, _, maxx, _ = self._shapely_polygon.bounds
- return maxx - minx
-
- @property
- def height(self) -> float:
- _, miny, _, maxy = self._shapely_polygon.bounds
- return maxy - miny
-
- @property
- def left(self) -> float:
- return self._shapely_polygon.bounds[0]
-
- @property
- def right(self) -> float:
- return self._shapely_polygon.bounds[2]
-
- @property
- def top(self) -> float:
- return self._shapely_polygon.bounds[1]
-
- @property
- def bottom(self) -> float:
- return self._shapely_polygon.bounds[3]
-
- @property
- def bounds(self) -> tuple[float, float, float, float]:
- return self.left, self.top, self.right, self.bottom
-
- @property
- def area(self) -> float:
- return self._shapely_polygon.area
-
- def get_top_edge_angle(self) -> float:
- coords = list(self._shapely_polygon.exterior.coords)
- edges = zip(coords, coords[1:], strict=False)
- top_edge = max(edges, key=lambda edge: (edge[0][1] + edge[1][1]) / 2)
- (x1, y1), (x2, y2) = top_edge
- angle = np.rad2deg(np.arctan2(y2 - y1, x2 - x1))
- angle = angle % 90
- return min(angle, 90 - angle)
-
- def contains(self, other: PageObject, tolerance: float = 0.05) -> bool:
- intersection = self._shapely_polygon.intersection(other._shapely_polygon)
- outside_area = other._shapely_polygon.area - intersection.area
- if other._shapely_polygon.area == 0:
- return False
- return (outside_area / other._shapely_polygon.area) <= tolerance
-
- def rotate(
- self,
- angle: float,
- center: tuple[float, float],
- offset_x: float,
- offset_y: float,
- ) -> None:
- rotated = (
- _rotate_point(x, y, angle, center)
- for x, y in zip(self.coordinates[::2], self.coordinates[1::2], strict=False)
- )
- coords = [
- coord + offset
- for point in rotated
- for coord, offset in zip(point, (offset_x, offset_y), strict=False)
- ]
- self._set_polygon(coords)
-
- def relative_coordinates(self) -> list[tuple[float, float]]:
- """Return polygon coordinates translated to the bounding box origin."""
- shift_x = self.left
- shift_y = self.top
- shifted = translate(self._shapely_polygon, xoff=-shift_x, yoff=-shift_y)
- return [(int(round(x)), int(round(y))) for x, y in shifted.exterior.coords]
-
- @staticmethod
- def all_encompassing_rectangle(
- page_objects: list[PageObject],
- object_id: str | None = None,
- ) -> PageObject:
- """Return a rectangle covering all provided page objects.
-
- Useful for creating smaller, more focused visualizations.
- """
- assert page_objects, "Cannot create an all-encompassing rectangle with no page objects"
-
- minx, miny, maxx, maxy = (
- page_objects[0].left,
- page_objects[0].top,
- page_objects[0].right,
- page_objects[0].bottom,
- )
-
- for obj in page_objects[1:]:
- b_left, b_top, b_right, b_bottom = obj.left, obj.top, obj.right, obj.bottom
- minx, miny = min(minx, b_left), min(miny, b_top)
- maxx, maxy = max(maxx, b_right), max(maxy, b_bottom)
-
- coords = [minx, miny, maxx, miny, maxx, maxy, minx, maxy]
- rect_id = object_id or f"{page_objects[0].object_id}-encompassing"
- return PageObject(object_id=rect_id, coordinates=coords)
-
- @staticmethod
- def from_bounds(
- left: float,
- top: float,
- right: float,
- bottom: float,
- object_id: str = "bounds",
- text: str | None = None,
- ) -> PageObject:
- coords = [left, top, right, top, right, bottom, left, bottom]
- return PageObject(object_id=object_id, coordinates=coords, text=text)
-
- @staticmethod
- def remove_subsumed_objects(
- page_objects: list[PageObject], tolerance: float = 0.2
- ) -> list[PageObject]:
- if not page_objects:
- return page_objects
-
- indexed_page_objects = sorted(
- enumerate(page_objects), key=lambda pair: pair[1].area, reverse=True
- )
-
- idx = rtree.index.Index()
- page_objects_to_keep: list[tuple[int, PageObject]] = []
-
- for original_idx, page_object in indexed_page_objects:
- bounds = page_object.bounds
- potential_subsumers = list(idx.intersection(bounds))
-
- for sub_idx in potential_subsumers:
- _, other_page_object = page_objects_to_keep[sub_idx]
- if other_page_object.contains(page_object, tolerance=tolerance):
- break
- else:
- page_objects_to_keep.append((original_idx, page_object))
- idx.insert(len(page_objects_to_keep) - 1, bounds)
-
- removed_count = len(page_objects) - len(page_objects_to_keep)
- if removed_count > 0:
- logger.info(f"Removed {removed_count} subsumed page objects out of {len(page_objects)}")
-
- page_objects_to_keep.sort(key=lambda pair: pair[0])
-
- return [page_object for _, page_object in page_objects_to_keep]
-
- def __hash__(self) -> int:
- """Return a stable hash for comparing page objects in sets and dicts."""
- return hash((self.object_id, tuple(self.coordinates), self.text))
-
- def __eq__(self, other: object) -> bool:
- """Return True if the other page object matches by id, text, and coordinates."""
- if not isinstance(other, PageObject):
- return False
- return (
- self.object_id == other.object_id
- and self.text == other.text
- and tuple(self.coordinates) == tuple(other.coordinates)
- )
-
- def __repr__(self) -> str:
- """Return a concise string representation for debugging the page object."""
- return (
- f"PageObject(id={self.object_id}, left={self.left}, top={self.top}, "
- f"right={self.right}, bottom={self.bottom})"
- )
diff --git a/page/visualization.py b/page/visualization.py
deleted file mode 100644
index 4732165..0000000
--- a/page/visualization.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from PIL import Image, ImageDraw
-
-from .page_object import PageObject
-
-
-def extract_polygon_region(
- image: Image.Image,
- page_object: PageObject,
-) -> Image.Image:
- """Crop a PIL image using a PageObject's polygon.
-
- Parameters:
- image (PIL.Image.Image): The input image to crop.
- page_object (PageObject): The polygon defining the crop area.
- background_color (tuple[int, int, int]): The background color to use for areas outside the polygon.
-
- Returns:
- PIL.Image.Image: The cropped image with the polygon applied as a mask.
- """
- # Extract bounding box dimensions
- width = int(page_object.width)
- height = int(page_object.height)
- min_x = int(page_object.left)
- min_y = int(page_object.top)
-
- # Create a mask image with the same size as the bounding box
- mask = Image.new("L", (width, height), 0)
-
- # Adjust polygon coordinates relative to the bounding box
- relative_points = page_object.relative_coordinates()
-
- # Draw the polygon on the mask
- mask_draw = ImageDraw.Draw(mask)
- mask_draw.polygon(relative_points, fill=255)
-
- # Crop the original image to the bounding box
- cropped_image = image.crop((min_x, min_y, min_x + width, min_y + height))
-
- # Create a new image
- result_image = Image.new("RGB", (width, height), color=(255, 255, 255)) # type: ignore
-
- # Paste the cropped image onto the result image using the mask
- result_image.paste(cropped_image, (0, 0), mask)
-
- return result_image
-
-
-def crop_page_objects_from_image(
- page_objects: list[PageObject],
- original_image: Image.Image,
-) -> list[Image.Image]:
- cropped_images = []
-
- for page_object in page_objects:
- cropped_img = extract_polygon_region(
- original_image,
- page_object,
- )
-
- cropped_images.append(cropped_img)
-
- return cropped_images
-
-
-def crop_image_to_objects(
- image: Image.Image, page_objects: list[PageObject], margin: int = 0
-) -> Image.Image:
- """Remove empty margins around page content.
-
- The function detects the minimal bounding rectangle over all page objects and
- crops the image to it, optionally expanding by ``margin``.
- """
- if margin < 0:
- raise ValueError("Margin must be a non-negative integer.")
-
- polygon = PageObject.all_encompassing_rectangle(page_objects)
- if margin > 0:
- left, top, right, bottom = polygon.bounds
- left = max(left - margin, 0)
- top = max(top - margin, 0)
- right = min(right + margin, image.width)
- bottom = min(bottom + margin, image.height)
- polygon = PageObject.from_bounds(
- left,
- top,
- right,
- bottom,
- object_id=f"{polygon.object_id}-margin",
- )
-
- return extract_polygon_region(image, polygon)
diff --git a/page_boundary/__init__.py b/page_boundary/__init__.py
deleted file mode 100644
index bab9535..0000000
--- a/page_boundary/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Public interfaces for Gemini page boundary detection."""
-
-from .detector import GeminiPageBoundaryDetector, run_page_detection
-
-
-__all__ = [
- "GeminiPageBoundaryDetector",
- "run_page_detection",
-]
diff --git a/page_boundary/_constants.py b/page_boundary/_constants.py
deleted file mode 100644
index 503e1bc..0000000
--- a/page_boundary/_constants.py
+++ /dev/null
@@ -1,73 +0,0 @@
-"""Shared constants for Gemini page boundary detection."""
-
-from __future__ import annotations
-
-
-PAGE_RESPONSE_INSTRUCTIONS = """- Return a JSON object with a key "pages".
-- "pages" must be a list containing zero or more objects.
-- Each object must have:
- * "page_index": 1-based index in reading order.
- * "left": integer normalized 0-1000 describing the minimum horizontal coordinate.
- * "top": integer normalized 0-1000 describing the minimum vertical coordinate.
- * "right": integer normalized 0-1000 describing the maximum horizontal coordinate.
- * "bottom": integer normalized 0-1000 describing the maximum vertical coordinate.
-- Provide all coordinates as integers (no decimals) and keep them normalized to the
- 0-1000 range.
-- The box should cover every part of the physical page contents, including all printed
- text, handwriting, stamps, or drawings that belong on the page surface.
-- Bounding boxes must cover the entire page content, excluding empty margins.
-- Do not include printer artifacts, scanner noise, shadows, binding rings, or any
- background beyond the true page edges.
-- If no page is visible, return {"pages": []}.
-"""
-
-PAGE_DETECTION_PROMPT = (
- "You are a careful vision assistant that locates the page content inside\n"
- "scanned or photographed document images. Return tight bounding boxes around each\n"
- "page (there will be either one page or a two-page spread). Ensure coordinates are\n"
- "precise.\n\n"
- "Identify every document page in this image. Follow these rules:\n"
- f"{PAGE_RESPONSE_INSTRUCTIONS}"
-)
-
-PAGE_REVIEW_PROMPT_TEMPLATE = (
- "You are an expert reviewer of document page boundary annotations. Evaluate the provided\n"
- "image with red rectangles drawn from the most recent prediction and output corrected boxes\n"
- "in the same JSON structure when adjustments are needed.\n\n"
- "Only the annotated preview image is supplied, so rely on the drawn rectangles and the\n"
- "visible page content to decide whether corrections are necessary.\n\n"
- "The currently drawn bounding boxes are summarized with lines such as\n"
- '"left : 412" for each page. Use them as context, but respond with a fresh JSON object that\n'
- "follows the required schema.\n\n"
- "Inspect the red rectangles and correct them if they are inaccurate or incomplete. Think about how much they should be moved to better fit the page content.\n"
- "Respond with JSON following these rules:\n"
- f"{PAGE_RESPONSE_INSTRUCTIONS}"
- "If the rectangles are already correct, you may return the same coordinates."
-)
-
-PAGE_DETECTION_BOX_WIDTH = 10
-MAX_PAGE_REVIEW_ROUNDS = 4
-BORDER_FRACTION = 0.10
-PROCESSED_MAX_DIM = 1500
-GUIDELINE_COLOR = "#ff3b30"
-DEFAULT_MODEL_KEY = "gemini-2.5-pro-high"
-DAMPENING_CONSTANT = 1
-
-_SCALE_WITH_BORDER = 1 + 2 * BORDER_FRACTION
-NORMALIZED_MIN_COORD = (BORDER_FRACTION / _SCALE_WITH_BORDER) * 1000
-NORMALIZED_MAX_COORD = ((1 + BORDER_FRACTION) / _SCALE_WITH_BORDER) * 1000
-
-__all__ = [
- "PAGE_RESPONSE_INSTRUCTIONS",
- "PAGE_DETECTION_PROMPT",
- "PAGE_REVIEW_PROMPT_TEMPLATE",
- "PAGE_DETECTION_BOX_WIDTH",
- "MAX_PAGE_REVIEW_ROUNDS",
- "BORDER_FRACTION",
- "DAMPENING_CONSTANT",
- "PROCESSED_MAX_DIM",
- "GUIDELINE_COLOR",
- "DEFAULT_MODEL_KEY",
- "NORMALIZED_MIN_COORD",
- "NORMALIZED_MAX_COORD",
-]
diff --git a/page_boundary/_image_processing.py b/page_boundary/_image_processing.py
deleted file mode 100644
index c271f64..0000000
--- a/page_boundary/_image_processing.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""Image preprocessing helpers for Gemini page detection."""
-
-from __future__ import annotations
-
-from collections.abc import Iterable
-
-from PIL import Image, ImageDraw, ImageOps
-
-from churro.page import PageObject, extract_polygon_region
-from churro.utils.image.transform import adjust_image, resize_image_to_fit
-
-from ._constants import (
- BORDER_FRACTION,
- GUIDELINE_COLOR,
- PAGE_DETECTION_BOX_WIDTH,
- PROCESSED_MAX_DIM,
-)
-from ._models import PageBox, PageDetectionTransform
-
-
-def _add_white_border(
- image: Image.Image, fraction: float = BORDER_FRACTION
-) -> tuple[Image.Image, int, int]:
- """Add a white border around the image proportional to the input size."""
- if fraction <= 0:
- return image, 0, 0
- border_w = max(1, int(round(image.width * fraction)))
- border_h = max(1, int(round(image.height * fraction)))
- expanded = ImageOps.expand(
- image,
- border=(border_w, border_h, border_w, border_h),
- fill="white",
- )
- return expanded, border_w, border_h
-
-
-def prepare_page_image(image: Image.Image) -> tuple[Image.Image, PageDetectionTransform]:
- """Normalize an input image so Gemini receives a padded and size-limited RGB copy."""
- original_size = image.size
- rgb_image = image.convert("RGB")
- grayscale = adjust_image(rgb_image, thresholding=True)
- padded, border_w, border_h = _add_white_border(grayscale)
- padded_size = padded.size
- processed = resize_image_to_fit(padded, PROCESSED_MAX_DIM, PROCESSED_MAX_DIM)
- processed_size = processed.size
- scale_x = processed_size[0] / padded_size[0] if padded_size[0] else 1.0
- scale_y = processed_size[1] / padded_size[1] if padded_size[1] else 1.0
- processed_rgb = processed.convert("RGB") if processed.mode != "RGB" else processed
- transform = PageDetectionTransform(
- original_size=original_size,
- border=(border_w, border_h),
- padded_size=padded_size,
- processed_size=processed_size,
- scale_x=scale_x,
- scale_y=scale_y,
- )
- return processed_rgb, transform
-
-
-def convert_boxes_to_original_polygons(
- boxes: Iterable[PageBox],
- transform: PageDetectionTransform,
-) -> list[PageObject]:
- """Map normalized Gemini bounding boxes back to polygons on the original image."""
- processed_w, processed_h = transform.processed_size
- original_w, original_h = transform.original_size
- border_w, border_h = transform.border
- scale_x = transform.scale_x or 1.0
- scale_y = transform.scale_y or 1.0
-
- polygons: list[PageObject] = []
- for box in boxes:
- left_proc, top_proc, right_proc, bottom_proc = box.denormalize(processed_w, processed_h)
- left_padded = left_proc / scale_x
- right_padded = right_proc / scale_x
- top_padded = top_proc / scale_y
- bottom_padded = bottom_proc / scale_y
-
- left_orig = max(0.0, min(original_w, left_padded - border_w))
- right_orig = max(0.0, min(original_w, right_padded - border_w))
- top_orig = max(0.0, min(original_h, top_padded - border_h))
- bottom_orig = max(0.0, min(original_h, bottom_padded - border_h))
-
- polygons.append(
- PageObject.from_bounds(
- left_orig,
- top_orig,
- right_orig,
- bottom_orig,
- object_id=f"box-{box.page_index}-{len(polygons)}",
- )
- )
-
- return polygons
-
-
-def draw_boxes(image: Image.Image, boxes: Iterable[PageBox]) -> Image.Image:
- """Overlay denormalized boxes onto a copy of the image."""
- annotated = image.copy()
- draw = ImageDraw.Draw(annotated)
- width, height = annotated.size
-
- for box in boxes:
- left, top, right, bottom = box.denormalize(width, height)
- draw.rectangle(
- [left, top, right, bottom],
- outline=GUIDELINE_COLOR,
- width=PAGE_DETECTION_BOX_WIDTH,
- )
- return annotated
-
-
-def extract_crops(source: Image.Image, polygons: Iterable[PageObject]) -> list[Image.Image]:
- """Create page crops from the provided polygons."""
- return [extract_polygon_region(source, polygon) for polygon in polygons]
-
-
-__all__ = [
- "prepare_page_image",
- "convert_boxes_to_original_polygons",
- "draw_boxes",
- "extract_crops",
-]
diff --git a/page_boundary/_models.py b/page_boundary/_models.py
deleted file mode 100644
index 6b78bde..0000000
--- a/page_boundary/_models.py
+++ /dev/null
@@ -1,101 +0,0 @@
-"""Dataclasses describing Gemini page detection structures."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Any
-
-from PIL import Image
-
-from churro.page import PageObject
-
-from ._constants import NORMALIZED_MAX_COORD, NORMALIZED_MIN_COORD
-
-
-def _clamp_normalized(value: float) -> int:
- clamped = max(NORMALIZED_MIN_COORD, min(NORMALIZED_MAX_COORD, value))
- rounded = int(round(clamped))
- return max(0, min(1000, rounded))
-
-
-@dataclass
-class PageDetectionTransform:
- """Stores geometry needed to map detection outputs back to the original image."""
-
- original_size: tuple[int, int]
- border: tuple[int, int]
- padded_size: tuple[int, int]
- processed_size: tuple[int, int]
- scale_x: float
- scale_y: float
-
-
-@dataclass
-class PageBox:
- """Normalized Gemini bounding box."""
-
- page_index: int
- ymin: int
- xmin: int
- ymax: int
- xmax: int
-
- @classmethod
- def from_json(cls, payload: dict[str, Any]) -> PageBox:
- if "page_index" not in payload:
- raise ValueError("Expected 'page_index' key in Gemini response.")
- required_keys = {"left", "top", "right", "bottom"}
- if not required_keys.issubset(payload):
- missing = required_keys - set(payload)
- raise ValueError(
- f"Gemini response must include keys {sorted(required_keys)},"
- f" missing {sorted(missing)}.",
- )
-
- ymin = _clamp_normalized(float(payload["top"]))
- xmin = _clamp_normalized(float(payload["left"]))
- ymax = _clamp_normalized(float(payload["bottom"]))
- xmax = _clamp_normalized(float(payload["right"]))
- return cls(
- page_index=int(payload["page_index"]),
- ymin=ymin,
- xmin=xmin,
- ymax=ymax,
- xmax=xmax,
- )
-
- def denormalize(self, width: int, height: int) -> tuple[int, int, int, int]:
- """Convert 0-1000 normalized box to pixel coordinates."""
- top = max(0, min(height, int(round(self.ymin * height / 1000))))
- left = max(0, min(width, int(round(self.xmin * width / 1000))))
- bottom = max(0, min(height, int(round(self.ymax * height / 1000))))
- right = max(0, min(width, int(round(self.xmax * width / 1000))))
- return left, top, right, bottom
-
- def to_json_dict(self) -> dict[str, Any]:
- return {
- "page_index": self.page_index,
- "left": self.xmin,
- "top": self.ymin,
- "right": self.xmax,
- "bottom": self.ymax,
- }
-
-
-@dataclass
-class PageDetectionResult:
- """Outputs produced by the Gemini page detection pipeline."""
-
- crops: list[Image.Image]
- boxes: list[PageBox]
- polygons_aligned: list[PageObject]
- polygons_original: list[PageObject]
- annotated_image: Image.Image
- transform: PageDetectionTransform
-
-
-__all__ = [
- "PageDetectionTransform",
- "PageBox",
- "PageDetectionResult",
-]
diff --git a/page_boundary/_pipeline.py b/page_boundary/_pipeline.py
deleted file mode 100644
index 02aee68..0000000
--- a/page_boundary/_pipeline.py
+++ /dev/null
@@ -1,203 +0,0 @@
-"""Core Gemini page boundary detection pipeline primitives."""
-
-from __future__ import annotations
-
-from collections.abc import Iterable, Sequence
-from pathlib import Path
-
-from PIL import Image
-
-from churro.utils.llm import run_llm_async
-from churro.utils.log_utils import logger
-
-from ._constants import (
- DAMPENING_CONSTANT,
- MAX_PAGE_REVIEW_ROUNDS,
- PAGE_DETECTION_PROMPT,
- PAGE_REVIEW_PROMPT_TEMPLATE,
-)
-from ._image_processing import draw_boxes
-from ._models import PageBox
-from ._serialization import boxes_equal, parse_pages_json
-
-
-async def detect_page_boxes(image: Image.Image, model_key: str) -> list[PageBox]:
- """Call Gemini to predict normalized page bounding boxes."""
- response_text = await run_llm_async(
- model=model_key,
- system_prompt_text=PAGE_DETECTION_PROMPT,
- user_message_text=None,
- user_message_image=image,
- image_detail="high",
- output_json=True,
- )
- logger.info(f"Initial Gemini response: {response_text}")
- return parse_pages_json(response_text)
-
-
-async def review_page_boxes(
- annotated_image: Image.Image,
- history_summary: str,
- history_steps: int,
- model_key: str,
-) -> list[PageBox]:
- """Review and optionally refine bounding boxes using Gemini with visual feedback."""
- review_prompt = PAGE_REVIEW_PROMPT_TEMPLATE
- if history_summary:
- history_sections = [
- "Here are the coordinates for the currently drawn bounding boxes:",
- history_summary,
- ]
- review_prompt += "\n\n" + "\n".join(history_sections)
- response_text = await run_llm_async(
- model=model_key,
- system_prompt_text=review_prompt,
- user_message_text=None,
- user_message_image=annotated_image,
- image_detail="high",
- output_json=True,
- )
- logger.info(
- f"Review Gemini response (history rounds={history_steps}): {response_text}",
- )
- return parse_pages_json(response_text)
-
-
-async def run_detection_pipeline(
- image: Image.Image,
- model_key: str,
- max_review_rounds: int = MAX_PAGE_REVIEW_ROUNDS,
-) -> list[PageBox]:
- """Perform initial Gemini detection and iterative review refinement."""
- initial_boxes = await detect_page_boxes(image, model_key=model_key)
- history_boxes: list[list[PageBox]] = [initial_boxes]
- final_boxes = initial_boxes
-
- for round_idx in range(max(0, max_review_rounds)):
- history_summary = _format_boxes_for_prompt(history_boxes[-1])
- dampening_factor = DAMPENING_CONSTANT ** len(history_boxes)
- annotated_preview = draw_boxes(image, history_boxes[-1])
- reviewed_boxes: list[PageBox] | None = None
- try:
- reviewed_boxes = await review_page_boxes(
- annotated_preview,
- history_summary,
- history_steps=len(history_boxes),
- model_key=model_key,
- )
- except Exception as exc: # pylint: disable=broad-except
- logger.info(
- f"Review round {round_idx + 1} failed, stopping reviews: {exc}",
- )
- break
-
- if reviewed_boxes is None:
- break
-
- if not reviewed_boxes:
- break
-
- dampened_boxes = _apply_dampening(history_boxes[-1], reviewed_boxes, dampening_factor)
-
- if boxes_equal(dampened_boxes, history_boxes[-1]):
- final_boxes = dampened_boxes
- break
-
- history_boxes.append(dampened_boxes)
- final_boxes = dampened_boxes
-
- _log_box_history(history_boxes)
-
- return final_boxes
-
-
-def save_page_crops(crops: Iterable[Image.Image], output_path: Path) -> list[Path]:
- """Persist each page crop to disk next to the annotated image."""
- saved_paths: list[Path] = []
- for idx, crop in enumerate(crops, start=1):
- crop_path = output_path.with_name(f"{output_path.stem}_page{idx}.png")
- crop.save(crop_path)
- saved_paths.append(crop_path)
- return saved_paths
-
-
-__all__ = [
- "detect_page_boxes",
- "review_page_boxes",
- "run_detection_pipeline",
- "save_page_crops",
-]
-
-
-def _build_coordinate_history(
- history_boxes: Sequence[Sequence[PageBox]],
-) -> dict[int, dict[str, list[int]]]:
- per_page_history: dict[int, dict[str, list[int]]] = {}
- for boxes in history_boxes:
- for box in boxes:
- page = per_page_history.setdefault(
- box.page_index,
- {"left": [], "top": [], "right": [], "bottom": []},
- )
- page["left"].append(box.xmin)
- page["top"].append(box.ymin)
- page["right"].append(box.xmax)
- page["bottom"].append(box.ymax)
- return per_page_history
-
-
-def _format_boxes_for_prompt(boxes: Sequence[PageBox]) -> str:
- if not boxes:
- return ""
- label_width = max(len(key) for key in ("left", "top", "right", "bottom"))
- lines: list[str] = []
- for box in sorted(boxes, key=lambda item: item.page_index):
- lines.append(f"Page {box.page_index}:")
- lines.append(f"{'left'.ljust(label_width)}: {box.xmin}")
- lines.append(f"{'top'.ljust(label_width)}: {box.ymin}")
- lines.append(f"{'right'.ljust(label_width)}: {box.xmax}")
- lines.append(f"{'bottom'.ljust(label_width)}: {box.ymax}")
- return "\n".join(lines)
-
-
-def _log_box_history(history_boxes: Sequence[Sequence[PageBox]]) -> None:
- per_page_history = _build_coordinate_history(history_boxes)
- if not per_page_history:
- return
- label_width = max(len(key) for key in ("left", "top", "right", "bottom"))
- for page_index in sorted(per_page_history):
- logger.info(f"Page {page_index} coordinate history:")
- history = per_page_history[page_index]
- for key in ("left", "top", "right", "bottom"):
- formatted = " -> ".join(str(value) for value in history[key])
- logger.info(f"{key.ljust(label_width)}: {formatted}")
-
-
-def _apply_dampening(
- previous_boxes: Sequence[PageBox],
- updated_boxes: Sequence[PageBox],
- factor: float,
-) -> list[PageBox]:
- prev_by_page = {box.page_index: box for box in previous_boxes}
- dampened: list[PageBox] = []
- for box in updated_boxes:
- previous = prev_by_page.get(box.page_index)
- if previous is None:
- dampened.append(box)
- continue
- dampened.append(
- PageBox(
- page_index=box.page_index,
- ymin=_blend_coordinate(previous.ymin, box.ymin, factor),
- xmin=_blend_coordinate(previous.xmin, box.xmin, factor),
- ymax=_blend_coordinate(previous.ymax, box.ymax, factor),
- xmax=_blend_coordinate(previous.xmax, box.xmax, factor),
- )
- )
- return dampened
-
-
-def _blend_coordinate(previous: int, current: int, factor: float) -> int:
- delta = current - previous
- adjusted = previous + delta * factor
- return max(0, min(1000, int(round(adjusted))))
diff --git a/page_boundary/_serialization.py b/page_boundary/_serialization.py
deleted file mode 100644
index 9079b36..0000000
--- a/page_boundary/_serialization.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""Serialization helpers for Gemini page boundary payloads."""
-
-from __future__ import annotations
-
-from collections.abc import Iterable
-import json
-
-from ._models import PageBox
-
-
-def strip_code_fence(raw: str) -> str:
- """Remove a leading/trailing triple-backtick fence if present."""
- text = raw.strip()
- if text.startswith("```"):
- lines = text.splitlines()
- if len(lines) >= 2:
- lines = lines[1:]
- if lines and lines[-1].startswith("```"):
- lines = lines[:-1]
- text = "\n".join(lines).strip()
- return text
-
-
-def parse_pages_json(raw: str) -> list[PageBox]:
- cleaned = strip_code_fence(raw)
- try:
- parsed = json.loads(cleaned)
- except json.JSONDecodeError as exc:
- raise ValueError(f"Failed to decode Gemini response as JSON: {exc}") from exc
-
- pages_data = parsed.get("pages", [])
- if not isinstance(pages_data, list):
- raise ValueError("Gemini response JSON must include a 'pages' list.")
- page_boxes = [PageBox.from_json(item) for item in pages_data]
- return sorted(page_boxes, key=lambda box: box.page_index)
-
-
-def boxes_to_json_payload(boxes: Iterable[PageBox]) -> str:
- payload = {"pages": [box.to_json_dict() for box in boxes]}
- return json.dumps(payload, ensure_ascii=False)
-
-
-def boxes_equal(a: Iterable[PageBox], b: Iterable[PageBox]) -> bool:
- list_a = list(a)
- list_b = list(b)
- if len(list_a) != len(list_b):
- return False
- for box_a, box_b in zip(list_a, list_b, strict=False):
- if box_a.page_index != box_b.page_index:
- return False
- coords_a = (box_a.ymin, box_a.xmin, box_a.ymax, box_a.xmax)
- coords_b = (box_b.ymin, box_b.xmin, box_b.ymax, box_b.xmax)
- if coords_a != coords_b:
- return False
- return True
-
-
-__all__ = [
- "parse_pages_json",
- "boxes_to_json_payload",
- "boxes_equal",
-]
diff --git a/page_boundary/cli.py b/page_boundary/cli.py
deleted file mode 100644
index fa814a2..0000000
--- a/page_boundary/cli.py
+++ /dev/null
@@ -1,103 +0,0 @@
-"""Command-line interface for Gemini page boundary detection."""
-
-from __future__ import annotations
-
-import argparse
-import asyncio
-from pathlib import Path
-
-from PIL import Image
-
-from churro.utils.llm import log_total_llm_cost
-from churro.utils.log_utils import logger
-
-from ._constants import DEFAULT_MODEL_KEY, MAX_PAGE_REVIEW_ROUNDS
-from ._pipeline import save_page_crops
-from ._serialization import boxes_to_json_payload
-from .detector import run_page_detection
-
-
-def parse_args() -> argparse.Namespace:
- """Parse CLI arguments for running the Gemini page boundary detector."""
- parser = argparse.ArgumentParser(
- description="Detect document page boundaries using Gemini.",
- )
- parser.add_argument(
- "image",
- type=Path,
- help="Path to the input image containing one or two document pages.",
- )
- parser.add_argument(
- "-o",
- "--output",
- type=Path,
- help="Path for the annotated image. Defaults to _boxed.png.",
- )
- parser.add_argument(
- "--model",
- type=str,
- default=DEFAULT_MODEL_KEY,
- help="Logical model key defined in utils.llm.models.MODEL_MAP.",
- )
- parser.add_argument(
- "--max-review-rounds",
- type=int,
- default=MAX_PAGE_REVIEW_ROUNDS,
- help="Maximum number of Gemini review passes to perform.",
- )
- return parser.parse_args()
-
-
-async def _async_main(
- image_path: Path,
- output_path: Path,
- model_key: str,
- max_review_rounds: int,
-) -> None:
- """Run the detection pipeline and persist annotated outputs asynchronously."""
- with Image.open(image_path) as img:
- original_rgb = img.convert("RGB")
- detection_result = await run_page_detection(
- original_rgb,
- model_key=model_key,
- max_review_rounds=max_review_rounds,
- )
- detection_result.annotated_image.save(output_path)
-
- crop_paths = save_page_crops(detection_result.crops, output_path)
- for idx, polygon in enumerate(detection_result.polygons_original, start=1):
- left, top, right, bottom = polygon.bounds
- logger.info(
- f"Page {idx} original coords: left={left:.1f}, top={top:.1f},"
- f" right={right:.1f}, bottom={bottom:.1f}",
- )
- for path in crop_paths:
- logger.info(f"Saved page crop: {path}")
- logger.info(f"Final boxes JSON: {boxes_to_json_payload(detection_result.boxes)}")
- logger.info(
- f"Saved annotated image with {len(detection_result.boxes)} page box(es)"
- f" to '{output_path}'",
- )
- log_total_llm_cost()
-
-
-def main() -> None:
- """CLI entry point for Gemini page boundary detection."""
- args = parse_args()
- image_path = args.image
- if not image_path.exists():
- raise FileNotFoundError(f"Missing input image: {image_path}")
- if not image_path.is_file():
- raise ValueError(f"Input path must be a file: {image_path}")
-
- default_output = image_path.with_name(f"{image_path.stem}_boxed.png")
- output_path: Path = args.output or default_output
-
- asyncio.run(_async_main(image_path, output_path, args.model, args.max_review_rounds))
-
-
-__all__ = ["main"]
-
-
-if __name__ == "__main__":
- main()
diff --git a/page_boundary/detector.py b/page_boundary/detector.py
deleted file mode 100644
index 9375e63..0000000
--- a/page_boundary/detector.py
+++ /dev/null
@@ -1,65 +0,0 @@
-"""High-level Gemini page boundary detector interfaces."""
-
-from __future__ import annotations
-
-from PIL import Image
-
-from ._constants import DEFAULT_MODEL_KEY, MAX_PAGE_REVIEW_ROUNDS
-from ._image_processing import (
- convert_boxes_to_original_polygons,
- draw_boxes,
- extract_crops,
- prepare_page_image,
-)
-from ._models import PageDetectionResult
-from ._pipeline import run_detection_pipeline
-
-
-class GeminiPageBoundaryDetector:
- """Runs the Gemini page detection pipeline with optional review rounds."""
-
- def __init__(
- self,
- model_key: str = DEFAULT_MODEL_KEY,
- max_review_rounds: int = MAX_PAGE_REVIEW_ROUNDS,
- ) -> None:
- self.model_key = model_key
- self.max_review_rounds = max(0, max_review_rounds)
-
- async def detect(self, image: Image.Image) -> PageDetectionResult:
- processed_image, transform = prepare_page_image(image)
- final_boxes = await run_detection_pipeline(
- processed_image,
- model_key=self.model_key,
- max_review_rounds=self.max_review_rounds,
- )
- annotated = draw_boxes(processed_image, final_boxes)
- polygons_original = convert_boxes_to_original_polygons(final_boxes, transform)
- crops = extract_crops(image, polygons_original)
- return PageDetectionResult(
- crops=crops,
- boxes=final_boxes,
- polygons_aligned=polygons_original,
- polygons_original=polygons_original,
- annotated_image=annotated,
- transform=transform,
- )
-
-
-async def run_page_detection(
- image: Image.Image,
- model_key: str = DEFAULT_MODEL_KEY,
- max_review_rounds: int = MAX_PAGE_REVIEW_ROUNDS,
-) -> PageDetectionResult:
- """Run Gemini page detection and return crops plus metadata."""
- detector = GeminiPageBoundaryDetector(
- model_key=model_key,
- max_review_rounds=max_review_rounds,
- )
- return await detector.detect(image)
-
-
-__all__ = [
- "GeminiPageBoundaryDetector",
- "run_page_detection",
-]
diff --git a/pixi.lock b/pixi.lock
index aec5c4a..1b2c0dc 100644
--- a/pixi.lock
+++ b/pixi.lock
@@ -1,6 +1,6 @@
version: 6
environments:
- default:
+ cuda:
channels:
- url: https://conda.anaconda.org/conda-forge/
- url: https://conda.anaconda.org/main/
@@ -8,209 +8,396 @@ environments:
- url: https://conda.anaconda.org/msys2/
indexes:
- https://pypi.org/simple
+ options:
+ pypi-prerelease-mode: if-necessary-or-explicit
packages:
linux-64:
- - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2
- - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2
- - conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-h39aace5_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-hb03c661_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-cudart-12.9.79-h5888daf_0.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/cuda-cudart_linux-64-12.9.79-h3f2d84a_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-libraries-12.9.1-ha770c72_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-nvrtc-12.9.86-hecca717_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-opencl-12.9.19-h5888daf_0.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/cuda-version-12.9-h4f385c5_3.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/cudnn-9.13.1.26-hbcb9cd8_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-ha97dd6f_2.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.76-h0b2e76d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/cudnn-9.10.2.21-hbcb9cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_101.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libattr-2.5.2-hb03c661_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.77-h3ff7636_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcublas-12.9.1.4-h676940d_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-9.13.1.26-hf7e9902_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-dev-9.13.1.26-h58dd1b1_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-9.10.2.21-hf7e9902_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-dev-9.10.2.21-h58dd1b1_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcufft-11.4.1.4-hecca717_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcufile-1.14.1.1-hbc026e6_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcurand-10.3.10.19-h676940d_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcusolver-11.7.5.82-h676940d_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcusparse-12.5.10.65-hecca717_2.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-lib-1.11.1-hb9d3cd8_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgpg-error-1.55-h3f2d84a_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnl-3.11.0-hb9d3cd8_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnpp-12.4.1.87-h676940d_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnvfatbin-12.9.82-hecca717_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnvjitlink-12.9.86-hecca717_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnvjpeg-12.4.0.76-hecca717_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.9-h996ca69_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.9-h085a93f_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-hd0affe5_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-hd0affe5_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.3-hb9d3cd8_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-h5888daf_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.4-h26f9b46_0.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.2-pyh8b19718_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hfe2f287_0_cpython.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/rdma-core-60.0-hecca717_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda
- - pypi: https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/rdma-core-61.0-h192683f_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda
+ - pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/59/911a1a597264f1fb7ac176995a0f0b6062e37f8c1b6e0f23071a76838507/cuda_pathfinder-1.4.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3e/af/3e990d8d4002bbc9342adb4facd59506e653da93b2417de0fa6027cb86b1/evaluate-0.4.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/3a/590d58dee65a238f7f3d5c37f8f9f9021ecaf27fe379a393b4259324b56e/litellm-1.82.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ff/49/a640b288a48dab1752281dd9b72c0679fccea107874e80a65a606b00efa9/pypdfium2-5.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl
+ - pypi: 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
+ - pypi: https://files.pythonhosted.org/packages/c4/43/80f67e0336cb2fc725f8e06f7fe35c1d0fe946f4d2b8b2175e797e07349e/qwen_vl_utils-0.0.14-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl
+ - pypi: ./
+ default:
+ channels:
+ - url: https://conda.anaconda.org/conda-forge/
+ - url: https://conda.anaconda.org/main/
+ - url: https://conda.anaconda.org/r/
+ - url: https://conda.anaconda.org/msys2/
+ indexes:
+ - https://pypi.org/simple
+ options:
+ pypi-prerelease-mode: if-necessary-or-explicit
+ packages:
+ linux-64:
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_101.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda
+ - pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/14/51/d0c1701a79fcb0109cff5304da16226581569b89a282d8e7f1549a7e3ec0/aiohttp-3.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/76/74/5d90ad14d55fbe3f9c474fdcb6e34b4bed99e3be8efac98734a5ddce88c1/anthropic-0.49.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/b1/3c/b90d5afc2e47c4a45f4bba00f9c3193b0417fad5ad3bb07869f9d12832aa/azure_core-1.36.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/91/9e/0bbbd09b116fd8ee2d3617e28e6598551d2f0f24d3a2ce99cc87ec85aeb0/datasets-4.2.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/59/911a1a597264f1fb7ac176995a0f0b6062e37f8c1b6e0f23071a76838507/cuda_pathfinder-1.4.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a9/b6/f85666707b9f9ff94ada851cbaa6f1c91a0f5802aa5e498772251e1e7772/elementpath-5.0.4-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/3e/af/3e990d8d4002bbc9342adb4facd59506e653da93b2417de0fa6027cb86b1/evaluate-0.4.6-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/6a/2b/1b89e90a8635e5587ccdbbeb169c590672ce7637880f2c047482a0359950/fastuuid-0.13.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/ee/1b/00a78aa2e8fbd63f9af08c9c19e6deb3d5d66b4dda677a0f61654680ee89/flatbuffers-25.9.23-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/77/ad/f73cf9fe9bd95918502b270e3ddb8764e4c900b3bbd7782b90c56fac14bb/google_api_core-2.26.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f4/38/d25ae1565103a545cf18207a5dec09a6d39ad88e5b0399a2430e9edb0550/google_api_python_client-2.184.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/bd/f6/806b39f86f912133a3071ffa9ff99801a12868216069e26c83a48943116b/google_cloud_aiplatform-1.121.0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/39/3c/c8cada9ec282b29232ed9aed5a0b5cca6cf5367cb2ffa8ad0d2583d743f1/google_cloud_bigquery-3.38.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/40/86/bda7241a8da2d28a754aad2ba0f6776e35b67e37c36ae0c45d49370f1014/google_cloud_core-2.4.3-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/24/92/e3c4850d5d1dd83da7538f1ee91baac9d4bc849e96899bc23ddbe20f15f4/google_cloud_documentai-3.6.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/b1/ea/a92631c358da377af34d3a9682c97af83185c2d66363d5939ab4a1169a7f/google_cloud_resource_manager-1.14.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/d5/94/6db383d8ee1adf45dc6c73477152b82731fa4c4a46d9c1932cc8757e0fd4/google_cloud_storage-2.19.0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/3b/a5/7279055cf004561894ed3a7bfdf5bf90a53f28fadd01af7cd166e88ddf16/google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/11/8f/922116dabe3d0312f08903d324db6ac9d406832cf57707550bc61151d91b/google_genai-1.45.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/82/35/b8d3baf8c46695858cb9d8835a53baa1eeb9906ddaf2f728a5f5b640fd1e/google_resumable_media-2.7.2-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/1e/9c/eda9fe57f2b84343d44c1b66cf3831c973ba29b078b16a27d4587a1fdd47/grpcio-1.75.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/d8/ad/6f414bb0b36eee20d93af6907256f208ffcda992ae6d3d7b6a778afe31e6/grpcio_status-1.75.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/15/07/86397573efefff941e100367bbda0b21496ffcdb34db7ab51912994c32a2/hf_xet-1.1.10-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/8c/a2/0d269db0f6163be503775dc8b6a6fa15820cc9fdc866f6ba608d86b721f2/httplib2-0.31.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/cb/bd/b394387b598ed84d8d0fa90611a90bee0adc2021820ad5729f7ced74a8e2/imageio-2.37.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/9a/5f/0dc34563d8164d31d07bc09d141d3da08157a68dcd1f9b886fa4e917805b/jiter-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/fc/fb/38a48efe3e05a8e9a9765b991740282e0358a83fb896ec00d70bf1448791/litellm-1.78.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/3a/590d58dee65a238f7f3d5c37f8f9f9021ecaf27fe379a393b4259324b56e/litellm-1.82.3-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/fe/76/4ce12563aea5a76016f8643eff30ab731e6656c845e9e4d090ef10c7b925/mistralai-1.9.11-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/0a/7d/a988f258104dcd2ccf1ed40fdc97e26c4ac351eeaf81d76e266c52d84e2f/multiprocess-0.70.16-py312-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/6c/d9/b7140a4f1615195938c7e358c0804bb84271f0d6886b5cbf105c6cb58aae/onnxruntime_gpu-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/9c/5b/4be258ff072ed8ee15f6bfd8d5a1a4618aa4704b127c0c5959212ad177d6/openai-2.3.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/5b/5a/1292a0df4ff71fbc00dfa8c08759d17c97e1e8ea9277eb5bc5f079ca188d/pymupdf-1.26.5-cp39-abi3-manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ff/49/a640b288a48dab1752281dd9b72c0679fccea107874e80a65a606b00efa9/pypdfium2-5.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl
- pypi: 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
- - pypi: https://files.pythonhosted.org/packages/20/14/7399c18c460e72d1b754e80dafc9f65cb42a46cc8f29cd57d11c0c4acc94/rapidfuzz-3.14.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c4/43/80f67e0336cb2fc725f8e06f7fe35c1d0fe946f4d2b8b2175e797e07349e/qwen_vl_utils-0.0.14-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/35/9e/a91b50332a9750519320ed30ec378b74c996f6befe282cfa6bb6cea7e9fd/regex-2025.9.18-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e2/c5/9136736c37022a6ad27fea38f3111eb8f02fe75d067f9a985cc358653102/rouge_score-0.1.2.tar.gz
- - pypi: https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/53/11/a0160990b82999b45874dc60c0c183d3a3a969a563fffc476d5a9995c407/scipy-1.16.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/6e/ff/e2f8dae90fb642b4b6f24464a2f96a3dc3b69151c51f7db24433be0f3f56/tifffile-2025.10.4-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/dc/1a/81ac398d35be848f3655893b6260f227b29ca6acf5c2674dc5ed9af63027/xmlschema-4.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl
- pypi: ./
minimal:
@@ -221,48 +408,90 @@ environments:
- url: https://conda.anaconda.org/msys2/
indexes:
- https://pypi.org/simple
+ options:
+ pypi-prerelease-mode: if-necessary-or-explicit
packages:
linux-64:
- - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2
- - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2
- - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1aa0949_4.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_101.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.4-h26f9b46_0.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.2-pyh8b19718_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hd63d673_1_cpython.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda
- - pypi: https://files.pythonhosted.org/packages/77/85/85951bc0f9843e2c10baaa1b6657227056095de08f4d1eea7d8b423a6832/accelerate-1.11.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda
+ - pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/59/911a1a597264f1fb7ac176995a0f0b6062e37f8c1b6e0f23071a76838507/cuda_pathfinder-1.4.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3e/af/3e990d8d4002bbc9342adb4facd59506e653da93b2417de0fa6027cb86b1/evaluate-0.4.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
@@ -276,55 +505,72 @@ environments:
- pypi: https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/3b/6c/99acb2f9eb85c29fc6f3a7ac4dccfd992e22666dd08a642b303311326a97/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/30/28/5e27f4d5a0e347f8e3cc16cd7d35533dbce086c95807f1f0e9cd77e26c10/psutil-7.1.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
- pypi: 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
- - pypi: https://files.pythonhosted.org/packages/99/14/9a39b7c9e007968411bc3c843cc14cf15437510c0a9991f080cab654fd16/regex-2025.10.23-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c4/43/80f67e0336cb2fc725f8e06f7fe35c1d0fe946f4d2b8b2175e797e07349e/qwen_vl_utils-0.0.14-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/fe/5d/5a514d7b88e310c8b146e2404e0dc161282e78634d9358975fd56dfd14be/safetensors-0.6.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/a5/4b/f4bb2e6c25d0272f798cd6d7a04ed315da76cec68c602d87040c7847287f/torch-2.9.0-cp312-cp312-manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/7e/e6/7324ead6793075a8c75c56abeed1236d1750de16a5613cfe2ddad164a92a/torchvision-0.24.0-cp312-cp312-manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/71/d3/c16c3b3cf7655a67db1144da94b021c200ac1303f82428f2beef6c2e72bb/transformers-4.57.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f5/3a/e991574f3102147b642e49637e0281e9bb7c4ba254edb2bab78247c85e01/triton-3.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: ./
packages:
-- conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2
- sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726
- md5: d7c89558ba9fa0495403155b64376d81
- license: None
- purls: []
- size: 2562
- timestamp: 1578324546067
-- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2
- build_number: 16
- sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22
- md5: 73aaf86a425cc6e73fcf236a5a46396d
+- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda
+ build_number: 20
+ sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9
+ md5: a9f577daf3de00bca7c3c76c0ecbd1de
depends:
- - _libgcc_mutex 0.1 conda_forge
+ - __glibc >=2.17,<3.0.a0
- libgomp >=7.5.0
constrains:
- - openmp_impl 9999
+ - openmp_impl <0.0a0
license: BSD-3-Clause
license_family: BSD
purls: []
- size: 23621
- timestamp: 1650670423406
-- pypi: https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl
- name: absl-py
- version: 2.3.1
- sha256: eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/77/85/85951bc0f9843e2c10baaa1b6657227056095de08f4d1eea7d8b423a6832/accelerate-1.11.0-py3-none-any.whl
+ size: 28948
+ timestamp: 1770939786096
+- pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl
name: accelerate
- version: 1.11.0
- sha256: a628fa6beb069b8e549460fc449135d5bd8d73e7a11fd09f0bc9fc4ace7f06f1
+ version: 1.13.0
+ sha256: cf1a3efb96c18f7b152eb0fa7490f3710b19c3f395699358f08decca2b8b62e0
requires_dist:
- numpy>=1.17
- packaging>=20.0
@@ -396,20 +642,15 @@ packages:
- rich ; extra == 'dev'
- sagemaker ; extra == 'sagemaker'
requires_python: '>=3.10.0'
-- pypi: https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl
- name: aiofiles
- version: 24.1.0
- sha256: b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5
- requires_python: '>=3.8'
- pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
name: aiohappyeyeballs
version: 2.6.1
sha256: f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/14/51/d0c1701a79fcb0109cff5304da16226581569b89a282d8e7f1549a7e3ec0/aiohttp-3.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: aiohttp
- version: 3.13.0
- sha256: 2e66c57416352f36bf98f6641ddadd47c93740a22af7150d3e9a1ef6e983f9a8
+ version: 3.13.3
+ sha256: 9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc
requires_dist:
- aiohappyeyeballs>=2.5.0
- aiosignal>=1.4.0
@@ -420,9 +661,9 @@ packages:
- propcache>=0.2.0
- yarl>=1.17.0,<2.0
- aiodns>=3.3.0 ; extra == 'speedups'
- - brotli ; platform_python_implementation == 'CPython' and extra == 'speedups'
- - brotlicffi ; platform_python_implementation != 'CPython' and extra == 'speedups'
- - zstandard ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups'
+ - brotli>=1.2 ; platform_python_implementation == 'CPython' and extra == 'speedups'
+ - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups'
+ - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups'
requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
name: aiosignal
@@ -432,6 +673,11 @@ packages:
- frozenlist>=1.1.0
- typing-extensions>=4.2 ; python_full_version < '3.13'
requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl
+ name: annotated-doc
+ version: 0.0.4
+ sha256: 571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320
+ requires_python: '>=3.8'
- pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
name: annotated-types
version: 0.7.0
@@ -439,49 +685,39 @@ packages:
requires_dist:
- typing-extensions>=4.0.0 ; python_full_version < '3.9'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/76/74/5d90ad14d55fbe3f9c474fdcb6e34b4bed99e3be8efac98734a5ddce88c1/anthropic-0.49.0-py3-none-any.whl
- name: anthropic
- version: 0.49.0
- sha256: bbc17ad4e7094988d2fa86b87753ded8dce12498f4b85fe5810f208f454a8375
- requires_dist:
- - anyio>=3.5.0,<5
- - distro>=1.7.0,<2
- - httpx>=0.23.0,<1
- - jiter>=0.4.0,<1
- - pydantic>=1.9.0,<3
- - sniffio
- - typing-extensions>=4.10,<5
- - boto3>=1.28.57 ; extra == 'bedrock'
- - botocore>=1.31.57 ; extra == 'bedrock'
- - google-auth>=2,<3 ; extra == 'vertex'
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl
name: anyio
- version: 4.11.0
- sha256: 0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc
+ version: 4.12.1
+ sha256: d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c
requires_dist:
- exceptiongroup>=1.0.2 ; python_full_version < '3.11'
- idna>=2.8
- - sniffio>=1.1
- typing-extensions>=4.5 ; python_full_version < '3.13'
- - trio>=0.31.0 ; extra == 'trio'
+ - trio>=0.32.0 ; python_full_version >= '3.10' and extra == 'trio'
+ - trio>=0.31.0 ; python_full_version < '3.10' and extra == 'trio'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-h39aace5_0.conda
- sha256: a9c114cbfeda42a226e2db1809a538929d2f118ef855372293bd188f71711c48
- md5: 791365c5f65975051e4e017b5da3abf5
+- conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-hb03c661_1.conda
+ sha256: 78c516af87437f52d883193cf167378f592ad445294c69f7c69f56059087c40d
+ md5: 9bb149f49de3f322fca007283eaa2725
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
+ - libattr 2.5.2 hb03c661_1
+ - libgcc >=14
license: GPL-2.0-or-later
license_family: GPL
purls: []
- size: 68072
- timestamp: 1756738968573
-- pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl
+ size: 31386
+ timestamp: 1773595914754
+- pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
name: attrs
- version: 25.4.0
- sha256: adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373
+ version: 26.1.0
+ sha256: c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309
requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl
+ name: av
+ version: 17.0.0
+ sha256: 1060cba85f97f4a337311169d92c0b5e143452cfa5ca0e65fa499d7955e8592e
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl
name: azure-ai-documentintelligence
version: 1.0.2
@@ -491,84 +727,162 @@ packages:
- azure-core>=1.30.0
- typing-extensions>=4.6.0
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/b1/3c/b90d5afc2e47c4a45f4bba00f9c3193b0417fad5ad3bb07869f9d12832aa/azure_core-1.36.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl
name: azure-core
- version: 1.36.0
- sha256: fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b
+ version: 1.39.0
+ sha256: 4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f
requires_dist:
- requests>=2.21.0
- typing-extensions>=4.6.0
- aiohttp>=3.0 ; extra == 'aio'
- opentelemetry-api~=1.26 ; extra == 'tracing'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda
- sha256: c30daba32ddebbb7ded490f0e371eae90f51e72db620554089103b4a6934b0d5
- md5: 51a19bba1b8ebfb60df25cde030b7ebc
+- pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl
+ name: build
+ version: 1.4.0
+ sha256: 6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596
+ requires_dist:
+ - packaging>=24.0
+ - pyproject-hooks
+ - colorama ; os_name == 'nt'
+ - importlib-metadata>=4.6 ; python_full_version < '3.10.2'
+ - tomli>=1.1.0 ; python_full_version < '3.11'
+ - uv>=0.1.18 ; extra == 'uv'
+ - virtualenv>=20.11 ; python_full_version < '3.10' and extra == 'virtualenv'
+ - virtualenv>=20.17 ; python_full_version >= '3.10' and python_full_version < '3.14' and extra == 'virtualenv'
+ - virtualenv>=20.31 ; python_full_version >= '3.14' and extra == 'virtualenv'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda
+ sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6
+ md5: d2ffd7602c02f2b316fd921d39876885
depends:
- __glibc >=2.17,<3.0.a0
- libgcc >=14
license: bzip2-1.0.6
license_family: BSD
purls: []
- size: 260341
- timestamp: 1757437258798
-- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
- sha256: 3b5ad78b8bb61b6cdc0978a6a99f8dfb2cc789a451378d054698441005ecbdb6
- md5: f9e5fbc24009179e8b0409624691758a
+ size: 260182
+ timestamp: 1771350215188
+- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda
+ sha256: 67cc7101b36421c5913a1687ef1b99f85b5d6868da3abbf6ec1a4181e79782fc
+ md5: 4492fd26db29495f0ba23f146cd5638d
depends:
- __unix
license: ISC
purls: []
- size: 155907
- timestamp: 1759649036195
-- pypi: https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl
- name: cachetools
- version: 6.2.1
- sha256: 09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
+ size: 147413
+ timestamp: 1772006283803
+- pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl
name: certifi
- version: 2025.10.5
- sha256: 0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de
+ version: 2026.2.25
+ sha256: 027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl
- name: cfgv
- version: 3.4.0
- sha256: b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ name: cffi
+ version: 2.0.0
+ sha256: 3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba
+ requires_dist:
+ - pycparser ; implementation_name != 'PyPy'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: charset-normalizer
- version: 3.4.4
- sha256: 11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86
+ version: 3.4.6
+ sha256: 0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5
requires_python: '>=3.7'
- pypi: ./
- name: churro
+ name: churro-ocr
version: 0.1.0
- sha256: 6c105b44011f9a92c660bf670f13d710d9869e9e98ccb01615cebd78d4adf1f9
+ sha256: 02b705c90e40d08ccc68417e903e8054045af7a3284a6c2425e57c0d862635f8
+ requires_dist:
+ - loguru>=0.7.2,<1
+ - pillow>=10.4.0,<12
+ - rich>=13.9.2,<14
+ - typer>=0.12.3,<1
+ - google-auth>=2.41.1,<3 ; extra == 'llm'
+ - litellm[caching]==1.82.3 ; extra == 'llm'
+ - azure-ai-documentintelligence==1.0.2 ; extra == 'azure'
+ - qwen-vl-utils ; extra == 'huggingface'
+ - transformers[torch]>=4.57.0,<5 ; extra == 'huggingface'
+ - torchvision ; extra == 'huggingface'
+ - qwen-vl-utils ; extra == 'hf'
+ - transformers[torch]>=4.57.0,<5 ; extra == 'hf'
+ - torchvision ; extra == 'hf'
+ - transformers>=4.57.0,<5 ; extra == 'vllm'
+ - torchvision ; extra == 'vllm'
+ - vllm>=0.18,<1 ; extra == 'vllm'
+ - mistralai>=1.6.0,<2 ; extra == 'mistral'
+ - litellm[caching]==1.82.3 ; extra == 'local'
+ - pypdfium2>=5,<6 ; extra == 'pdf'
+ - google-auth>=2.41.1,<3 ; extra == 'all'
+ - litellm[caching]==1.82.3 ; extra == 'all'
+ - azure-ai-documentintelligence==1.0.2 ; extra == 'all'
+ - qwen-vl-utils ; extra == 'all'
+ - transformers[torch]>=4.57.0,<5 ; extra == 'all'
+ - torchvision ; extra == 'all'
+ - vllm>=0.18,<1 ; extra == 'all'
+ - mistralai>=1.6.0,<2 ; extra == 'all'
+ - pypdfium2>=5,<6 ; extra == 'all'
requires_python: '>=3.12'
- editable: true
-- pypi: https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl
name: click
- version: 8.3.0
- sha256: 9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc
+ version: 8.3.1
+ sha256: 981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6
requires_dist:
- colorama ; sys_platform == 'win32'
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl
- name: coloredlogs
- version: 15.0.1
- sha256: 612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934
- requires_dist:
- - humanfriendly>=9.1
- - capturer>=2.4 ; extra == 'cron'
- requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*'
-- pypi: https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
name: coverage
- version: 7.11.0
- sha256: 16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893
+ version: 7.13.5
+ sha256: 03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5
requires_dist:
- tomli ; python_full_version <= '3.11' and extra == 'toml'
requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl
+ name: cryptography
+ version: 46.0.5
+ sha256: 3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed
+ requires_dist:
+ - cffi>=1.14 ; python_full_version == '3.8.*' and platform_python_implementation != 'PyPy'
+ - cffi>=2.0.0 ; python_full_version >= '3.9' and platform_python_implementation != 'PyPy'
+ - typing-extensions>=4.13.2 ; python_full_version < '3.11'
+ - bcrypt>=3.1.5 ; extra == 'ssh'
+ - nox[uv]>=2024.4.15 ; extra == 'nox'
+ - cryptography-vectors==46.0.5 ; extra == 'test'
+ - pytest>=7.4.0 ; extra == 'test'
+ - pytest-benchmark>=4.0 ; extra == 'test'
+ - pytest-cov>=2.10.1 ; extra == 'test'
+ - pytest-xdist>=3.5.0 ; extra == 'test'
+ - pretend>=0.7 ; extra == 'test'
+ - certifi>=2024 ; extra == 'test'
+ - pytest-randomly ; extra == 'test-randomorder'
+ - sphinx>=5.3.0 ; extra == 'docs'
+ - sphinx-rtd-theme>=3.0.0 ; extra == 'docs'
+ - sphinx-inline-tabs ; extra == 'docs'
+ - pyenchant>=3 ; extra == 'docstest'
+ - readme-renderer>=30.0 ; extra == 'docstest'
+ - sphinxcontrib-spelling>=7.3.1 ; extra == 'docstest'
+ - build>=1.0.0 ; extra == 'sdist'
+ - ruff>=0.11.11 ; extra == 'pep8test'
+ - mypy>=1.14 ; extra == 'pep8test'
+ - check-sdist ; extra == 'pep8test'
+ - click>=8.0.1 ; extra == 'pep8test'
+ requires_python: '>=3.8,!=3.9.0,!=3.9.1'
+- pypi: https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ name: cuda-bindings
+ version: 12.9.4
+ sha256: fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8
+ requires_dist:
+ - cuda-pathfinder~=1.1
+ - nvidia-cuda-nvcc-cu12 ; extra == 'all'
+ - nvidia-cuda-nvrtc-cu12 ; extra == 'all'
+ - nvidia-nvjitlink-cu12>=12.3 ; extra == 'all'
+ - nvidia-cufile-cu12 ; sys_platform == 'linux' and extra == 'all'
+ - cython>=3.1,<3.2 ; extra == 'test'
+ - setuptools>=77.0.0 ; extra == 'test'
+ - numpy>=1.21.1 ; extra == 'test'
+ - pytest>=6.2.4 ; extra == 'test'
+ - pytest-benchmark>=3.4.1 ; extra == 'test'
+ - pyglet>=2.1.9 ; extra == 'test'
- conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-cudart-12.9.79-h5888daf_0.conda
sha256: 57d1294ecfaf9dc8cdb5fc4be3e63ebc7614538bddb5de53cfd9b1b7de43aed5
md5: cb15315d19b58bd9cd424084e58ad081
@@ -637,6 +951,11 @@ packages:
purls: []
size: 30747
timestamp: 1746192810479
+- pypi: https://files.pythonhosted.org/packages/c0/59/911a1a597264f1fb7ac176995a0f0b6062e37f8c1b6e0f23071a76838507/cuda_pathfinder-1.4.3-py3-none-any.whl
+ name: cuda-pathfinder
+ version: 1.4.3
+ sha256: 4345d8ead1f701c4fb8a99be6bc1843a7348b6ba0ef3b031f5a2d66fb128ae4c
+ requires_python: '>=3.10'
- conda: https://conda.anaconda.org/conda-forge/noarch/cuda-version-12.9-h4f385c5_3.conda
sha256: 5f5f428031933f117ff9f7fcc650e6ea1b3fef5936cf84aa24af79167513b656
md5: b6d5d7f1c171cbd228ea06b556cfa859
@@ -647,37 +966,37 @@ packages:
purls: []
size: 21578
timestamp: 1746134436166
-- conda: https://conda.anaconda.org/conda-forge/linux-64/cudnn-9.13.1.26-hbcb9cd8_0.conda
- sha256: e7fee0538f05969ab3b33ac93d892ab777c8ce1a34b1ffd207aa339b82e18b49
- md5: 7ebbea86a820fdc69ffa044b7c9729cb
+- conda: https://conda.anaconda.org/conda-forge/linux-64/cudnn-9.10.2.21-hbcb9cd8_0.conda
+ sha256: 5760ad9de2ecff210b018503168d26996497604608cf59f93df90f01ea4eb982
+ md5: c8168e26c0a9f50425ac05d6a5201c12
depends:
- __glibc >=2.28,<3.0.a0
- cuda-version >=12,<13.0a0
- - libcudnn-dev 9.13.1.26 h58dd1b1_0
+ - libcudnn-dev 9.10.2.21 h58dd1b1_0
- libgcc >=14
- libstdcxx >=14
constrains:
- cudnn-jit <0a
license: LicenseRef-cuDNN-Software-License-Agreement
purls: []
- size: 19353
- timestamp: 1759247527005
-- pypi: https://files.pythonhosted.org/packages/91/9e/0bbbd09b116fd8ee2d3617e28e6598551d2f0f24d3a2ce99cc87ec85aeb0/datasets-4.2.0-py3-none-any.whl
+ size: 19646
+ timestamp: 1762823905292
+- pypi: https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl
name: datasets
- version: 4.2.0
- sha256: fdc43aaf4a73b31f64f80f72f195ab413a1141ed15555d675b2fd17926f8b026
+ version: 4.8.3
+ sha256: a66cb506097bfce8461b076fb9e86ea216e00fd622387ba19909d9c1689ff8f9
requires_dist:
- filelock
- numpy>=1.17
- pyarrow>=21.0.0
- - dill>=0.3.0,<0.4.1
+ - dill>=0.3.0,<0.4.2
- pandas
- requests>=2.32.2
- httpx<1.0.0
- tqdm>=4.66.3
- xxhash
- - multiprocess<0.70.17
- - fsspec[http]>=2023.1.0,<=2025.9.0
+ - multiprocess<0.70.20
+ - fsspec[http]>=2023.1.0,<=2026.2.0
- huggingface-hub>=0.25.0,<2.0
- packaging
- pyyaml>=5.1
@@ -689,11 +1008,11 @@ packages:
- torch ; extra == 'torch'
- jax>=0.3.14 ; extra == 'jax'
- jaxlib>=0.3.14 ; extra == 'jax'
- - numba>=0.56.4 ; extra == 'dev'
+ - numba>=0.56.4 ; python_full_version < '3.14' and extra == 'dev'
- absl-py ; extra == 'dev'
- decorator ; extra == 'dev'
- joblib<1.3.0 ; extra == 'dev'
- - joblibspark ; extra == 'dev'
+ - joblibspark ; python_full_version < '3.14' and extra == 'dev'
- pytest ; extra == 'dev'
- pytest-datadir ; extra == 'dev'
- pytest-xdist ; extra == 'dev'
@@ -701,9 +1020,10 @@ packages:
- elasticsearch>=7.17.12,<8.0.0 ; extra == 'dev'
- faiss-cpu>=1.8.0.post1 ; extra == 'dev'
- h5py ; extra == 'dev'
+ - pylance ; extra == 'dev'
- jax>=0.3.14 ; sys_platform != 'win32' and extra == 'dev'
- jaxlib>=0.3.14 ; sys_platform != 'win32' and extra == 'dev'
- - lz4 ; extra == 'dev'
+ - lz4 ; python_full_version < '3.14' and extra == 'dev'
- moto[server] ; extra == 'dev'
- pyspark>=3.4 ; extra == 'dev'
- py7zr ; extra == 'dev'
@@ -711,7 +1031,7 @@ packages:
- sqlalchemy ; extra == 'dev'
- protobuf<4.0.0 ; extra == 'dev'
- tensorflow>=2.6.0 ; python_full_version < '3.10' and sys_platform != 'win32' and extra == 'dev'
- - tensorflow>=2.16.0 ; python_full_version >= '3.10' and sys_platform != 'win32' and extra == 'dev'
+ - tensorflow>=2.16.0 ; python_full_version >= '3.10' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'dev'
- tiktoken ; extra == 'dev'
- torch>=2.8.0 ; extra == 'dev'
- torchdata ; extra == 'dev'
@@ -719,16 +1039,17 @@ packages:
- zstandard ; extra == 'dev'
- polars[timezone]>=0.20.0 ; extra == 'dev'
- pillow>=9.4.0 ; extra == 'dev'
- - torchcodec>=0.7.0 ; extra == 'dev'
+ - torchcodec>=0.7.0 ; python_full_version < '3.14' and extra == 'dev'
+ - nibabel>=5.3.1 ; extra == 'dev'
- ruff>=0.3.0 ; extra == 'dev'
- transformers ; extra == 'dev'
- torch ; extra == 'dev'
- tensorflow>=2.6.0 ; extra == 'dev'
- - numba>=0.56.4 ; extra == 'tests'
+ - numba>=0.56.4 ; python_full_version < '3.14' and extra == 'tests'
- absl-py ; extra == 'tests'
- decorator ; extra == 'tests'
- joblib<1.3.0 ; extra == 'tests'
- - joblibspark ; extra == 'tests'
+ - joblibspark ; python_full_version < '3.14' and extra == 'tests'
- pytest ; extra == 'tests'
- pytest-datadir ; extra == 'tests'
- pytest-xdist ; extra == 'tests'
@@ -736,9 +1057,10 @@ packages:
- elasticsearch>=7.17.12,<8.0.0 ; extra == 'tests'
- faiss-cpu>=1.8.0.post1 ; extra == 'tests'
- h5py ; extra == 'tests'
+ - pylance ; extra == 'tests'
- jax>=0.3.14 ; sys_platform != 'win32' and extra == 'tests'
- jaxlib>=0.3.14 ; sys_platform != 'win32' and extra == 'tests'
- - lz4 ; extra == 'tests'
+ - lz4 ; python_full_version < '3.14' and extra == 'tests'
- moto[server] ; extra == 'tests'
- pyspark>=3.4 ; extra == 'tests'
- py7zr ; extra == 'tests'
@@ -746,7 +1068,7 @@ packages:
- sqlalchemy ; extra == 'tests'
- protobuf<4.0.0 ; extra == 'tests'
- tensorflow>=2.6.0 ; python_full_version < '3.10' and sys_platform != 'win32' and extra == 'tests'
- - tensorflow>=2.16.0 ; python_full_version >= '3.10' and sys_platform != 'win32' and extra == 'tests'
+ - tensorflow>=2.16.0 ; python_full_version >= '3.10' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'tests'
- tiktoken ; extra == 'tests'
- torch>=2.8.0 ; extra == 'tests'
- torchdata ; extra == 'tests'
@@ -754,21 +1076,23 @@ packages:
- zstandard ; extra == 'tests'
- polars[timezone]>=0.20.0 ; extra == 'tests'
- pillow>=9.4.0 ; extra == 'tests'
- - torchcodec>=0.7.0 ; extra == 'tests'
- - numba>=0.56.4 ; extra == 'tests-numpy2'
+ - torchcodec>=0.7.0 ; python_full_version < '3.14' and extra == 'tests'
+ - nibabel>=5.3.1 ; extra == 'tests'
+ - numba>=0.56.4 ; python_full_version < '3.14' and extra == 'tests-numpy2'
- absl-py ; extra == 'tests-numpy2'
- decorator ; extra == 'tests-numpy2'
- joblib<1.3.0 ; extra == 'tests-numpy2'
- - joblibspark ; extra == 'tests-numpy2'
+ - joblibspark ; python_full_version < '3.14' and extra == 'tests-numpy2'
- pytest ; extra == 'tests-numpy2'
- pytest-datadir ; extra == 'tests-numpy2'
- pytest-xdist ; extra == 'tests-numpy2'
- aiohttp ; extra == 'tests-numpy2'
- elasticsearch>=7.17.12,<8.0.0 ; extra == 'tests-numpy2'
- h5py ; extra == 'tests-numpy2'
+ - pylance ; extra == 'tests-numpy2'
- jax>=0.3.14 ; sys_platform != 'win32' and extra == 'tests-numpy2'
- jaxlib>=0.3.14 ; sys_platform != 'win32' and extra == 'tests-numpy2'
- - lz4 ; extra == 'tests-numpy2'
+ - lz4 ; python_full_version < '3.14' and extra == 'tests-numpy2'
- moto[server] ; extra == 'tests-numpy2'
- pyspark>=3.4 ; extra == 'tests-numpy2'
- py7zr ; extra == 'tests-numpy2'
@@ -782,7 +1106,8 @@ packages:
- zstandard ; extra == 'tests-numpy2'
- polars[timezone]>=0.20.0 ; extra == 'tests-numpy2'
- pillow>=9.4.0 ; extra == 'tests-numpy2'
- - torchcodec>=0.7.0 ; extra == 'tests-numpy2'
+ - torchcodec>=0.7.0 ; python_full_version < '3.14' and extra == 'tests-numpy2'
+ - nibabel>=5.3.1 ; extra == 'tests-numpy2'
- ruff>=0.3.0 ; extra == 'quality'
- tensorflow==2.12.0 ; extra == 'benchmarks'
- torch==2.0.1 ; extra == 'benchmarks'
@@ -791,83 +1116,39 @@ packages:
- torch ; extra == 'docs'
- tensorflow>=2.6.0 ; extra == 'docs'
- pdfplumber>=0.11.4 ; extra == 'pdfs'
- requires_python: '>=3.9.0'
-- pypi: https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl
+ - nibabel>=5.3.2 ; extra == 'nibabel'
+ - ipyniivue==2.4.2 ; extra == 'nibabel'
+ requires_python: '>=3.10.0'
+- pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl
name: dill
- version: 0.4.0
- sha256: 44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049
+ version: 0.4.1
+ sha256: 1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d
requires_dist:
- objgraph>=1.7.2 ; extra == 'graph'
- gprof2dot>=2022.7.29 ; extra == 'profile'
- requires_python: '>=3.8'
+ requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl
name: diskcache
version: 5.6.3
sha256: 5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19
requires_python: '>=3'
-- pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl
- name: distlib
- version: 0.4.0
- sha256: 9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16
- pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl
name: distro
version: 1.9.0
sha256: 7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
requires_python: '>=3.6'
-- pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
- name: docker
- version: 7.1.0
- sha256: c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0
- requires_dist:
- - pywin32>=304 ; sys_platform == 'win32'
- - requests>=2.26.0
- - urllib3>=1.26.0
- - coverage==7.2.7 ; extra == 'dev'
- - pytest-cov==4.1.0 ; extra == 'dev'
- - pytest-timeout==2.1.0 ; extra == 'dev'
- - pytest==7.4.2 ; extra == 'dev'
- - ruff==0.1.8 ; extra == 'dev'
- - myst-parser==0.18.0 ; extra == 'docs'
- - sphinx==5.1.1 ; extra == 'docs'
- - paramiko>=2.4.3 ; extra == 'ssh'
- - websocket-client>=1.3.0 ; extra == 'websockets'
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl
- name: docstring-parser
- version: 0.17.0
- sha256: cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708
- requires_dist:
- - pre-commit>=2.16.0 ; python_full_version >= '3.9' and extra == 'dev'
- - pydoctor>=25.4.0 ; extra == 'dev'
- - pytest ; extra == 'dev'
- - pydoctor>=25.4.0 ; extra == 'docs'
- - pytest ; extra == 'test'
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/a9/b6/f85666707b9f9ff94ada851cbaa6f1c91a0f5802aa5e498772251e1e7772/elementpath-5.0.4-py3-none-any.whl
- name: elementpath
- version: 5.0.4
- sha256: 75d6f31c614d57e50eb749fc50806e3102880cd1f6552da3f2265f8eb8d3bbc6
- requires_dist:
- - coverage ; extra == 'dev'
- - flake8 ; extra == 'dev'
- - lxml ; extra == 'dev'
- - lxml-stubs ; extra == 'dev'
- - memray ; extra == 'dev'
- - mypy ; extra == 'dev'
- - psutil ; extra == 'dev'
- - sphinx ; extra == 'dev'
- - tox ; extra == 'dev'
- - xmlschema>=4.0.1 ; extra == 'dev'
- - sphinx ; extra == 'docs'
- - readthedocs-sphinx-search ; extra == 'docs'
+- pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl
+ name: docutils
+ version: 0.22.4
+ sha256: d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl
name: eval-type-backport
- version: 0.2.2
- sha256: cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a
+ version: 0.3.1
+ sha256: 279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8
requires_dist:
- pytest ; extra == 'tests'
- requires_python: '>=3.8'
+ requires_python: '>=3.7'
- pypi: https://files.pythonhosted.org/packages/3e/af/3e990d8d4002bbc9342adb4facd59506e653da93b2417de0fa6027cb86b1/evaluate-0.4.6-py3-none-any.whl
name: evaluate
version: 0.4.6
@@ -962,136 +1243,25 @@ packages:
- six~=1.15.0 ; extra == 'tests'
- torch ; extra == 'torch'
requires_python: '>=3.8.0'
-- pypi: https://files.pythonhosted.org/packages/6a/2b/1b89e90a8635e5587ccdbbeb169c590672ce7637880f2c047482a0359950/fastuuid-0.13.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: fastuuid
- version: 0.13.5
- sha256: f10c77b826738c1a27dcdaa92ea4dc1ec9d869748a99e1fde54f1379553d4854
+ version: 0.14.0
+ sha256: 808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl
name: filelock
- version: 3.20.0
- sha256: 339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2
+ version: 3.25.2
+ sha256: ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/ee/1b/00a78aa2e8fbd63f9af08c9c19e6deb3d5d66b4dda677a0f61654680ee89/flatbuffers-25.9.23-py2.py3-none-any.whl
- name: flatbuffers
- version: 25.9.23
- sha256: 255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2
- pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
name: frozenlist
version: 1.8.0
sha256: 494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl
- name: fsspec
- version: 2025.9.0
- sha256: 530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7
- requires_dist:
- - adlfs ; extra == 'abfs'
- - adlfs ; extra == 'adl'
- - pyarrow>=1 ; extra == 'arrow'
- - dask ; extra == 'dask'
- - distributed ; extra == 'dask'
- - pre-commit ; extra == 'dev'
- - ruff>=0.5 ; extra == 'dev'
- - numpydoc ; extra == 'doc'
- - sphinx ; extra == 'doc'
- - sphinx-design ; extra == 'doc'
- - sphinx-rtd-theme ; extra == 'doc'
- - yarl ; extra == 'doc'
- - dropbox ; extra == 'dropbox'
- - dropboxdrivefs ; extra == 'dropbox'
- - requests ; extra == 'dropbox'
- - adlfs ; extra == 'full'
- - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'full'
- - dask ; extra == 'full'
- - distributed ; extra == 'full'
- - dropbox ; extra == 'full'
- - dropboxdrivefs ; extra == 'full'
- - fusepy ; extra == 'full'
- - gcsfs ; extra == 'full'
- - libarchive-c ; extra == 'full'
- - ocifs ; extra == 'full'
- - panel ; extra == 'full'
- - paramiko ; extra == 'full'
- - pyarrow>=1 ; extra == 'full'
- - pygit2 ; extra == 'full'
- - requests ; extra == 'full'
- - s3fs ; extra == 'full'
- - smbprotocol ; extra == 'full'
- - tqdm ; extra == 'full'
- - fusepy ; extra == 'fuse'
- - gcsfs ; extra == 'gcs'
- - pygit2 ; extra == 'git'
- - requests ; extra == 'github'
- - gcsfs ; extra == 'gs'
- - panel ; extra == 'gui'
- - pyarrow>=1 ; extra == 'hdfs'
- - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'http'
- - libarchive-c ; extra == 'libarchive'
- - ocifs ; extra == 'oci'
- - s3fs ; extra == 's3'
- - paramiko ; extra == 'sftp'
- - smbprotocol ; extra == 'smb'
- - paramiko ; extra == 'ssh'
- - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test'
- - numpy ; extra == 'test'
- - pytest ; extra == 'test'
- - pytest-asyncio!=0.22.0 ; extra == 'test'
- - pytest-benchmark ; extra == 'test'
- - pytest-cov ; extra == 'test'
- - pytest-mock ; extra == 'test'
- - pytest-recording ; extra == 'test'
- - pytest-rerunfailures ; extra == 'test'
- - requests ; extra == 'test'
- - aiobotocore>=2.5.4,<3.0.0 ; extra == 'test-downstream'
- - dask[dataframe,test] ; extra == 'test-downstream'
- - moto[server]>4,<5 ; extra == 'test-downstream'
- - pytest-timeout ; extra == 'test-downstream'
- - xarray ; extra == 'test-downstream'
- - adlfs ; extra == 'test-full'
- - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test-full'
- - cloudpickle ; extra == 'test-full'
- - dask ; extra == 'test-full'
- - distributed ; extra == 'test-full'
- - dropbox ; extra == 'test-full'
- - dropboxdrivefs ; extra == 'test-full'
- - fastparquet ; extra == 'test-full'
- - fusepy ; extra == 'test-full'
- - gcsfs ; extra == 'test-full'
- - jinja2 ; extra == 'test-full'
- - kerchunk ; extra == 'test-full'
- - libarchive-c ; extra == 'test-full'
- - lz4 ; extra == 'test-full'
- - notebook ; extra == 'test-full'
- - numpy ; extra == 'test-full'
- - ocifs ; extra == 'test-full'
- - pandas ; extra == 'test-full'
- - panel ; extra == 'test-full'
- - paramiko ; extra == 'test-full'
- - pyarrow ; extra == 'test-full'
- - pyarrow>=1 ; extra == 'test-full'
- - pyftpdlib ; extra == 'test-full'
- - pygit2 ; extra == 'test-full'
- - pytest ; extra == 'test-full'
- - pytest-asyncio!=0.22.0 ; extra == 'test-full'
- - pytest-benchmark ; extra == 'test-full'
- - pytest-cov ; extra == 'test-full'
- - pytest-mock ; extra == 'test-full'
- - pytest-recording ; extra == 'test-full'
- - pytest-rerunfailures ; extra == 'test-full'
- - python-snappy ; extra == 'test-full'
- - requests ; extra == 'test-full'
- - smbprotocol ; extra == 'test-full'
- - tqdm ; extra == 'test-full'
- - urllib3 ; extra == 'test-full'
- - zarr ; extra == 'test-full'
- - zstandard ; python_full_version < '3.14' and extra == 'test-full'
- - tqdm ; extra == 'tqdm'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl
name: fsspec
- version: 2025.10.0
- sha256: 7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d
+ version: 2026.2.0
+ sha256: 98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437
requires_dist:
- adlfs ; extra == 'abfs'
- adlfs ; extra == 'adl'
@@ -1115,7 +1285,7 @@ packages:
- dropbox ; extra == 'full'
- dropboxdrivefs ; extra == 'full'
- fusepy ; extra == 'full'
- - gcsfs ; extra == 'full'
+ - gcsfs>2024.2.0 ; extra == 'full'
- libarchive-c ; extra == 'full'
- ocifs ; extra == 'full'
- panel ; extra == 'full'
@@ -1123,11 +1293,11 @@ packages:
- pyarrow>=1 ; extra == 'full'
- pygit2 ; extra == 'full'
- requests ; extra == 'full'
- - s3fs ; extra == 'full'
+ - s3fs>2024.2.0 ; extra == 'full'
- smbprotocol ; extra == 'full'
- tqdm ; extra == 'full'
- fusepy ; extra == 'fuse'
- - gcsfs ; extra == 'gcs'
+ - gcsfs>2024.2.0 ; extra == 'gcs'
- pygit2 ; extra == 'git'
- requests ; extra == 'github'
- gcsfs ; extra == 'gs'
@@ -1136,7 +1306,7 @@ packages:
- aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'http'
- libarchive-c ; extra == 'libarchive'
- ocifs ; extra == 'oci'
- - s3fs ; extra == 's3'
+ - s3fs>2024.2.0 ; extra == 's3'
- paramiko ; extra == 'sftp'
- smbprotocol ; extra == 'smb'
- paramiko ; extra == 'ssh'
@@ -1157,6 +1327,7 @@ packages:
- xarray ; extra == 'test-downstream'
- adlfs ; extra == 'test-full'
- aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test-full'
+ - backports-zstd ; python_full_version < '3.14' and extra == 'test-full'
- cloudpickle ; extra == 'test-full'
- dask ; extra == 'test-full'
- distributed ; extra == 'test-full'
@@ -1172,7 +1343,7 @@ packages:
- notebook ; extra == 'test-full'
- numpy ; extra == 'test-full'
- ocifs ; extra == 'test-full'
- - pandas ; extra == 'test-full'
+ - pandas<3.0.0 ; extra == 'test-full'
- panel ; extra == 'test-full'
- paramiko ; extra == 'test-full'
- pyarrow ; extra == 'test-full'
@@ -1194,73 +1365,30 @@ packages:
- zarr ; extra == 'test-full'
- zstandard ; python_full_version < '3.14' and extra == 'test-full'
- tqdm ; extra == 'tqdm'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/77/ad/f73cf9fe9bd95918502b270e3ddb8764e4c900b3bbd7782b90c56fac14bb/google_api_core-2.26.0-py3-none-any.whl
- name: google-api-core
- version: 2.26.0
- sha256: 2b204bd0da2c81f918e3582c48458e24c11771f987f6258e6e227212af78f3ed
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl
+ name: google-auth
+ version: 2.49.1
+ sha256: 195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7
requires_dist:
- - googleapis-common-protos>=1.56.2,<2.0.0
- - protobuf>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- - proto-plus>=1.22.3,<2.0.0
- - proto-plus>=1.25.0,<2.0.0 ; python_full_version >= '3.13'
- - google-auth>=2.14.1,<3.0.0
- - requests>=2.18.0,<3.0.0
- - google-auth[aiohttp]>=2.35.0,<3.0.0 ; extra == 'async-rest'
- - grpcio>=1.33.2,<2.0.0 ; extra == 'grpc'
- - grpcio>=1.49.1,<2.0.0 ; python_full_version >= '3.11' and extra == 'grpc'
- - grpcio>=1.75.1,<2.0.0 ; python_full_version >= '3.14' and extra == 'grpc'
- - grpcio-status>=1.33.2,<2.0.0 ; extra == 'grpc'
- - grpcio-status>=1.49.1,<2.0.0 ; python_full_version >= '3.11' and extra == 'grpc'
- - grpcio-status>=1.75.1,<2.0.0 ; python_full_version >= '3.14' and extra == 'grpc'
- - grpcio-gcp>=0.2.2,<1.0.0 ; extra == 'grpcgcp'
- - grpcio-gcp>=0.2.2,<1.0.0 ; extra == 'grpcio-gcp'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/f4/38/d25ae1565103a545cf18207a5dec09a6d39ad88e5b0399a2430e9edb0550/google_api_python_client-2.184.0-py3-none-any.whl
- name: google-api-python-client
- version: 2.184.0
- sha256: 15a18d02f42de99416921c77be235d12ead474e474a1abc348b01a2b92633fa4
- requires_dist:
- - httplib2>=0.19.0,<1.0.0
- - google-auth>=1.32.0,!=2.24.0,!=2.25.0,<3.0.0
- - google-auth-httplib2>=0.2.0,<1.0.0
- - google-api-core>=1.31.5,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0
- - uritemplate>=3.0.1,<5
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl
- name: google-auth
- version: 2.41.1
- sha256: 754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d
- requires_dist:
- - cachetools>=2.0.0,<7.0
- pyasn1-modules>=0.2.1
- - rsa>=3.1.4,<5
+ - cryptography>=38.0.3
+ - cryptography>=38.0.3 ; extra == 'cryptography'
- aiohttp>=3.6.2,<4.0.0 ; extra == 'aiohttp'
- requests>=2.20.0,<3.0.0 ; extra == 'aiohttp'
- - cryptography ; extra == 'enterprise-cert'
- pyopenssl ; extra == 'enterprise-cert'
- pyopenssl>=20.0.0 ; extra == 'pyopenssl'
- - cryptography>=38.0.3 ; extra == 'pyopenssl'
- - cryptography<39.0.0 ; python_full_version < '3.8' and extra == 'pyopenssl'
- pyjwt>=2.0 ; extra == 'pyjwt'
- - cryptography>=38.0.3 ; extra == 'pyjwt'
- - cryptography<39.0.0 ; python_full_version < '3.8' and extra == 'pyjwt'
- pyu2f>=0.1.5 ; extra == 'reauth'
- requests>=2.20.0,<3.0.0 ; extra == 'requests'
- grpcio ; extra == 'testing'
- flask ; extra == 'testing'
- freezegun ; extra == 'testing'
- - mock ; extra == 'testing'
- - oauth2client ; extra == 'testing'
- pyjwt>=2.0 ; extra == 'testing'
- - cryptography>=38.0.3 ; extra == 'testing'
- - cryptography<39.0.0 ; python_full_version < '3.8' and extra == 'testing'
- pytest ; extra == 'testing'
- pytest-cov ; extra == 'testing'
- pytest-localserver ; extra == 'testing'
- pyopenssl>=20.0.0 ; extra == 'testing'
- - cryptography>=38.0.3 ; extra == 'testing'
- - cryptography<39.0.0 ; python_full_version < '3.8' and extra == 'testing'
- pyu2f>=0.1.5 ; extra == 'testing'
- responses ; extra == 'testing'
- urllib3 ; extra == 'testing'
@@ -1273,413 +1401,25 @@ packages:
- aiohttp<3.10.0 ; extra == 'testing'
- urllib3 ; extra == 'urllib3'
- packaging ; extra == 'urllib3'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl
- name: google-auth-httplib2
- version: 0.2.0
- sha256: b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d
- requires_dist:
- - google-auth
- - httplib2>=0.19.0
-- pypi: https://files.pythonhosted.org/packages/bd/f6/806b39f86f912133a3071ffa9ff99801a12868216069e26c83a48943116b/google_cloud_aiplatform-1.121.0-py2.py3-none-any.whl
- name: google-cloud-aiplatform
- version: 1.121.0
- sha256: 1e7105dfd17963207e966550c9544264508efdfded29cf4924c5b86ff4a22efd
- requires_dist:
- - google-api-core[grpc]>=1.34.1,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,<3.0.0
- - google-auth>=2.14.1,<3.0.0
- - proto-plus>=1.22.3,<2.0.0
- - protobuf>=3.20.2,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- - packaging>=14.3
- - google-cloud-storage>=1.32.0,<3.0.0
- - google-cloud-bigquery>=1.15.0,!=3.20.0,<4.0.0
- - google-cloud-resource-manager>=1.3.3,<3.0.0
- - shapely<3.0.0
- - google-genai>=1.37.0,<2.0.0
- - pydantic<3
- - typing-extensions
- - docstring-parser<1
- - requests>=2.28.1 ; extra == 'endpoint'
- - requests-toolbelt<=1.0.0 ; extra == 'endpoint'
- - ray[default]>=2.5,<=2.47.1 ; python_full_version == '3.11.*' and extra == 'full'
- - jsonschema ; extra == 'full'
- - explainable-ai-sdk>=1.0.0 ; extra == 'full'
- - ruamel-yaml ; extra == 'full'
- - httpx>=0.23.0,<=0.28.1 ; extra == 'full'
- - pyyaml ; extra == 'full'
- - scikit-learn<1.6.0 ; python_full_version < '3.11' and extra == 'full'
- - pyarrow>=10.0.1 ; python_full_version == '3.11.*' and extra == 'full'
- - docker>=5.0.3 ; extra == 'full'
- - google-vizier>=0.1.6 ; extra == 'full'
- - pyarrow>=6.0.1 ; extra == 'full'
- - google-cloud-bigquery-storage ; extra == 'full'
- - google-cloud-bigquery ; extra == 'full'
- - uvicorn[standard]>=0.16.0 ; extra == 'full'
- - pyyaml>=5.3.1,<7 ; extra == 'full'
- - starlette>=0.17.1 ; extra == 'full'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'full'
- - requests>=2.28.1 ; extra == 'full'
- - scikit-learn ; python_full_version >= '3.11' and extra == 'full'
- - litellm>=1.72.4,<=1.76.3 ; extra == 'full'
- - werkzeug>=2.0.0,<4.0.0 ; extra == 'full'
- - fastapi>=0.71.0,<=0.114.0 ; extra == 'full'
- - ray[default]>=2.4,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.0,!=2.9.1,!=2.9.2,!=2.10.*,!=2.11.*,!=2.12.*,!=2.13.*,!=2.14.*,!=2.15.*,!=2.16.*,!=2.17.*,!=2.18.*,!=2.19.*,!=2.20.*,!=2.21.*,!=2.22.*,!=2.23.*,!=2.24.*,!=2.25.*,!=2.26.*,!=2.27.*,!=2.28.*,!=2.29.*,!=2.30.*,!=2.31.*,!=2.32.*,!=2.34.*,!=2.35.*,!=2.36.*,!=2.37.*,!=2.38.*,!=2.39.*,!=2.40.*,!=2.41.*,<=2.42.0 ; python_full_version < '3.11' and extra == 'full'
- - pyarrow>=3.0.0,<8.0.0 ; python_full_version < '3.11' and extra == 'full'
- - numpy>=1.15.0 ; extra == 'full'
- - tensorboard-plugin-profile>=2.4.0,<2.18.0 ; extra == 'full'
- - pyarrow>=14.0.0 ; python_full_version >= '3.12' and extra == 'full'
- - immutabledict ; extra == 'full'
- - urllib3>=1.21.1,<1.27 ; extra == 'full'
- - tqdm>=4.23.0 ; extra == 'full'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'full'
- - mlflow>=1.27.0,<=2.16.0 ; extra == 'full'
- - pandas>=1.0.0 ; extra == 'full'
- - lit-nlp==0.4.0 ; extra == 'full'
- - requests-toolbelt<=1.0.0 ; extra == 'full'
- - pandas>=1.0.0 ; extra == 'metadata'
- - numpy>=1.15.0 ; extra == 'metadata'
- - tensorboard-plugin-profile>=2.4.0,<2.18.0 ; extra == 'tensorboard'
- - werkzeug>=2.0.0,<4.0.0 ; extra == 'tensorboard'
- - ray[default]>=2.5,<=2.47.1 ; python_full_version == '3.11.*' and extra == 'testing'
- - jsonschema ; extra == 'testing'
- - explainable-ai-sdk>=1.0.0 ; extra == 'testing'
- - ruamel-yaml ; extra == 'testing'
- - httpx>=0.23.0,<=0.28.1 ; extra == 'testing'
- - pyyaml ; extra == 'testing'
- - scikit-learn<1.6.0 ; python_full_version < '3.11' and extra == 'testing'
- - pyarrow>=10.0.1 ; python_full_version == '3.11.*' and extra == 'testing'
- - docker>=5.0.3 ; extra == 'testing'
- - google-vizier>=0.1.6 ; extra == 'testing'
- - pyarrow>=6.0.1 ; extra == 'testing'
- - google-cloud-bigquery-storage ; extra == 'testing'
- - google-cloud-bigquery ; extra == 'testing'
- - uvicorn[standard]>=0.16.0 ; extra == 'testing'
- - pyyaml>=5.3.1,<7 ; extra == 'testing'
- - starlette>=0.17.1 ; extra == 'testing'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'testing'
- - requests>=2.28.1 ; extra == 'testing'
- - scikit-learn ; python_full_version >= '3.11' and extra == 'testing'
- - litellm>=1.72.4,<=1.76.3 ; extra == 'testing'
- - werkzeug>=2.0.0,<4.0.0 ; extra == 'testing'
- - fastapi>=0.71.0,<=0.114.0 ; extra == 'testing'
- - ray[default]>=2.4,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.0,!=2.9.1,!=2.9.2,!=2.10.*,!=2.11.*,!=2.12.*,!=2.13.*,!=2.14.*,!=2.15.*,!=2.16.*,!=2.17.*,!=2.18.*,!=2.19.*,!=2.20.*,!=2.21.*,!=2.22.*,!=2.23.*,!=2.24.*,!=2.25.*,!=2.26.*,!=2.27.*,!=2.28.*,!=2.29.*,!=2.30.*,!=2.31.*,!=2.32.*,!=2.34.*,!=2.35.*,!=2.36.*,!=2.37.*,!=2.38.*,!=2.39.*,!=2.40.*,!=2.41.*,<=2.42.0 ; python_full_version < '3.11' and extra == 'testing'
- - pyarrow>=3.0.0,<8.0.0 ; python_full_version < '3.11' and extra == 'testing'
- - numpy>=1.15.0 ; extra == 'testing'
- - tensorboard-plugin-profile>=2.4.0,<2.18.0 ; extra == 'testing'
- - pyarrow>=14.0.0 ; python_full_version >= '3.12' and extra == 'testing'
- - immutabledict ; extra == 'testing'
- - urllib3>=1.21.1,<1.27 ; extra == 'testing'
- - tqdm>=4.23.0 ; extra == 'testing'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'testing'
- - mlflow>=1.27.0,<=2.16.0 ; extra == 'testing'
- - pandas>=1.0.0 ; extra == 'testing'
- - lit-nlp==0.4.0 ; extra == 'testing'
- - requests-toolbelt<=1.0.0 ; extra == 'testing'
- - tensorboard-plugin-profile>=2.4.0,<2.18.0 ; extra == 'testing'
- - werkzeug>=2.0.0,<4.0.0 ; extra == 'testing'
- - sentencepiece>=0.2.0 ; extra == 'testing'
- - nltk ; extra == 'testing'
- - google-vizier>=0.1.6 ; extra == 'testing'
- - aiohttp ; extra == 'testing'
- - bigframes ; python_full_version >= '3.10' and extra == 'testing'
- - google-api-core>=2.11,<3.0.0 ; extra == 'testing'
- - grpcio-testing ; extra == 'testing'
- - ipython ; extra == 'testing'
- - kfp>=2.6.0,<3.0.0 ; extra == 'testing'
- - pytest-asyncio ; extra == 'testing'
- - pytest-xdist ; extra == 'testing'
- - scikit-learn<1.6.0 ; python_full_version < '3.11' and extra == 'testing'
- - scikit-learn ; python_full_version >= '3.11' and extra == 'testing'
- - tensorflow==2.14.1 ; python_full_version < '3.12' and extra == 'testing'
- - tensorflow==2.19.0 ; python_full_version >= '3.12' and extra == 'testing'
- - protobuf<=5.29.4 ; extra == 'testing'
- - torch>=2.0.0,<2.1.0 ; python_full_version < '3.12' and extra == 'testing'
- - torch>=2.2.0 ; python_full_version >= '3.12' and extra == 'testing'
- - requests-toolbelt<=1.0.0 ; extra == 'testing'
- - immutabledict ; extra == 'testing'
- - xgboost ; extra == 'testing'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'xai'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'lit'
- - pandas>=1.0.0 ; extra == 'lit'
- - lit-nlp==0.4.0 ; extra == 'lit'
- - explainable-ai-sdk>=1.0.0 ; extra == 'lit'
- - tensorboard-plugin-profile>=2.4.0,<2.18.0 ; extra == 'cloud-profiler'
- - werkzeug>=2.0.0,<4.0.0 ; extra == 'cloud-profiler'
- - pyyaml>=5.3.1,<7 ; extra == 'pipelines'
- - google-vizier>=0.1.6 ; extra == 'vizier'
- - docker>=5.0.3 ; extra == 'prediction'
- - fastapi>=0.71.0,<=0.114.0 ; extra == 'prediction'
- - httpx>=0.23.0,<=0.28.1 ; extra == 'prediction'
- - starlette>=0.17.1 ; extra == 'prediction'
- - uvicorn[standard]>=0.16.0 ; extra == 'prediction'
- - pyarrow>=3.0.0,<8.0.0 ; python_full_version < '3.11' and extra == 'datasets'
- - pyarrow>=10.0.1 ; python_full_version == '3.11.*' and extra == 'datasets'
- - pyarrow>=14.0.0 ; python_full_version >= '3.12' and extra == 'datasets'
- - urllib3>=1.21.1,<1.27 ; extra == 'private-endpoints'
- - requests>=2.28.1 ; extra == 'private-endpoints'
- - mlflow>=1.27.0,<=2.16.0 ; extra == 'autologging'
- - ray[default]>=2.4,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.0,!=2.9.1,!=2.9.2,!=2.10.*,!=2.11.*,!=2.12.*,!=2.13.*,!=2.14.*,!=2.15.*,!=2.16.*,!=2.17.*,!=2.18.*,!=2.19.*,!=2.20.*,!=2.21.*,!=2.22.*,!=2.23.*,!=2.24.*,!=2.25.*,!=2.26.*,!=2.27.*,!=2.28.*,!=2.29.*,!=2.30.*,!=2.31.*,!=2.32.*,!=2.34.*,!=2.35.*,!=2.36.*,!=2.37.*,!=2.38.*,!=2.39.*,!=2.40.*,!=2.41.*,<=2.42.0 ; python_full_version < '3.11' and extra == 'ray'
- - ray[default]>=2.5,<=2.47.1 ; python_full_version == '3.11.*' and extra == 'ray'
- - google-cloud-bigquery-storage ; extra == 'ray'
- - google-cloud-bigquery ; extra == 'ray'
- - pandas>=1.0.0 ; extra == 'ray'
- - pyarrow>=6.0.1 ; extra == 'ray'
- - immutabledict ; extra == 'ray'
- - ray[default]>=2.4,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.0,!=2.9.1,!=2.9.2,!=2.10.*,!=2.11.*,!=2.12.*,!=2.13.*,!=2.14.*,!=2.15.*,!=2.16.*,!=2.17.*,!=2.18.*,!=2.19.*,!=2.20.*,!=2.21.*,!=2.22.*,!=2.23.*,!=2.24.*,!=2.25.*,!=2.26.*,!=2.27.*,!=2.28.*,!=2.29.*,!=2.30.*,!=2.31.*,!=2.32.*,!=2.34.*,!=2.35.*,!=2.36.*,!=2.37.*,!=2.38.*,!=2.39.*,!=2.40.*,!=2.41.*,<=2.42.0 ; python_full_version < '3.11' and extra == 'ray-testing'
- - ray[default]>=2.5,<=2.47.1 ; python_full_version == '3.11.*' and extra == 'ray-testing'
- - google-cloud-bigquery-storage ; extra == 'ray-testing'
- - google-cloud-bigquery ; extra == 'ray-testing'
- - pandas>=1.0.0 ; extra == 'ray-testing'
- - pyarrow>=6.0.1 ; extra == 'ray-testing'
- - immutabledict ; extra == 'ray-testing'
- - pytest-xdist ; extra == 'ray-testing'
- - ray[train] ; extra == 'ray-testing'
- - scikit-learn<1.6.0 ; extra == 'ray-testing'
- - tensorflow ; extra == 'ray-testing'
- - torch>=2.0.0,<2.1.0 ; extra == 'ray-testing'
- - xgboost ; extra == 'ray-testing'
- - xgboost-ray ; extra == 'ray-testing'
- - google-adk>=1.0.0,<2.0.0 ; extra == 'adk'
- - opentelemetry-instrumentation-google-genai>=0.3b0,<1.0.0 ; extra == 'adk'
- - cloudpickle>=3.0,<4.0 ; extra == 'reasoningengine'
- - google-cloud-trace<2 ; extra == 'reasoningengine'
- - opentelemetry-sdk<2 ; extra == 'reasoningengine'
- - opentelemetry-exporter-gcp-trace<2 ; extra == 'reasoningengine'
- - pydantic>=2.11.1,<3 ; extra == 'reasoningengine'
- - typing-extensions ; extra == 'reasoningengine'
- - packaging>=24.0 ; extra == 'agent-engines'
- - cloudpickle>=3.0,<4.0 ; extra == 'agent-engines'
- - google-cloud-trace<2 ; extra == 'agent-engines'
- - google-cloud-logging<4 ; extra == 'agent-engines'
- - opentelemetry-sdk<2 ; extra == 'agent-engines'
- - opentelemetry-exporter-gcp-trace<2 ; extra == 'agent-engines'
- - pydantic>=2.11.1,<3 ; extra == 'agent-engines'
- - typing-extensions ; extra == 'agent-engines'
- - pandas>=1.0.0 ; extra == 'evaluation'
- - tqdm>=4.23.0 ; extra == 'evaluation'
- - scikit-learn<1.6.0 ; python_full_version < '3.11' and extra == 'evaluation'
- - scikit-learn ; python_full_version >= '3.11' and extra == 'evaluation'
- - jsonschema ; extra == 'evaluation'
- - ruamel-yaml ; extra == 'evaluation'
- - pyyaml ; extra == 'evaluation'
- - litellm>=1.72.4,<=1.76.3 ; extra == 'evaluation'
- - langchain>=0.3,<0.4 ; extra == 'langchain'
- - langchain-core>=0.3,<0.4 ; extra == 'langchain'
- - langchain-google-vertexai>=2.0.22,<3 ; extra == 'langchain'
- - langgraph>=0.2.45,<0.4 ; extra == 'langchain'
- - openinference-instrumentation-langchain>=0.1.19,<0.2 ; extra == 'langchain'
- - cloudpickle>=3.0,<4.0 ; extra == 'langchain-testing'
- - langgraph>=0.2.45,<0.4 ; extra == 'langchain-testing'
- - langchain>=0.3,<0.4 ; extra == 'langchain-testing'
- - langchain-core>=0.3,<0.4 ; extra == 'langchain-testing'
- - opentelemetry-exporter-gcp-trace<2 ; extra == 'langchain-testing'
- - pydantic>=2.11.1,<3 ; extra == 'langchain-testing'
- - google-cloud-trace<2 ; extra == 'langchain-testing'
- - absl-py ; extra == 'langchain-testing'
- - openinference-instrumentation-langchain>=0.1.19,<0.2 ; extra == 'langchain-testing'
- - pytest-xdist ; extra == 'langchain-testing'
- - opentelemetry-sdk<2 ; extra == 'langchain-testing'
- - typing-extensions ; extra == 'langchain-testing'
- - langchain-google-vertexai>=2.0.22,<3 ; extra == 'langchain-testing'
- - sentencepiece>=0.2.0 ; extra == 'tokenization'
- - ag2[gemini] ; extra == 'ag2'
- - openinference-instrumentation-autogen>=0.1.6,<0.2 ; extra == 'ag2'
- - cloudpickle>=3.0,<4.0 ; extra == 'ag2-testing'
- - opentelemetry-exporter-gcp-trace<2 ; extra == 'ag2-testing'
- - pydantic>=2.11.1,<3 ; extra == 'ag2-testing'
- - google-cloud-trace<2 ; extra == 'ag2-testing'
- - openinference-instrumentation-autogen>=0.1.6,<0.2 ; extra == 'ag2-testing'
- - absl-py ; extra == 'ag2-testing'
- - pytest-xdist ; extra == 'ag2-testing'
- - opentelemetry-sdk<2 ; extra == 'ag2-testing'
- - typing-extensions ; extra == 'ag2-testing'
- - ag2[gemini] ; extra == 'ag2-testing'
- - llama-index ; extra == 'llama-index'
- - llama-index-llms-google-genai ; extra == 'llama-index'
- - openinference-instrumentation-llama-index>=3.0,<4.0 ; extra == 'llama-index'
- - cloudpickle>=3.0,<4.0 ; extra == 'llama-index-testing'
- - opentelemetry-exporter-gcp-trace<2 ; extra == 'llama-index-testing'
- - pydantic>=2.11.1,<3 ; extra == 'llama-index-testing'
- - google-cloud-trace<2 ; extra == 'llama-index-testing'
- - llama-index-llms-google-genai ; extra == 'llama-index-testing'
- - absl-py ; extra == 'llama-index-testing'
- - pytest-xdist ; extra == 'llama-index-testing'
- - llama-index ; extra == 'llama-index-testing'
- - opentelemetry-sdk<2 ; extra == 'llama-index-testing'
- - typing-extensions ; extra == 'llama-index-testing'
- - openinference-instrumentation-llama-index>=3.0,<4.0 ; extra == 'llama-index-testing'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/39/3c/c8cada9ec282b29232ed9aed5a0b5cca6cf5367cb2ffa8ad0d2583d743f1/google_cloud_bigquery-3.38.0-py3-none-any.whl
- name: google-cloud-bigquery
- version: 3.38.0
- sha256: e06e93ff7b245b239945ef59cb59616057598d369edac457ebf292bd61984da6
- requires_dist:
- - google-api-core[grpc]>=2.11.1,<3.0.0
- - google-auth>=2.14.1,<3.0.0
- - google-cloud-core>=2.4.1,<3.0.0
- - google-resumable-media>=2.0.0,<3.0.0
- - packaging>=24.2.0
- - python-dateutil>=2.8.2,<3.0.0
- - requests>=2.21.0,<3.0.0
- - google-cloud-bigquery-storage>=2.18.0,<3.0.0 ; extra == 'bqstorage'
- - grpcio>=1.47.0,<2.0.0 ; extra == 'bqstorage'
- - grpcio>=1.49.1,<2.0.0 ; python_full_version >= '3.11' and extra == 'bqstorage'
- - pyarrow>=4.0.0 ; extra == 'bqstorage'
- - pandas>=1.3.0 ; extra == 'pandas'
- - pandas-gbq>=0.26.1 ; extra == 'pandas'
- - grpcio>=1.47.0,<2.0.0 ; extra == 'pandas'
- - grpcio>=1.49.1,<2.0.0 ; python_full_version >= '3.11' and extra == 'pandas'
- - pyarrow>=3.0.0 ; extra == 'pandas'
- - db-dtypes>=1.0.4,<2.0.0 ; extra == 'pandas'
- - ipywidgets>=7.7.1 ; extra == 'ipywidgets'
- - ipykernel>=6.2.0 ; extra == 'ipywidgets'
- - geopandas>=0.9.0,<2.0.0 ; extra == 'geopandas'
- - shapely>=1.8.4,<3.0.0 ; extra == 'geopandas'
- - ipython>=7.23.1 ; extra == 'ipython'
- - bigquery-magics>=0.6.0 ; extra == 'ipython'
- - matplotlib>=3.7.1,<=3.9.2 ; python_full_version == '3.9.*' and extra == 'matplotlib'
- - matplotlib>=3.10.3 ; python_full_version >= '3.10' and extra == 'matplotlib'
- - tqdm>=4.23.4,<5.0.0 ; extra == 'tqdm'
- - opentelemetry-api>=1.1.0 ; extra == 'opentelemetry'
- - opentelemetry-sdk>=1.1.0 ; extra == 'opentelemetry'
- - opentelemetry-instrumentation>=0.20b0 ; extra == 'opentelemetry'
- - proto-plus>=1.22.3,<2.0.0 ; extra == 'bigquery-v2'
- - protobuf>=3.20.2,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0 ; extra == 'bigquery-v2'
- - google-cloud-bigquery[bigquery-v2,bqstorage,geopandas,ipython,ipywidgets,matplotlib,opentelemetry,pandas,tqdm] ; extra == 'all'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/40/86/bda7241a8da2d28a754aad2ba0f6776e35b67e37c36ae0c45d49370f1014/google_cloud_core-2.4.3-py2.py3-none-any.whl
- name: google-cloud-core
- version: 2.4.3
- sha256: 5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e
- requires_dist:
- - google-api-core>=1.31.6,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0.dev0
- - google-auth>=1.25.0,<3.0.dev0
- - importlib-metadata>1.0.0 ; python_full_version < '3.8'
- - grpcio>=1.38.0,<2.0.dev0 ; extra == 'grpc'
- - grpcio-status>=1.38.0,<2.0.dev0 ; extra == 'grpc'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/24/92/e3c4850d5d1dd83da7538f1ee91baac9d4bc849e96899bc23ddbe20f15f4/google_cloud_documentai-3.6.0-py3-none-any.whl
- name: google-cloud-documentai
- version: 3.6.0
- sha256: 4c630002eec653313cde75892c92a5482c0d4629997279b3244d356261f9b5f5
- requires_dist:
- - google-api-core[grpc]>=1.34.1,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,!=2.10.*,<3.0.0
- - google-auth>=2.14.1,!=2.24.0,!=2.25.0,<3.0.0
- - proto-plus>=1.22.3,<2.0.0
- - proto-plus>=1.25.0,<2.0.0 ; python_full_version >= '3.13'
- - protobuf>=3.20.2,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/b1/ea/a92631c358da377af34d3a9682c97af83185c2d66363d5939ab4a1169a7f/google_cloud_resource_manager-1.14.2-py3-none-any.whl
- name: google-cloud-resource-manager
- version: 1.14.2
- sha256: d0fa954dedd1d2b8e13feae9099c01b8aac515b648e612834f9942d2795a9900
- requires_dist:
- - google-api-core[grpc]>=1.34.1,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,!=2.10.*,<3.0.0
- - google-auth>=2.14.1,!=2.24.0,!=2.25.0,<3.0.0
- - proto-plus>=1.22.3,<2.0.0
- - protobuf>=3.20.2,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- - grpc-google-iam-v1>=0.14.0,<1.0.0
- - proto-plus>=1.25.0,<2.0.0 ; python_full_version >= '3.13'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/d5/94/6db383d8ee1adf45dc6c73477152b82731fa4c4a46d9c1932cc8757e0fd4/google_cloud_storage-2.19.0-py2.py3-none-any.whl
- name: google-cloud-storage
- version: 2.19.0
- sha256: aeb971b5c29cf8ab98445082cbfe7b161a1f48ed275822f59ed3f1524ea54fba
- requires_dist:
- - google-auth>=2.26.1,<3.0.dev0
- - google-api-core>=2.15.0,<3.0.0.dev0
- - google-cloud-core>=2.3.0,<3.0.dev0
- - google-resumable-media>=2.7.2
- - requests>=2.18.0,<3.0.0.dev0
- - google-crc32c>=1.0,<2.0.dev0
- - protobuf<6.0.0.dev0 ; extra == 'protobuf'
- - opentelemetry-api>=1.1.0 ; extra == 'tracing'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/3b/a5/7279055cf004561894ed3a7bfdf5bf90a53f28fadd01af7cd166e88ddf16/google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- name: google-crc32c
- version: 1.7.1
- sha256: b6d86616faaea68101195c6bdc40c494e4d76f41e07a37ffdef270879c15fb65
- requires_dist:
- - importlib-resources>=1.3 ; python_full_version < '3.9' and os_name == 'nt'
- - pytest ; extra == 'testing'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/11/8f/922116dabe3d0312f08903d324db6ac9d406832cf57707550bc61151d91b/google_genai-1.45.0-py3-none-any.whl
- name: google-genai
- version: 1.45.0
- sha256: e755295063e5fd5a4c44acff782a569e37fa8f76a6c75d0ede3375c70d916b7f
- requires_dist:
- - anyio>=4.8.0,<5.0.0
- - google-auth>=2.14.1,<3.0.0
- - httpx>=0.28.1,<1.0.0
- - pydantic>=2.0.0,<3.0.0
- - requests>=2.28.1,<3.0.0
- - tenacity>=8.2.3,<9.2.0
- - websockets>=13.0.0,<15.1.0
- - typing-extensions>=4.11.0,<5.0.0
- - aiohttp<4.0.0 ; extra == 'aiohttp'
- - sentencepiece>=0.2.0 ; extra == 'local-tokenizer'
- - protobuf ; extra == 'local-tokenizer'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/82/35/b8d3baf8c46695858cb9d8835a53baa1eeb9906ddaf2f728a5f5b640fd1e/google_resumable_media-2.7.2-py2.py3-none-any.whl
- name: google-resumable-media
- version: 2.7.2
- sha256: 3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa
- requires_dist:
- - google-crc32c>=1.0,<2.0.dev0
- - aiohttp>=3.6.2,<4.0.0.dev0 ; extra == 'aiohttp'
- - google-auth>=1.22.0,<2.0.dev0 ; extra == 'aiohttp'
- - requests>=2.18.0,<3.0.0.dev0 ; extra == 'requests'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl
+ - rsa>=3.1.4,<5 ; extra == 'rsa'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl
name: googleapis-common-protos
- version: 1.70.0
- sha256: b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8
+ version: 1.73.0
+ sha256: dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8
requires_dist:
- protobuf>=3.20.2,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- grpcio>=1.44.0,<2.0.0 ; extra == 'grpc'
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl
- name: grpc-google-iam-v1
- version: 0.14.3
- sha256: 7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6
- requires_dist:
- - grpcio>=1.44.0,<2.0.0
- - googleapis-common-protos[grpc]>=1.56.0,<2.0.0
- - protobuf>=3.20.2,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/1e/9c/eda9fe57f2b84343d44c1b66cf3831c973ba29b078b16a27d4587a1fdd47/grpcio-1.75.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- name: grpcio
- version: 1.75.1
- sha256: 7d4fa6ccc3ec2e68a04f7b883d354d7fea22a34c44ce535a2f0c0049cf626ddf
- requires_dist:
- - typing-extensions~=4.12
- - grpcio-tools>=1.75.1 ; extra == 'protobuf'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/d8/ad/6f414bb0b36eee20d93af6907256f208ffcda992ae6d3d7b6a778afe31e6/grpcio_status-1.75.1-py3-none-any.whl
- name: grpcio-status
- version: 1.75.1
- sha256: f681b301be26dcf7abf5c765d4a22e4098765e1a65cbdfa3efca384edf8e4e3c
- requires_dist:
- - protobuf>=6.31.1,<7.0.0
- - grpcio>=1.75.1
- - googleapis-common-protos>=1.5.5
- requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl
name: h11
version: 0.16.0
sha256: 63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/15/07/86397573efefff941e100367bbda0b21496ffcdb34db7ab51912994c32a2/hf_xet-1.1.10-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
name: hf-xet
- version: 1.1.10
- sha256: 6b6bceb6361c80c1cc42b5a7b4e3efd90e64630bcf11224dcac50ef30a47e435
- requires_dist:
- - pytest ; extra == 'tests'
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- name: hf-xet
- version: 1.2.0
- sha256: 3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd
+ version: 1.4.2
+ sha256: 77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c
requires_dist:
- pytest ; extra == 'tests'
requires_python: '>=3.8'
@@ -1695,13 +1435,6 @@ packages:
- socksio==1.* ; extra == 'socks'
- trio>=0.22.0,<1.0 ; extra == 'trio'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/8c/a2/0d269db0f6163be503775dc8b6a6fa15820cc9fdc866f6ba608d86b721f2/httplib2-0.31.0-py3-none-any.whl
- name: httplib2
- version: 0.31.0
- sha256: b9cd78abea9b4e43a7714c6e0f8b6b8561a6fc1e95d5dbd367f5bf0ef35f5d24
- requires_dist:
- - pyparsing>=3.0.4,<4
- requires_python: '>=3.6'
- pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl
name: httpx
version: 0.28.1
@@ -1720,109 +1453,40 @@ packages:
- socksio==1.* ; extra == 'socks'
- zstandard>=0.18.0 ; extra == 'zstd'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl
name: huggingface-hub
- version: 0.35.3
- sha256: 0e3a01829c19d86d03793e4577816fe3bdfc1602ac62c7fb220d593d351224ba
+ version: 0.36.2
+ sha256: 48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270
requires_dist:
- filelock
- fsspec>=2023.5.0
+ - hf-xet>=1.1.3,<2.0.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
- packaging>=20.9
- pyyaml>=5.1
- requests
- tqdm>=4.42.1
- typing-extensions>=3.7.4.3
- - hf-xet>=1.1.3,<2.0.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
- - inquirerpy==0.3.4 ; extra == 'all'
- - aiohttp ; extra == 'all'
- - authlib>=1.3.2 ; extra == 'all'
- - fastapi ; extra == 'all'
- - httpx ; extra == 'all'
- - itsdangerous ; extra == 'all'
- - jedi ; extra == 'all'
- - jinja2 ; extra == 'all'
- - pytest>=8.1.1,<8.2.2 ; extra == 'all'
- - pytest-cov ; extra == 'all'
- - pytest-env ; extra == 'all'
- - pytest-xdist ; extra == 'all'
- - pytest-vcr ; extra == 'all'
- - pytest-asyncio ; extra == 'all'
- - pytest-rerunfailures<16.0 ; extra == 'all'
- - pytest-mock ; extra == 'all'
- - urllib3<2.0 ; extra == 'all'
- - soundfile ; extra == 'all'
- - pillow ; extra == 'all'
- - gradio>=4.0.0 ; extra == 'all'
- - numpy ; extra == 'all'
- - ruff>=0.9.0 ; extra == 'all'
- - libcst>=1.4.0 ; extra == 'all'
- - ty ; extra == 'all'
- - typing-extensions>=4.8.0 ; extra == 'all'
- - types-pyyaml ; extra == 'all'
- - types-requests ; extra == 'all'
- - types-simplejson ; extra == 'all'
- - types-toml ; extra == 'all'
- - types-tqdm ; extra == 'all'
- - types-urllib3 ; extra == 'all'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'all'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'all'
- inquirerpy==0.3.4 ; extra == 'cli'
- - inquirerpy==0.3.4 ; extra == 'dev'
- - aiohttp ; extra == 'dev'
- - authlib>=1.3.2 ; extra == 'dev'
- - fastapi ; extra == 'dev'
- - httpx ; extra == 'dev'
- - itsdangerous ; extra == 'dev'
- - jedi ; extra == 'dev'
- - jinja2 ; extra == 'dev'
- - pytest>=8.1.1,<8.2.2 ; extra == 'dev'
- - pytest-cov ; extra == 'dev'
- - pytest-env ; extra == 'dev'
- - pytest-xdist ; extra == 'dev'
- - pytest-vcr ; extra == 'dev'
- - pytest-asyncio ; extra == 'dev'
- - pytest-rerunfailures<16.0 ; extra == 'dev'
- - pytest-mock ; extra == 'dev'
- - urllib3<2.0 ; extra == 'dev'
- - soundfile ; extra == 'dev'
- - pillow ; extra == 'dev'
- - gradio>=4.0.0 ; extra == 'dev'
- - numpy ; extra == 'dev'
- - ruff>=0.9.0 ; extra == 'dev'
- - libcst>=1.4.0 ; extra == 'dev'
- - ty ; extra == 'dev'
- - typing-extensions>=4.8.0 ; extra == 'dev'
- - types-pyyaml ; extra == 'dev'
- - types-requests ; extra == 'dev'
- - types-simplejson ; extra == 'dev'
- - types-toml ; extra == 'dev'
- - types-tqdm ; extra == 'dev'
- - types-urllib3 ; extra == 'dev'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'dev'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'dev'
- - toml ; extra == 'fastai'
- - fastai>=2.4 ; extra == 'fastai'
- - fastcore>=1.3.27 ; extra == 'fastai'
- - hf-transfer>=0.1.4 ; extra == 'hf-transfer'
- - hf-xet>=1.1.2,<2.0.0 ; extra == 'hf-xet'
- aiohttp ; extra == 'inference'
- - mcp>=1.8.0 ; extra == 'mcp'
- - typer ; extra == 'mcp'
- - aiohttp ; extra == 'mcp'
- authlib>=1.3.2 ; extra == 'oauth'
- fastapi ; extra == 'oauth'
- httpx ; extra == 'oauth'
- itsdangerous ; extra == 'oauth'
- - ruff>=0.9.0 ; extra == 'quality'
- - libcst>=1.4.0 ; extra == 'quality'
- - ty ; extra == 'quality'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'quality'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'quality'
+ - torch ; extra == 'torch'
+ - safetensors[torch] ; extra == 'torch'
+ - hf-transfer>=0.1.4 ; extra == 'hf-transfer'
+ - toml ; extra == 'fastai'
+ - fastai>=2.4 ; extra == 'fastai'
+ - fastcore>=1.3.27 ; extra == 'fastai'
- tensorflow ; extra == 'tensorflow'
- pydot ; extra == 'tensorflow'
- graphviz ; extra == 'tensorflow'
- tensorflow ; extra == 'tensorflow-testing'
- keras<3.0 ; extra == 'tensorflow-testing'
+ - hf-xet>=1.1.2,<2.0.0 ; extra == 'hf-xet'
+ - mcp>=1.8.0 ; extra == 'mcp'
+ - typer ; extra == 'mcp'
+ - aiohttp ; extra == 'mcp'
- inquirerpy==0.3.4 ; extra == 'testing'
- aiohttp ; extra == 'testing'
- authlib>=1.3.2 ; extra == 'testing'
@@ -1844,8 +1508,7 @@ packages:
- pillow ; extra == 'testing'
- gradio>=4.0.0 ; extra == 'testing'
- numpy ; extra == 'testing'
- - torch ; extra == 'torch'
- - safetensors[torch] ; extra == 'torch'
+ - fastapi ; extra == 'testing'
- typing-extensions>=4.8.0 ; extra == 'typing'
- types-pyyaml ; extra == 'typing'
- types-requests ; extra == 'typing'
@@ -1853,20 +1516,11 @@ packages:
- types-toml ; extra == 'typing'
- types-tqdm ; extra == 'typing'
- types-urllib3 ; extra == 'typing'
- requires_python: '>=3.8.0'
-- pypi: https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl
- name: huggingface-hub
- version: 0.36.0
- sha256: 7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d
- requires_dist:
- - filelock
- - fsspec>=2023.5.0
- - packaging>=20.9
- - pyyaml>=5.1
- - requests
- - tqdm>=4.42.1
- - typing-extensions>=3.7.4.3
- - hf-xet>=1.1.3,<2.0.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
+ - ruff>=0.9.0 ; extra == 'quality'
+ - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'quality'
+ - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'quality'
+ - libcst>=1.4.0 ; extra == 'quality'
+ - ty ; extra == 'quality'
- inquirerpy==0.3.4 ; extra == 'all'
- aiohttp ; extra == 'all'
- authlib>=1.3.2 ; extra == 'all'
@@ -1888,7 +1542,10 @@ packages:
- pillow ; extra == 'all'
- gradio>=4.0.0 ; extra == 'all'
- numpy ; extra == 'all'
+ - fastapi ; extra == 'all'
- ruff>=0.9.0 ; extra == 'all'
+ - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'all'
+ - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'all'
- libcst>=1.4.0 ; extra == 'all'
- ty ; extra == 'all'
- typing-extensions>=4.8.0 ; extra == 'all'
@@ -1898,9 +1555,6 @@ packages:
- types-toml ; extra == 'all'
- types-tqdm ; extra == 'all'
- types-urllib3 ; extra == 'all'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'all'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'all'
- - inquirerpy==0.3.4 ; extra == 'cli'
- inquirerpy==0.3.4 ; extra == 'dev'
- aiohttp ; extra == 'dev'
- authlib>=1.3.2 ; extra == 'dev'
@@ -1922,7 +1576,10 @@ packages:
- pillow ; extra == 'dev'
- gradio>=4.0.0 ; extra == 'dev'
- numpy ; extra == 'dev'
+ - fastapi ; extra == 'dev'
- ruff>=0.9.0 ; extra == 'dev'
+ - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'dev'
+ - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'dev'
- libcst>=1.4.0 ; extra == 'dev'
- ty ; extra == 'dev'
- typing-extensions>=4.8.0 ; extra == 'dev'
@@ -1932,77 +1589,36 @@ packages:
- types-toml ; extra == 'dev'
- types-tqdm ; extra == 'dev'
- types-urllib3 ; extra == 'dev'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'dev'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'dev'
- - toml ; extra == 'fastai'
- - fastai>=2.4 ; extra == 'fastai'
- - fastcore>=1.3.27 ; extra == 'fastai'
- - hf-transfer>=0.1.4 ; extra == 'hf-transfer'
- - hf-xet>=1.1.2,<2.0.0 ; extra == 'hf-xet'
- - aiohttp ; extra == 'inference'
- - mcp>=1.8.0 ; extra == 'mcp'
- - typer ; extra == 'mcp'
- - aiohttp ; extra == 'mcp'
- - authlib>=1.3.2 ; extra == 'oauth'
- - fastapi ; extra == 'oauth'
- - httpx ; extra == 'oauth'
- - itsdangerous ; extra == 'oauth'
- - ruff>=0.9.0 ; extra == 'quality'
- - libcst>=1.4.0 ; extra == 'quality'
- - ty ; extra == 'quality'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'quality'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'quality'
- - tensorflow ; extra == 'tensorflow'
- - pydot ; extra == 'tensorflow'
- - graphviz ; extra == 'tensorflow'
- - tensorflow ; extra == 'tensorflow-testing'
- - keras<3.0 ; extra == 'tensorflow-testing'
- - inquirerpy==0.3.4 ; extra == 'testing'
- - aiohttp ; extra == 'testing'
- - authlib>=1.3.2 ; extra == 'testing'
- - fastapi ; extra == 'testing'
- - httpx ; extra == 'testing'
- - itsdangerous ; extra == 'testing'
- - jedi ; extra == 'testing'
- - jinja2 ; extra == 'testing'
- - pytest>=8.1.1,<8.2.2 ; extra == 'testing'
- - pytest-cov ; extra == 'testing'
- - pytest-env ; extra == 'testing'
- - pytest-xdist ; extra == 'testing'
- - pytest-vcr ; extra == 'testing'
- - pytest-asyncio ; extra == 'testing'
- - pytest-rerunfailures<16.0 ; extra == 'testing'
- - pytest-mock ; extra == 'testing'
- - urllib3<2.0 ; extra == 'testing'
- - soundfile ; extra == 'testing'
- - pillow ; extra == 'testing'
- - gradio>=4.0.0 ; extra == 'testing'
- - numpy ; extra == 'testing'
- - torch ; extra == 'torch'
- - safetensors[torch] ; extra == 'torch'
- - typing-extensions>=4.8.0 ; extra == 'typing'
- - types-pyyaml ; extra == 'typing'
- - types-requests ; extra == 'typing'
- - types-simplejson ; extra == 'typing'
- - types-toml ; extra == 'typing'
- - types-tqdm ; extra == 'typing'
- - types-urllib3 ; extra == 'typing'
requires_python: '>=3.8.0'
-- pypi: https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl
- name: humanfriendly
- version: '10.0'
- sha256: 1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477
- requires_dist:
- - monotonic ; python_full_version == '2.7.*'
- - pyreadline ; python_full_version < '3.8' and sys_platform == 'win32'
- - pyreadline3 ; python_full_version >= '3.8' and sys_platform == 'win32'
- requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*'
-- pypi: https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl
- name: identify
- version: 2.6.15
- sha256: 1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757
- requires_dist:
- - ukkonen ; extra == 'license'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda
+ sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a
+ md5: c80d8a3b84358cb967fa81e7075fbc8a
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ - libstdcxx >=14
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 12723451
+ timestamp: 1773822285671
+- pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl
+ name: id
+ version: 1.6.1
+ sha256: f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca
+ requires_dist:
+ - urllib3>=2,<3
+ - build ; extra == 'dev'
+ - bump>=1.3.2 ; extra == 'dev'
+ - id[test,lint] ; extra == 'dev'
+ - bandit ; extra == 'lint'
+ - interrogate ; extra == 'lint'
+ - mypy ; extra == 'lint'
+ - ruff<0.14.15 ; extra == 'lint'
+ - pytest ; extra == 'test'
+ - pytest-cov ; extra == 'test'
+ - pretend ; extra == 'test'
+ - coverage[toml] ; extra == 'test'
requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
name: idna
@@ -2014,111 +1630,120 @@ packages:
- pytest>=8.3.2 ; extra == 'all'
- flake8>=7.1.1 ; extra == 'all'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/cb/bd/b394387b598ed84d8d0fa90611a90bee0adc2021820ad5729f7ced74a8e2/imageio-2.37.0-py3-none-any.whl
- name: imageio
- version: 2.37.0
- sha256: 11efa15b87bc7871b61590326b2d635439acc321cf7f8ce996f812543ce10eed
+- pypi: https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl
+ name: importlib-metadata
+ version: 8.7.1
+ sha256: 5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151
requires_dist:
- - numpy
- - pillow>=8.3.2
- - astropy ; extra == 'all-plugins'
- - av ; extra == 'all-plugins'
- - imageio-ffmpeg ; extra == 'all-plugins'
- - numpy>2 ; extra == 'all-plugins'
- - pillow-heif ; extra == 'all-plugins'
- - psutil ; extra == 'all-plugins'
- - rawpy ; extra == 'all-plugins'
- - tifffile ; extra == 'all-plugins'
- - av ; extra == 'all-plugins-pypy'
- - imageio-ffmpeg ; extra == 'all-plugins-pypy'
- - pillow-heif ; extra == 'all-plugins-pypy'
- - psutil ; extra == 'all-plugins-pypy'
- - tifffile ; extra == 'all-plugins-pypy'
- - wheel ; extra == 'build'
- - pytest ; extra == 'dev'
- - pytest-cov ; extra == 'dev'
- - fsspec[github] ; extra == 'dev'
- - black ; extra == 'dev'
- - flake8 ; extra == 'dev'
- - sphinx<6 ; extra == 'docs'
- - numpydoc ; extra == 'docs'
- - pydata-sphinx-theme ; extra == 'docs'
- - imageio-ffmpeg ; extra == 'ffmpeg'
- - psutil ; extra == 'ffmpeg'
- - astropy ; extra == 'fits'
- - astropy ; extra == 'full'
- - av ; extra == 'full'
- - black ; extra == 'full'
- - flake8 ; extra == 'full'
- - fsspec[github] ; extra == 'full'
- - gdal ; extra == 'full'
- - imageio-ffmpeg ; extra == 'full'
- - itk ; extra == 'full'
- - numpy>2 ; extra == 'full'
- - numpydoc ; extra == 'full'
- - pillow-heif ; extra == 'full'
- - psutil ; extra == 'full'
- - pydata-sphinx-theme ; extra == 'full'
- - pytest ; extra == 'full'
- - pytest-cov ; extra == 'full'
- - rawpy ; extra == 'full'
- - sphinx<6 ; extra == 'full'
- - tifffile ; extra == 'full'
- - wheel ; extra == 'full'
- - gdal ; extra == 'gdal'
- - itk ; extra == 'itk'
- - black ; extra == 'linting'
- - flake8 ; extra == 'linting'
- - pillow-heif ; extra == 'pillow-heif'
- - av ; extra == 'pyav'
- - rawpy ; extra == 'rawpy'
- - numpy>2 ; extra == 'rawpy'
- - pytest ; extra == 'test'
- - pytest-cov ; extra == 'test'
- - fsspec[github] ; extra == 'test'
- - tifffile ; extra == 'tifffile'
+ - zipp>=3.20
+ - pytest>=6,!=8.1.* ; extra == 'test'
+ - packaging ; extra == 'test'
+ - pyfakefs ; extra == 'test'
+ - flufl-flake8 ; extra == 'test'
+ - pytest-perf>=0.9.2 ; extra == 'test'
+ - jaraco-test>=5.4 ; extra == 'test'
+ - sphinx>=3.5 ; extra == 'doc'
+ - jaraco-packaging>=9.3 ; extra == 'doc'
+ - rst-linker>=1.9 ; extra == 'doc'
+ - furo ; extra == 'doc'
+ - sphinx-lint ; extra == 'doc'
+ - jaraco-tidelift>=1.4 ; extra == 'doc'
+ - ipython ; extra == 'perf'
+ - pytest-checkdocs>=2.4 ; extra == 'check'
+ - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check'
+ - pytest-cov ; extra == 'cover'
+ - pytest-enabler>=3.4 ; extra == 'enabler'
+ - pytest-mypy>=1.0.1 ; extra == 'type'
+ - mypy<1.19 ; platform_python_implementation == 'PyPy' and extra == 'type'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl
- name: importlib-metadata
- version: 8.7.0
- sha256: e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd
+- pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
+ name: iniconfig
+ version: 2.3.0
+ sha256: f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl
+ name: invoke
+ version: 2.2.1
+ sha256: 2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8
+ requires_python: '>=3.6'
+- pypi: https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl
+ name: isodate
+ version: 0.7.2
+ sha256: 28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl
+ name: jaraco-classes
+ version: 3.4.0
+ sha256: f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790
+ requires_dist:
+ - more-itertools
+ - sphinx>=3.5 ; extra == 'docs'
+ - jaraco-packaging>=9.3 ; extra == 'docs'
+ - rst-linker>=1.9 ; extra == 'docs'
+ - furo ; extra == 'docs'
+ - sphinx-lint ; extra == 'docs'
+ - jaraco-tidelift>=1.4 ; extra == 'docs'
+ - pytest>=6 ; extra == 'testing'
+ - pytest-checkdocs>=2.4 ; extra == 'testing'
+ - pytest-cov ; extra == 'testing'
+ - pytest-mypy ; extra == 'testing'
+ - pytest-enabler>=2.2 ; extra == 'testing'
+ - pytest-ruff>=0.2.1 ; extra == 'testing'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl
+ name: jaraco-context
+ version: 6.1.2
+ sha256: bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535
+ requires_dist:
+ - backports-tarfile ; python_full_version < '3.12'
+ - pytest>=6,!=8.1.* ; extra == 'test'
+ - jaraco-test>=5.6.0 ; extra == 'test'
+ - portend ; extra == 'test'
+ - sphinx>=3.5 ; extra == 'doc'
+ - jaraco-packaging>=9.3 ; extra == 'doc'
+ - rst-linker>=1.9 ; extra == 'doc'
+ - furo ; extra == 'doc'
+ - sphinx-lint ; extra == 'doc'
+ - jaraco-tidelift>=1.4 ; extra == 'doc'
+ - pytest-checkdocs>=2.14 ; extra == 'check'
+ - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check'
+ - pytest-cov ; extra == 'cover'
+ - pytest-enabler>=3.4 ; extra == 'enabler'
+ - pytest-mypy>=1.0.1 ; platform_python_implementation != 'PyPy' and extra == 'type'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl
+ name: jaraco-functools
+ version: 4.4.0
+ sha256: 9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176
requires_dist:
- - zipp>=3.20
- - typing-extensions>=3.6.4 ; python_full_version < '3.8'
+ - more-itertools
- pytest>=6,!=8.1.* ; extra == 'test'
- - importlib-resources>=1.3 ; python_full_version < '3.9' and extra == 'test'
- - packaging ; extra == 'test'
- - pyfakefs ; extra == 'test'
- - flufl-flake8 ; extra == 'test'
- - pytest-perf>=0.9.2 ; extra == 'test'
- - jaraco-test>=5.4 ; extra == 'test'
+ - jaraco-classes ; extra == 'test'
- sphinx>=3.5 ; extra == 'doc'
- jaraco-packaging>=9.3 ; extra == 'doc'
- rst-linker>=1.9 ; extra == 'doc'
- furo ; extra == 'doc'
- sphinx-lint ; extra == 'doc'
- jaraco-tidelift>=1.4 ; extra == 'doc'
- - ipython ; extra == 'perf'
- pytest-checkdocs>=2.4 ; extra == 'check'
- pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check'
- pytest-cov ; extra == 'cover'
- - pytest-enabler>=2.2 ; extra == 'enabler'
- - pytest-mypy ; extra == 'type'
+ - pytest-enabler>=3.4 ; extra == 'enabler'
+ - pytest-mypy>=1.0.1 ; extra == 'type'
+ - mypy<1.19 ; platform_python_implementation == 'PyPy' and extra == 'type'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl
- name: iniconfig
- version: 2.1.0
- sha256: 9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl
- name: invoke
- version: 2.2.1
- sha256: 2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8
- requires_python: '>=3.6'
-- pypi: https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl
- name: isodate
- version: 0.7.2
- sha256: 28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15
+- pypi: https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl
+ name: jeepney
+ version: 0.9.0
+ sha256: 97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683
+ requires_dist:
+ - pytest ; extra == 'test'
+ - pytest-trio ; extra == 'test'
+ - pytest-asyncio>=0.17 ; extra == 'test'
+ - testpath ; extra == 'test'
+ - trio ; extra == 'test'
+ - async-timeout ; python_full_version < '3.11' and extra == 'test'
+ - trio ; extra == 'trio'
requires_python: '>=3.7'
- pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl
name: jinja2
@@ -2128,25 +1753,25 @@ packages:
- markupsafe>=2.0
- babel>=2.7 ; extra == 'i18n'
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/9a/5f/0dc34563d8164d31d07bc09d141d3da08157a68dcd1f9b886fa4e917805b/jiter-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: jiter
- version: 0.11.0
- sha256: cf408d2a0abd919b60de8c2e7bc5eeab72d4dafd18784152acc7c9adc3291591
+ version: 0.13.0
+ sha256: bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl
name: joblib
- version: 1.5.2
- sha256: 4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241
+ version: 1.5.3
+ sha256: 5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl
name: jsonschema
- version: 4.25.1
- sha256: 3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63
+ version: 4.26.0
+ sha256: d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
requires_dist:
- attrs>=22.2.0
- jsonschema-specifications>=2023.3.6
- referencing>=0.28.4
- - rpds-py>=0.7.1
+ - rpds-py>=0.25.0
- fqdn ; extra == 'format'
- idna ; extra == 'format'
- isoduration ; extra == 'format'
@@ -2164,7 +1789,7 @@ packages:
- rfc3987-syntax>=1.1.0 ; extra == 'format-nongpl'
- uri-template ; extra == 'format-nongpl'
- webcolors>=24.6.0 ; extra == 'format-nongpl'
- requires_python: '>=3.9'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl
name: jsonschema-specifications
version: 2025.9.1
@@ -2172,55 +1797,72 @@ packages:
requires_dist:
- referencing>=0.31.0
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl
- name: lazy-loader
- version: '0.4'
- sha256: 342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc
- requires_dist:
- - packaging
- - importlib-metadata ; python_full_version < '3.8'
- - changelist==0.5 ; extra == 'dev'
- - pre-commit==3.7.0 ; extra == 'lint'
- - pytest>=7.4 ; extra == 'test'
- - pytest-cov>=4.1 ; extra == 'test'
- requires_python: '>=3.7'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1aa0949_4.conda
- sha256: 96b6900ca0489d9e5d0318a6b49f8eff43fd85fef6e07cb0c25344ee94cd7a3a
- md5: c94ab6ff54ba5172cf1c58267005670f
+- pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl
+ name: keyring
+ version: 25.7.0
+ sha256: be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f
+ requires_dist:
+ - pywin32-ctypes>=0.2.0 ; sys_platform == 'win32'
+ - secretstorage>=3.2 ; sys_platform == 'linux'
+ - jeepney>=0.4.2 ; sys_platform == 'linux'
+ - importlib-metadata>=4.11.4 ; python_full_version < '3.12'
+ - jaraco-classes
+ - jaraco-functools
+ - jaraco-context
+ - pytest>=6,!=8.1.* ; extra == 'test'
+ - pyfakefs ; extra == 'test'
+ - sphinx>=3.5 ; extra == 'doc'
+ - jaraco-packaging>=9.3 ; extra == 'doc'
+ - rst-linker>=1.9 ; extra == 'doc'
+ - furo ; extra == 'doc'
+ - sphinx-lint ; extra == 'doc'
+ - jaraco-tidelift>=1.4 ; extra == 'doc'
+ - pytest-checkdocs>=2.4 ; extra == 'check'
+ - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check'
+ - pytest-cov ; extra == 'cover'
+ - pytest-enabler>=3.4 ; extra == 'enabler'
+ - pytest-mypy>=1.0.1 ; extra == 'type'
+ - pygobject-stubs ; extra == 'type'
+ - shtab ; extra == 'type'
+ - types-pywin32 ; extra == 'type'
+ - shtab>=1.1.0 ; extra == 'completion'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_101.conda
+ sha256: 565941ac1f8b0d2f2e8f02827cbca648f4d18cd461afc31f15604cd291b5c5f3
+ md5: 12bd9a3f089ee6c9266a37dab82afabd
depends:
- __glibc >=2.17,<3.0.a0
- zstd >=1.5.7,<1.6.0a0
constrains:
- - binutils_impl_linux-64 2.44
+ - binutils_impl_linux-64 2.45.1
license: GPL-3.0-only
license_family: GPL
purls: []
- size: 742501
- timestamp: 1761335175964
-- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-ha97dd6f_2.conda
- sha256: 707dfb8d55d7a5c6f95c772d778ef07a7ca85417d9971796f7d3daad0b615de8
- md5: 14bae321b8127b63cba276bd53fac237
+ size: 725507
+ timestamp: 1770267139900
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libattr-2.5.2-hb03c661_1.conda
+ sha256: 0cef37eb013dc7091f17161c357afbdef9a9bc79ef6462508face6db3f37db77
+ md5: 7e7f0a692eb62b95d3010563e7f963b6
depends:
- __glibc >=2.17,<3.0.a0
- constrains:
- - binutils_impl_linux-64 2.44
- license: GPL-3.0-only
- license_family: GPL
+ - libgcc >=14
+ license: LGPL-2.1-or-later
+ license_family: LGPL
purls: []
- size: 747158
- timestamp: 1758810907507
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.76-h0b2e76d_0.conda
- sha256: a946b61be1af15ff08c7722e9bac0fab446d8b9896c9f0f35657dfcf887fda8a
- md5: 0f7f0c878c8dceb3b9ec67f5c06d6057
+ size: 53316
+ timestamp: 1773595896163
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.77-h3ff7636_0.conda
+ sha256: 9517cce5193144af0fcbf19b7bd67db0a329c2cc2618f28ffecaa921a1cbe9d3
+ md5: 09c264d40c67b82b49a3f3b89037bd2e
depends:
- __glibc >=2.17,<3.0.a0
- - attr >=2.5.1,<2.6.0a0
- - libgcc >=13
+ - attr >=2.5.2,<2.6.0a0
+ - libgcc >=14
license: BSD-3-Clause
license_family: BSD
purls: []
- size: 121852
- timestamp: 1744577167992
+ size: 121429
+ timestamp: 1762349484074
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcublas-12.9.1.4-h676940d_1.conda
sha256: 671a5204ae983c775d17b3f55b2b0f8ee8cb73b8f0c8b6036070dfadc2770707
md5: af0df9bc982b5ed2c67e8f5062d1f8c1
@@ -2234,9 +1876,9 @@ packages:
purls: []
size: 467746725
timestamp: 1761086109565
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-9.13.1.26-hf7e9902_0.conda
- sha256: 490d4d2136dda223b71e391363810aa9bb542d0d4a22270901df344090d0e394
- md5: 9cd33afe990aff3302820edb37fad8d4
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-9.10.2.21-hf7e9902_0.conda
+ sha256: dc6b89e874867b2cdf08224059bd1543cbb72ed646da177c1454596469c9a4bb
+ md5: a178a1f3642521f104ecceeefa138d01
depends:
- __glibc >=2.28,<3.0.a0
- cuda-nvrtc
@@ -2249,23 +1891,23 @@ packages:
- libcudnn-jit <0a
license: LicenseRef-cuDNN-Software-License-Agreement
purls: []
- size: 447009909
- timestamp: 1759247115298
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-dev-9.13.1.26-h58dd1b1_0.conda
- sha256: af7c9b773738dfbb1d2d0354c9459a95a0e77e53380532a8486139764294a171
- md5: f048ce39203cfab52eba53f35c3228c0
+ size: 526823453
+ timestamp: 1762823414388
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-dev-9.10.2.21-h58dd1b1_0.conda
+ sha256: e9fef18b943a8181427734bc9fada8a594e3a8391fa2a8d59d980acfe1c2cf04
+ md5: 7d7a47d067261531c3089dcec326d6fa
depends:
- __glibc >=2.28,<3.0.a0
- cuda-version >=12,<13.0a0
- - libcudnn 9.13.1.26 hf7e9902_0
+ - libcudnn 9.10.2.21 hf7e9902_0
- libgcc >=14
- libstdcxx >=14
constrains:
- libcudnn-jit-dev <0a
license: LicenseRef-cuDNN-Software-License-Agreement
purls: []
- size: 44048
- timestamp: 1759247497317
+ size: 44188
+ timestamp: 1762823889020
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcufft-11.4.1.4-hecca717_1.conda
sha256: 62d4214c182c89cfb02271a42eaac56a41f50bbbea3b0d795a8e33f167a39a4e
md5: 75ae571353ec92c8f34d4cf6ec6ba264
@@ -2331,110 +1973,76 @@ packages:
purls: []
size: 208846028
timestamp: 1761069913328
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda
- sha256: da2080da8f0288b95dd86765c801c6e166c4619b910b11f9a8446fb852438dc2
- md5: 4211416ecba1866fab0c6470986c22d6
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda
+ sha256: d78f1d3bea8c031d2f032b760f36676d87929b18146351c4464c66b0869df3f5
+ md5: e7f7ce06ec24cfcfb9e36d28cf82ba57
depends:
- __glibc >=2.17,<3.0.a0
- libgcc >=14
constrains:
- - expat 2.7.1.*
- license: MIT
- license_family: MIT
- purls: []
- size: 74811
- timestamp: 1752719572741
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda
- sha256: 764432d32db45466e87f10621db5b74363a9f847d2b8b1f9743746cd160f06ab
- md5: ede4673863426c0883c0063d853bbd85
- depends:
- - __glibc >=2.17,<3.0.a0
- - libgcc >=13
+ - expat 2.7.4.*
license: MIT
license_family: MIT
purls: []
- size: 57433
- timestamp: 1743434498161
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda
- sha256: 25cbdfa65580cfab1b8d15ee90b4c9f1e0d72128f1661449c9a999d341377d54
- md5: 35f29eec58405aaf55e01cb470d8c26a
+ size: 76798
+ timestamp: 1771259418166
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda
+ sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6
+ md5: a360c33a5abe61c07959e449fa1453eb
depends:
- __glibc >=2.17,<3.0.a0
- libgcc >=14
license: MIT
license_family: MIT
purls: []
- size: 57821
- timestamp: 1760295480630
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda
- sha256: 08f9b87578ab981c7713e4e6a7d935e40766e10691732bba376d4964562bcb45
- md5: c0374badb3a5d4b1372db28d19462c53
+ size: 58592
+ timestamp: 1769456073053
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda
+ sha256: faf7d2017b4d718951e3a59d081eb09759152f93038479b768e3d612688f83f5
+ md5: 0aa00f03f9e39fb9876085dee11a85d4
depends:
- __glibc >=2.17,<3.0.a0
- _openmp_mutex >=4.5
constrains:
- - libgomp 15.2.0 h767d61c_7
- - libgcc-ng ==15.2.0=*_7
+ - libgcc-ng ==15.2.0=*_18
+ - libgomp 15.2.0 he0feb66_18
license: GPL-3.0-only WITH GCC-exception-3.1
license_family: GPL
purls: []
- size: 822552
- timestamp: 1759968052178
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda
- sha256: 2045066dd8e6e58aaf5ae2b722fb6dfdbb57c862b5f34ac7bfb58c40ef39b6ad
- md5: 280ea6eee9e2ddefde25ff799c4f0363
+ size: 1041788
+ timestamp: 1771378212382
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda
+ sha256: e318a711400f536c81123e753d4c797a821021fb38970cebfb3f454126016893
+ md5: d5e96b1ed75ca01906b3d2469b4ce493
depends:
- - libgcc 15.2.0 h767d61c_7
+ - libgcc 15.2.0 he0feb66_18
license: GPL-3.0-only WITH GCC-exception-3.1
license_family: GPL
purls: []
- size: 29313
- timestamp: 1759968065504
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-lib-1.11.1-hb9d3cd8_0.conda
- sha256: dc9c7d7a6c0e6639deee6fde2efdc7e119e7739a6b229fa5f9049a449bae6109
- md5: 8504a291085c9fb809b66cabd5834307
- depends:
- - __glibc >=2.17,<3.0.a0
- - libgcc >=13
- - libgpg-error >=1.55,<2.0a0
- license: LGPL-2.1-or-later
- purls: []
- size: 590353
- timestamp: 1747060639058
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda
- sha256: e9fb1c258c8e66ee278397b5822692527c5f5786d372fe7a869b900853f3f5ca
- md5: f7b4d76975aac7e5d9e6ad13845f92fe
+ size: 27526
+ timestamp: 1771378224552
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda
+ sha256: 21337ab58e5e0649d869ab168d4e609b033509de22521de1bfed0c031bfc5110
+ md5: 239c5e9546c38a1e884d69effcf4c882
depends:
- __glibc >=2.17,<3.0.a0
license: GPL-3.0-only WITH GCC-exception-3.1
license_family: GPL
purls: []
- size: 447919
- timestamp: 1759967942498
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libgpg-error-1.55-h3f2d84a_0.conda
- sha256: 697334de4786a1067ea86853e520c64dd72b11a05137f5b318d8a444007b5e60
- md5: 2bd47db5807daade8500ed7ca4c512a4
- depends:
- - libstdcxx >=13
- - libgcc >=13
- - __glibc >=2.17,<3.0.a0
- - libgcc >=13
- license: LGPL-2.1-only
- purls: []
- size: 312184
- timestamp: 1745575272035
-- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda
- sha256: f2591c0069447bbe28d4d696b7fcb0c5bd0b4ac582769b89addbcf26fb3430d8
- md5: 1a580f7796c7bf6393fddb8bbbde58dc
+ size: 603262
+ timestamp: 1771378117851
+- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda
+ sha256: 755c55ebab181d678c12e49cced893598f2bab22d582fbbf4d8b83c18be207eb
+ md5: c7c83eecbb72d88b940c249af56c8b17
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
+ - libgcc >=14
constrains:
- - xz 5.8.1.*
+ - xz 5.8.2.*
license: 0BSD
purls: []
- size: 112894
- timestamp: 1749230047870
+ size: 113207
+ timestamp: 1768752626120
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnl-3.11.0-hb9d3cd8_0.conda
sha256: ba7c5d294e3d80f08ac5a39564217702d1a752e352e486210faff794ac5001b4
md5: db63358239cbe1ff86242406d440e44a
@@ -2505,67 +2113,64 @@ packages:
purls: []
size: 3582778
timestamp: 1761098854056
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda
- sha256: 6d9c32fc369af5a84875725f7ddfbfc2ace795c28f246dc70055a79f9b2003da
- md5: 0b367fad34931cb79e0d6b7e5c06bb1c
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda
+ sha256: d716847b7deca293d2e49ed1c8ab9e4b9e04b9d780aea49a97c26925b28a7993
+ md5: fd893f6a3002a635b5e50ceb9dd2c0f4
depends:
- __glibc >=2.17,<3.0.a0
+ - icu >=78.2,<79.0a0
- libgcc >=14
- libzlib >=1.3.1,<2.0a0
license: blessing
purls: []
- size: 932581
- timestamp: 1753948484112
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda
- sha256: 1b981647d9775e1cdeb2fab0a4dd9cd75a6b0de2963f6c3953dbd712f78334b3
- md5: 5b767048b1b3ee9a954b06f4084f93dc
+ size: 951405
+ timestamp: 1772818874251
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda
+ sha256: 78668020064fdaa27e9ab65cd2997e2c837b564ab26ce3bf0e58a2ce1a525c6e
+ md5: 1b08cd684f34175e4514474793d44bcb
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc 15.2.0 h767d61c_7
+ - libgcc 15.2.0 he0feb66_18
constrains:
- - libstdcxx-ng ==15.2.0=*_7
+ - libstdcxx-ng ==15.2.0=*_18
license: GPL-3.0-only WITH GCC-exception-3.1
license_family: GPL
purls: []
- size: 3898269
- timestamp: 1759968103436
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.9-h996ca69_0.conda
- sha256: 6b063df2d13dc9cedeae7b1591b1917ced7f4e1b04f7246e66cc7fb0088dea07
- md5: b6d222422c17dc11123e63fae4ad4178
+ size: 5852330
+ timestamp: 1771378262446
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-hd0affe5_0.conda
+ sha256: c5008b602cb5c819f7b52d418b3ed17e1818cbbf6705b189e7ab36bb70cce3d8
+ md5: 8ee3cb7f64be0e8c4787f3a4dbe024e6
depends:
- __glibc >=2.17,<3.0.a0
- - libcap >=2.76,<2.77.0a0
+ - libcap >=2.77,<2.78.0a0
- libgcc >=14
- - libgcrypt-lib >=1.11.1,<2.0a0
- - liblzma >=5.8.1,<6.0a0
- - lz4-c >=1.10.0,<1.11.0a0
- - zstd >=1.5.7,<1.6.0a0
license: LGPL-2.1-or-later
purls: []
- size: 492733
- timestamp: 1757520335407
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.9-h085a93f_0.conda
- sha256: 1c8f0b02c400617a9f2ea8429c604b28e25a10f51b3c8d73ce127b4e7b462297
- md5: 973f365f19c1d702bda523658a77de26
+ size: 492799
+ timestamp: 1773797095649
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-hd0affe5_0.conda
+ sha256: 1a1e367c04d66030aa93b4d33905f7f6fbb59cfc292e816fe3e9c1e8b3f4d1e2
+ md5: 2c2270f93d6f9073cbf72d821dfc7d72
depends:
- __glibc >=2.17,<3.0.a0
- - libcap >=2.76,<2.77.0a0
+ - libcap >=2.77,<2.78.0a0
- libgcc >=14
license: LGPL-2.1-or-later
purls: []
- size: 144265
- timestamp: 1757520342166
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda
- sha256: e5ec6d2ad7eef538ddcb9ea62ad4346fde70a4736342c4ad87bd713641eb9808
- md5: 80c07c68d2f6870250959dcc95b209d1
+ size: 145087
+ timestamp: 1773797108513
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda
+ sha256: 1a7539cfa7df00714e8943e18de0b06cceef6778e420a5ee3a2a145773758aee
+ md5: db409b7c1720428638e7c0d509d3e1b5
depends:
- __glibc >=2.17,<3.0.a0
- libgcc >=14
license: BSD-3-Clause
license_family: BSD
purls: []
- size: 37135
- timestamp: 1758626800002
+ size: 40311
+ timestamp: 1766271528534
- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda
sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c
md5: 5aa797f8787fe7a17d1b0821485b5adc
@@ -2575,70 +2180,75 @@ packages:
purls: []
size: 100393
timestamp: 1702724383534
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda
- sha256: d4bfe88d7cb447768e31650f06257995601f89076080e76df55e3112d4e47dc4
- md5: edb0dca6bc32e4f4789199455a1dbeb8
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda
+ sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9
+ md5: d87ff7921124eccd67248aa483c23fec
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
constrains:
- - zlib 1.3.1 *_2
+ - zlib 1.3.2 *_2
license: Zlib
license_family: Other
purls: []
- size: 60963
- timestamp: 1727963148474
-- pypi: https://files.pythonhosted.org/packages/fc/fb/38a48efe3e05a8e9a9765b991740282e0358a83fb896ec00d70bf1448791/litellm-1.78.0-py3-none-any.whl
+ size: 63629
+ timestamp: 1774072609062
+- pypi: https://files.pythonhosted.org/packages/5a/3a/590d58dee65a238f7f3d5c37f8f9f9021ecaf27fe379a393b4259324b56e/litellm-1.82.3-py3-none-any.whl
name: litellm
- version: 1.78.0
- sha256: a9d6deee882de8df38ca24beb930689f49209340137ff8a3dcab0c5fc4a0513d
+ version: 1.82.3
+ sha256: 609901f6c5a5cf8c24386e4e3f50738bb8a9db719709fd76b208c8ee6d00f7a7
requires_dist:
- - pyjwt>=2.8.0,<3.0.0 ; extra == 'proxy'
+ - pyjwt>=2.12.0,<3.0.0 ; python_full_version >= '3.9' and extra == 'proxy'
+ - a2a-sdk>=0.3.22,<0.4.0 ; python_full_version >= '3.10' and extra == 'extra-proxy'
- aiohttp>=3.10
- apscheduler>=3.10.4,<4.0.0 ; extra == 'proxy'
- - azure-identity>=1.15.0,<2.0.0 ; extra == 'extra-proxy' or extra == 'proxy'
+ - azure-identity>=1.15.0,<2.0.0 ; (python_full_version >= '3.9' and extra == 'extra-proxy') or (python_full_version >= '3.9' and extra == 'proxy')
- azure-keyvault-secrets>=4.8.0,<5.0.0 ; extra == 'extra-proxy'
- azure-storage-blob>=12.25.1,<13.0.0 ; extra == 'proxy'
- backoff ; extra == 'proxy'
- - boto3==1.36.0 ; extra == 'proxy'
+ - boto3>=1.40.76,<2.0.0 ; extra == 'proxy'
- click
- cryptography ; extra == 'proxy'
- diskcache>=5.6.1,<6.0.0 ; extra == 'caching'
- - fastapi>=0.115.5,<0.116.0 ; extra == 'proxy'
+ - fastapi>=0.120.1 ; extra == 'proxy'
- fastapi-sso>=0.16.0,<0.17.0 ; extra == 'proxy'
- fastuuid>=0.13.0
+ - google-cloud-aiplatform>=1.38.0 ; extra == 'google'
- google-cloud-iam>=2.19.1,<3.0.0 ; extra == 'extra-proxy'
- google-cloud-kms>=2.21.3,<3.0.0 ; extra == 'extra-proxy'
+ - grpcio>=1.62.3,!=1.68.*,!=1.69.*,!=1.70.*,!=1.71.0,!=1.71.1,!=1.72.0,!=1.72.1,!=1.73.0 ; python_full_version < '3.14' and extra == 'grpc'
+ - grpcio>=1.75.0 ; python_full_version >= '3.14' and extra == 'grpc'
- gunicorn>=23.0.0,<24.0.0 ; extra == 'proxy'
- httpx>=0.23.0
- importlib-metadata>=6.8.0
- jinja2>=3.1.2,<4.0.0
- - jsonschema>=4.22.0,<5.0.0
- - litellm-enterprise==0.1.20 ; extra == 'proxy'
- - litellm-proxy-extras==0.2.26 ; extra == 'proxy'
- - mcp>=1.10.0,<2.0.0 ; python_full_version >= '3.10' and extra == 'proxy'
+ - jsonschema>=4.23.0,<5.0.0
+ - litellm-enterprise>=0.1.33,<0.2.0 ; extra == 'proxy'
+ - litellm-proxy-extras>=0.4.56,<0.5.0 ; extra == 'proxy'
+ - mcp>=1.25.0,<2.0.0 ; python_full_version >= '3.10' and extra == 'proxy'
- mlflow>3.1.4 ; python_full_version >= '3.10' and extra == 'mlflow'
- numpydoc ; extra == 'utils'
- - openai>=1.99.5
+ - openai>=2.8.0
- orjson>=3.9.7,<4.0.0 ; extra == 'proxy'
- polars>=1.31.0,<2.0.0 ; python_full_version >= '3.10' and extra == 'proxy'
- - prisma==0.11.0 ; extra == 'extra-proxy'
+ - prisma>=0.11.0,<0.12.0 ; extra == 'extra-proxy'
- pydantic>=2.5.0,<3.0.0
- pynacl>=1.5.0,<2.0.0 ; extra == 'proxy'
+ - pyroscope-io>=0.8,<0.9 ; sys_platform != 'win32' and extra == 'proxy'
- python-dotenv>=0.2.0
- - python-multipart>=0.0.18,<0.0.19 ; extra == 'proxy'
+ - python-multipart>=0.0.20 ; extra == 'proxy'
- pyyaml>=6.0.1,<7.0.0 ; extra == 'proxy'
- redisvl>=0.4.1,<0.5.0 ; python_full_version >= '3.9' and python_full_version < '3.14' and extra == 'extra-proxy'
- - resend>=0.8.0,<0.9.0 ; extra == 'extra-proxy'
- - rich==13.7.1 ; extra == 'proxy'
+ - resend>=0.8.0 ; extra == 'extra-proxy'
+ - rich>=13.7.1,<14.0.0 ; extra == 'proxy'
- rq ; extra == 'proxy'
- - semantic-router ; python_full_version >= '3.9' and extra == 'semantic-router'
+ - semantic-router>=0.1.12 ; python_full_version >= '3.9' and python_full_version < '3.14' and extra == 'semantic-router'
+ - soundfile>=0.12.1,<0.13.0 ; extra == 'proxy'
- tiktoken>=0.7.0
- tokenizers
- - uvicorn>=0.29.0,<0.30.0 ; extra == 'proxy'
+ - uvicorn>=0.32.1,<1.0.0 ; extra == 'proxy'
- uvloop>=0.21.0,<0.22.0 ; sys_platform != 'win32' and extra == 'proxy'
- - websockets>=13.1.0,<14.0.0 ; extra == 'proxy'
- requires_python: '!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8'
+ - websockets>=15.0.1,<16.0.0 ; extra == 'proxy'
+ requires_python: '>=3.9,<4.0'
- pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl
name: loguru
version: 0.7.3
@@ -2672,28 +2282,6 @@ packages:
- build==1.2.2 ; python_full_version >= '3.11' and extra == 'dev'
- twine==6.0.1 ; python_full_version >= '3.11' and extra == 'dev'
requires_python: '>=3.5,<4.0'
-- pypi: https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl
- name: lxml
- version: 6.0.2
- sha256: 90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192
- requires_dist:
- - cssselect>=0.7 ; extra == 'cssselect'
- - html5lib ; extra == 'html5'
- - beautifulsoup4 ; extra == 'htmlsoup'
- - lxml-html-clean ; extra == 'html-clean'
- requires_python: '>=3.8'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda
- sha256: 47326f811392a5fd3055f0f773036c392d26fdb32e4d8e7a8197eed951489346
- md5: 9de5350a85c4a20c685259b889aa6393
- depends:
- - __glibc >=2.17,<3.0.a0
- - libgcc >=13
- - libstdcxx >=13
- license: BSD-2-Clause
- license_family: BSD
- purls: []
- size: 167055
- timestamp: 1733741040117
- pypi: https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl
name: markdown-it-py
version: 4.0.0
@@ -2737,23 +2325,32 @@ packages:
version: 0.1.2
sha256: 84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/fe/76/4ce12563aea5a76016f8643eff30ab731e6656c845e9e4d090ef10c7b925/mistralai-1.9.11-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl
name: mistralai
- version: 1.9.11
- sha256: 7a3dc2b8ef3fceaa3582220234261b5c4e3e03a972563b07afa150e44a25a6d3
+ version: 1.12.4
+ sha256: 7b69fcbc306436491ad3377fbdead527c9f3a0ce145ec029bf04c6308ff2cca6
requires_dist:
- - authlib>=1.5.2,<2.0 ; extra == 'agents'
- eval-type-backport>=0.2.0
- - google-auth>=2.27.0 ; extra == 'gcp'
- - griffe>=1.7.3,<2.0 ; extra == 'agents'
- httpx>=0.28.1
- invoke>=2.2.0,<3.0.0
- - mcp>=1.0,<2.0 ; python_full_version >= '3.10' and extra == 'agents'
+ - opentelemetry-api>=1.33.1,<2.0.0
+ - opentelemetry-exporter-otlp-proto-http>=1.37.0,<2.0.0
+ - opentelemetry-sdk>=1.33.1,<2.0.0
- pydantic>=2.10.3
- python-dateutil>=2.8.2
- pyyaml>=6.0.2,<7.0.0
- - requests>=2.32.3 ; extra == 'gcp'
- typing-inspection>=0.4.0
+ - authlib>=1.5.2,<2.0 ; extra == 'agents'
+ - griffe>=1.7.3,<2.0 ; extra == 'agents'
+ - mcp>=1.0,<2.0 ; extra == 'agents'
+ - google-auth>=2.27.0 ; extra == 'gcp'
+ - requests>=2.32.3 ; extra == 'gcp'
+ - websockets>=13.0 ; extra == 'realtime'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl
+ name: more-itertools
+ version: 10.8.0
+ sha256: 52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b
requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
name: mpmath
@@ -2768,20 +2365,20 @@ packages:
- sphinx ; extra == 'docs'
- gmpy2>=2.1.0a4 ; platform_python_implementation != 'PyPy' and extra == 'gmpy'
- pytest>=4.6 ; extra == 'tests'
-- pypi: https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: multidict
- version: 6.7.0
- sha256: 123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d
+ version: 6.7.1
+ sha256: bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961
requires_dist:
- typing-extensions>=4.1.0 ; python_full_version < '3.11'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/0a/7d/a988f258104dcd2ccf1ed40fdc97e26c4ac351eeaf81d76e266c52d84e2f/multiprocess-0.70.16-py312-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl
name: multiprocess
- version: 0.70.16
- sha256: fc0544c531920dde3b00c29863377f87e1632601092ea2daca74e4beb40faa2e
+ version: 0.70.19
+ sha256: 3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28
requires_dist:
- - dill>=0.3.8
- requires_python: '>=3.8'
+ - dill>=0.4.1
+ requires_python: '>=3.9'
- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586
md5: 47e340acb35de30501a76c7c799c41d7
@@ -2792,11 +2389,13 @@ packages:
purls: []
size: 891641
timestamp: 1738195959188
-- pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl
name: networkx
- version: '3.5'
- sha256: 0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec
+ version: 3.6.1
+ sha256: d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762
requires_dist:
+ - asv ; extra == 'benchmarking'
+ - virtualenv ; extra == 'benchmarking'
- numpy>=1.25 ; extra == 'default'
- scipy>=1.11.2 ; extra == 'default'
- matplotlib>=3.8 ; extra == 'default'
@@ -2818,20 +2417,30 @@ packages:
- cairocffi>=1.7 ; extra == 'example'
- igraph>=0.11 ; extra == 'example'
- scikit-learn>=1.5 ; extra == 'example'
+ - iplotx>=0.9.0 ; extra == 'example'
- lxml>=4.6 ; extra == 'extra'
- pygraphviz>=1.14 ; extra == 'extra'
- pydot>=3.0.1 ; extra == 'extra'
- sympy>=1.10 ; extra == 'extra'
+ - build>=0.10 ; extra == 'release'
+ - twine>=4.0 ; extra == 'release'
+ - wheel>=0.40 ; extra == 'release'
+ - changelist==0.5 ; extra == 'release'
- pytest>=7.2 ; extra == 'test'
- pytest-cov>=4.0 ; extra == 'test'
- pytest-xdist>=3.0 ; extra == 'test'
- pytest-mpl ; extra == 'test-extras'
- pytest-randomly ; extra == 'test-extras'
- requires_python: '>=3.11'
-- pypi: https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl
+ requires_python: '>=3.11,!=3.14.1'
+- pypi: https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: nh3
+ version: 0.3.3
+ sha256: e98fa3dbfd54e25487e36ba500bc29bca3a4cab4ffba18cfb1a35a2d02624297
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl
name: nltk
- version: 3.9.2
- sha256: 1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a
+ version: 3.9.3
+ sha256: 60b3db6e9995b3dd976b1f0fa7dec22069b2677e759c28eb69b62ddd44870522
requires_dist:
- click
- joblib
@@ -2845,24 +2454,19 @@ packages:
- pyparsing ; extra == 'tgrep'
- twython ; extra == 'twitter'
- requests ; extra == 'corenlp'
- - matplotlib ; extra == 'all'
- - numpy ; extra == 'all'
- - scipy ; extra == 'all'
- - twython ; extra == 'all'
- - requests ; extra == 'all'
- python-crfsuite ; extra == 'all'
- pyparsing ; extra == 'all'
+ - scipy ; extra == 'all'
+ - numpy ; extra == 'all'
- scikit-learn ; extra == 'all'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl
- name: nodeenv
- version: 1.9.1
- sha256: ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
- requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*'
-- pypi: https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - matplotlib ; extra == 'all'
+ - requests ; extra == 'all'
+ - twython ; extra == 'all'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
name: numpy
- version: 2.3.4
- sha256: 4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a
+ version: 2.4.3
+ sha256: e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97
requires_python: '>=3.11'
- pypi: https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl
name: nvidia-cublas-cu12
@@ -2938,10 +2542,10 @@ packages:
version: 12.8.93
sha256: 81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88
requires_python: '>=3'
-- pypi: https://files.pythonhosted.org/packages/3b/6c/99acb2f9eb85c29fc6f3a7ac4dccfd992e22666dd08a642b303311326a97/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
name: nvidia-nvshmem-cu12
- version: 3.3.20
- sha256: d00f26d3f9b2e3c3065be895e3059d6479ea5c638a3f38c9fec49b1b9dd7c1e5
+ version: 3.4.5
+ sha256: 042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd
requires_python: '>=3'
- pypi: https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
name: nvidia-nvtx-cu12
@@ -2960,27 +2564,10 @@ packages:
purls: []
size: 106742
timestamp: 1743700382939
-- pypi: https://files.pythonhosted.org/packages/6c/d9/b7140a4f1615195938c7e358c0804bb84271f0d6886b5cbf105c6cb58aae/onnxruntime_gpu-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- name: onnxruntime-gpu
- version: 1.23.2
- sha256: 4f2d1f720685d729b5258ec1b36dee1de381b8898189908c98cbeecdb2f2b5c2
- requires_dist:
- - coloredlogs
- - flatbuffers
- - numpy>=1.21.6
- - packaging
- - protobuf
- - sympy
- - nvidia-cuda-nvrtc-cu12~=12.0 ; extra == 'cuda'
- - nvidia-cuda-runtime-cu12~=12.0 ; extra == 'cuda'
- - nvidia-cufft-cu12~=11.0 ; extra == 'cuda'
- - nvidia-curand-cu12~=10.0 ; extra == 'cuda'
- - nvidia-cudnn-cu12~=9.0 ; extra == 'cudnn'
- requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/9c/5b/4be258ff072ed8ee15f6bfd8d5a1a4618aa4704b127c0c5959212ad177d6/openai-2.3.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl
name: openai
- version: 2.3.0
- sha256: a7aa83be6f7b0ab2e4d4d7bcaf36e3d790874c0167380c5d0afd0ed99a86bd7b
+ version: 2.29.0
+ sha256: b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a
requires_dist:
- anyio>=3.5.0,<5
- distro>=1.7.0,<2
@@ -2990,30 +2577,31 @@ packages:
- sniffio
- tqdm>4
- typing-extensions>=4.11,<5
+ - typing-extensions>=4.14,<5
- aiohttp ; extra == 'aiohttp'
- - httpx-aiohttp>=0.1.8 ; extra == 'aiohttp'
+ - httpx-aiohttp>=0.1.9 ; extra == 'aiohttp'
- numpy>=1 ; extra == 'datalib'
- pandas-stubs>=1.1.0.11 ; extra == 'datalib'
- pandas>=1.2.3 ; extra == 'datalib'
- websockets>=13,<16 ; extra == 'realtime'
- numpy>=2.0.2 ; extra == 'voice-helpers'
- sounddevice>=0.5.1 ; extra == 'voice-helpers'
- requires_python: '>=3.8'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-h5888daf_0.conda
- sha256: 2b6ce54174ec19110e1b3c37455f7cd138d0e228a75727a9bba443427da30a36
- md5: 45c3d2c224002d6d0d7769142b29f986
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda
+ sha256: 8de2f0cd8a659b01abf86e7fbb8cea4f28ada62fd288429a2bbc040db1b98dd0
+ md5: c930c8052d780caa41216af7de472226
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
- - libstdcxx >=13
+ - libgcc >=14
+ - libstdcxx >=14
license: Apache-2.0
license_family: APACHE
purls: []
- size: 55357
- timestamp: 1749853464518
-- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.4-h26f9b46_0.conda
- sha256: e807f3bad09bdf4075dbb4168619e14b0c0360bacb2e12ef18641a834c8c5549
- md5: 14edad12b59ccbfa3910d42c72adc2a0
+ size: 55754
+ timestamp: 1773844383536
+- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda
+ sha256: 44c877f8af015332a5d12f5ff0fb20ca32f896526a7d0cdb30c769df1144fb5c
+ md5: f61eb8cd60ff9057122a3d338b99c00f
depends:
- __glibc >=2.17,<3.0.a0
- ca-certificates
@@ -3021,108 +2609,167 @@ packages:
license: Apache-2.0
license_family: Apache
purls: []
- size: 3119624
- timestamp: 1759324353651
-- pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
- name: packaging
- version: '25.0'
- sha256: 29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ size: 3164551
+ timestamp: 1769555830639
+- pypi: https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl
+ name: opentelemetry-api
+ version: 1.40.0
+ sha256: 82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9
+ requires_dist:
+ - importlib-metadata>=6.0,<8.8.0
+ - typing-extensions>=4.5.0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl
+ name: opentelemetry-exporter-otlp-proto-common
+ version: 1.40.0
+ sha256: 7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149
+ requires_dist:
+ - opentelemetry-proto==1.40.0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl
+ name: opentelemetry-exporter-otlp-proto-http
+ version: 1.40.0
+ sha256: a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069
+ requires_dist:
+ - googleapis-common-protos~=1.52
+ - opentelemetry-api~=1.15
+ - opentelemetry-exporter-otlp-proto-common==1.40.0
+ - opentelemetry-proto==1.40.0
+ - opentelemetry-sdk~=1.40.0
+ - requests~=2.7
+ - typing-extensions>=4.5.0
+ - opentelemetry-exporter-credential-provider-gcp>=0.59b0 ; extra == 'gcp-auth'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl
+ name: opentelemetry-proto
+ version: 1.40.0
+ sha256: 266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f
+ requires_dist:
+ - protobuf>=5.0,<7.0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl
+ name: opentelemetry-sdk
+ version: 1.40.0
+ sha256: 787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1
+ requires_dist:
+ - opentelemetry-api==1.40.0
+ - opentelemetry-semantic-conventions==0.61b0
+ - typing-extensions>=4.5.0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl
+ name: opentelemetry-semantic-conventions
+ version: 0.61b0
+ sha256: fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2
+ requires_dist:
+ - opentelemetry-api==1.40.0
+ - typing-extensions>=4.5.0
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda
+ sha256: c1fc0f953048f743385d31c468b4a678b3ad20caffdeaa94bed85ba63049fd58
+ md5: b76541e68fea4d511b1ac46a28dcd2c6
+ depends:
+ - python >=3.8
+ - python
+ license: Apache-2.0
+ license_family: APACHE
+ purls:
+ - pkg:pypi/packaging?source=compressed-mapping
+ size: 72010
+ timestamp: 1769093650580
+- pypi: https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
name: pandas
- version: 2.3.3
- sha256: b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89
+ version: 3.0.1
+ sha256: 532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4
requires_dist:
- - numpy>=1.22.4 ; python_full_version < '3.11'
- - numpy>=1.23.2 ; python_full_version == '3.11.*'
- - numpy>=1.26.0 ; python_full_version >= '3.12'
+ - numpy>=1.26.0 ; python_full_version < '3.14'
+ - numpy>=2.3.3 ; python_full_version >= '3.14'
- python-dateutil>=2.8.2
- - pytz>=2020.1
- - tzdata>=2022.7
- - hypothesis>=6.46.1 ; extra == 'test'
- - pytest>=7.3.2 ; extra == 'test'
- - pytest-xdist>=2.2.0 ; extra == 'test'
- - pyarrow>=10.0.1 ; extra == 'pyarrow'
- - bottleneck>=1.3.6 ; extra == 'performance'
- - numba>=0.56.4 ; extra == 'performance'
- - numexpr>=2.8.4 ; extra == 'performance'
- - scipy>=1.10.0 ; extra == 'computation'
- - xarray>=2022.12.0 ; extra == 'computation'
- - fsspec>=2022.11.0 ; extra == 'fss'
- - s3fs>=2022.11.0 ; extra == 'aws'
- - gcsfs>=2022.11.0 ; extra == 'gcp'
- - pandas-gbq>=0.19.0 ; extra == 'gcp'
+ - tzdata ; sys_platform == 'win32'
+ - tzdata ; sys_platform == 'emscripten'
+ - hypothesis>=6.116.0 ; extra == 'test'
+ - pytest>=8.3.4 ; extra == 'test'
+ - pytest-xdist>=3.6.1 ; extra == 'test'
+ - pyarrow>=13.0.0 ; extra == 'pyarrow'
+ - bottleneck>=1.4.2 ; extra == 'performance'
+ - numba>=0.60.0 ; extra == 'performance'
+ - numexpr>=2.10.2 ; extra == 'performance'
+ - scipy>=1.14.1 ; extra == 'computation'
+ - xarray>=2024.10.0 ; extra == 'computation'
+ - fsspec>=2024.10.0 ; extra == 'fss'
+ - s3fs>=2024.10.0 ; extra == 'aws'
+ - gcsfs>=2024.10.0 ; extra == 'gcp'
- odfpy>=1.4.1 ; extra == 'excel'
- - openpyxl>=3.1.0 ; extra == 'excel'
- - python-calamine>=0.1.7 ; extra == 'excel'
+ - openpyxl>=3.1.5 ; extra == 'excel'
+ - python-calamine>=0.3.0 ; extra == 'excel'
- pyxlsb>=1.0.10 ; extra == 'excel'
- xlrd>=2.0.1 ; extra == 'excel'
- - xlsxwriter>=3.0.5 ; extra == 'excel'
- - pyarrow>=10.0.1 ; extra == 'parquet'
- - pyarrow>=10.0.1 ; extra == 'feather'
- - tables>=3.8.0 ; extra == 'hdf5'
- - pyreadstat>=1.2.0 ; extra == 'spss'
- - sqlalchemy>=2.0.0 ; extra == 'postgresql'
- - psycopg2>=2.9.6 ; extra == 'postgresql'
- - adbc-driver-postgresql>=0.8.0 ; extra == 'postgresql'
- - sqlalchemy>=2.0.0 ; extra == 'mysql'
- - pymysql>=1.0.2 ; extra == 'mysql'
- - sqlalchemy>=2.0.0 ; extra == 'sql-other'
- - adbc-driver-postgresql>=0.8.0 ; extra == 'sql-other'
- - adbc-driver-sqlite>=0.8.0 ; extra == 'sql-other'
- - beautifulsoup4>=4.11.2 ; extra == 'html'
+ - xlsxwriter>=3.2.0 ; extra == 'excel'
+ - pyarrow>=13.0.0 ; extra == 'parquet'
+ - pyarrow>=13.0.0 ; extra == 'feather'
+ - pyiceberg>=0.8.1 ; extra == 'iceberg'
+ - tables>=3.10.1 ; extra == 'hdf5'
+ - pyreadstat>=1.2.8 ; extra == 'spss'
+ - sqlalchemy>=2.0.36 ; extra == 'postgresql'
+ - psycopg2>=2.9.10 ; extra == 'postgresql'
+ - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql'
+ - sqlalchemy>=2.0.36 ; extra == 'mysql'
+ - pymysql>=1.1.1 ; extra == 'mysql'
+ - sqlalchemy>=2.0.36 ; extra == 'sql-other'
+ - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other'
+ - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other'
+ - beautifulsoup4>=4.12.3 ; extra == 'html'
- html5lib>=1.1 ; extra == 'html'
- - lxml>=4.9.2 ; extra == 'html'
- - lxml>=4.9.2 ; extra == 'xml'
- - matplotlib>=3.6.3 ; extra == 'plot'
- - jinja2>=3.1.2 ; extra == 'output-formatting'
+ - lxml>=5.3.0 ; extra == 'html'
+ - lxml>=5.3.0 ; extra == 'xml'
+ - matplotlib>=3.9.3 ; extra == 'plot'
+ - jinja2>=3.1.5 ; extra == 'output-formatting'
- tabulate>=0.9.0 ; extra == 'output-formatting'
- pyqt5>=5.15.9 ; extra == 'clipboard'
- - qtpy>=2.3.0 ; extra == 'clipboard'
- - zstandard>=0.19.0 ; extra == 'compression'
- - dataframe-api-compat>=0.1.7 ; extra == 'consortium-standard'
- - adbc-driver-postgresql>=0.8.0 ; extra == 'all'
- - adbc-driver-sqlite>=0.8.0 ; extra == 'all'
- - beautifulsoup4>=4.11.2 ; extra == 'all'
- - bottleneck>=1.3.6 ; extra == 'all'
- - dataframe-api-compat>=0.1.7 ; extra == 'all'
- - fastparquet>=2022.12.0 ; extra == 'all'
- - fsspec>=2022.11.0 ; extra == 'all'
- - gcsfs>=2022.11.0 ; extra == 'all'
+ - qtpy>=2.4.2 ; extra == 'clipboard'
+ - zstandard>=0.23.0 ; extra == 'compression'
+ - pytz>=2024.2 ; extra == 'timezone'
+ - adbc-driver-postgresql>=1.2.0 ; extra == 'all'
+ - adbc-driver-sqlite>=1.2.0 ; extra == 'all'
+ - beautifulsoup4>=4.12.3 ; extra == 'all'
+ - bottleneck>=1.4.2 ; extra == 'all'
+ - fastparquet>=2024.11.0 ; extra == 'all'
+ - fsspec>=2024.10.0 ; extra == 'all'
+ - gcsfs>=2024.10.0 ; extra == 'all'
- html5lib>=1.1 ; extra == 'all'
- - hypothesis>=6.46.1 ; extra == 'all'
- - jinja2>=3.1.2 ; extra == 'all'
- - lxml>=4.9.2 ; extra == 'all'
- - matplotlib>=3.6.3 ; extra == 'all'
- - numba>=0.56.4 ; extra == 'all'
- - numexpr>=2.8.4 ; extra == 'all'
+ - hypothesis>=6.116.0 ; extra == 'all'
+ - jinja2>=3.1.5 ; extra == 'all'
+ - lxml>=5.3.0 ; extra == 'all'
+ - matplotlib>=3.9.3 ; extra == 'all'
+ - numba>=0.60.0 ; extra == 'all'
+ - numexpr>=2.10.2 ; extra == 'all'
- odfpy>=1.4.1 ; extra == 'all'
- - openpyxl>=3.1.0 ; extra == 'all'
- - pandas-gbq>=0.19.0 ; extra == 'all'
- - psycopg2>=2.9.6 ; extra == 'all'
- - pyarrow>=10.0.1 ; extra == 'all'
- - pymysql>=1.0.2 ; extra == 'all'
+ - openpyxl>=3.1.5 ; extra == 'all'
+ - psycopg2>=2.9.10 ; extra == 'all'
+ - pyarrow>=13.0.0 ; extra == 'all'
+ - pyiceberg>=0.8.1 ; extra == 'all'
+ - pymysql>=1.1.1 ; extra == 'all'
- pyqt5>=5.15.9 ; extra == 'all'
- - pyreadstat>=1.2.0 ; extra == 'all'
- - pytest>=7.3.2 ; extra == 'all'
- - pytest-xdist>=2.2.0 ; extra == 'all'
- - python-calamine>=0.1.7 ; extra == 'all'
+ - pyreadstat>=1.2.8 ; extra == 'all'
+ - pytest>=8.3.4 ; extra == 'all'
+ - pytest-xdist>=3.6.1 ; extra == 'all'
+ - python-calamine>=0.3.0 ; extra == 'all'
+ - pytz>=2024.2 ; extra == 'all'
- pyxlsb>=1.0.10 ; extra == 'all'
- - qtpy>=2.3.0 ; extra == 'all'
- - scipy>=1.10.0 ; extra == 'all'
- - s3fs>=2022.11.0 ; extra == 'all'
- - sqlalchemy>=2.0.0 ; extra == 'all'
- - tables>=3.8.0 ; extra == 'all'
+ - qtpy>=2.4.2 ; extra == 'all'
+ - scipy>=1.14.1 ; extra == 'all'
+ - s3fs>=2024.10.0 ; extra == 'all'
+ - sqlalchemy>=2.0.36 ; extra == 'all'
+ - tables>=3.10.1 ; extra == 'all'
- tabulate>=0.9.0 ; extra == 'all'
- - xarray>=2022.12.0 ; extra == 'all'
+ - xarray>=2024.10.0 ; extra == 'all'
- xlrd>=2.0.1 ; extra == 'all'
- - xlsxwriter>=3.0.5 ; extra == 'all'
- - zstandard>=0.19.0 ; extra == 'all'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - xlsxwriter>=3.2.0 ; extra == 'all'
+ - zstandard>=0.23.0 ; extra == 'all'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
name: pillow
- version: 12.0.0
- sha256: b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8
+ version: 11.3.0
+ sha256: 676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024
requires_dist:
- furo ; extra == 'docs'
- olefile ; extra == 'docs'
@@ -3133,9 +2780,6 @@ packages:
- sphinxext-opengraph ; extra == 'docs'
- olefile ; extra == 'fpx'
- olefile ; extra == 'mic'
- - arro3-compute ; extra == 'test-arrow'
- - arro3-core ; extra == 'test-arrow'
- - nanoarrow ; extra == 'test-arrow'
- pyarrow ; extra == 'test-arrow'
- check-manifest ; extra == 'tests'
- coverage>=7.4.2 ; extra == 'tests'
@@ -3143,43 +2787,28 @@ packages:
- markdown2 ; extra == 'tests'
- olefile ; extra == 'tests'
- packaging ; extra == 'tests'
- - pyroma>=5 ; extra == 'tests'
+ - pyroma ; extra == 'tests'
- pytest ; extra == 'tests'
- pytest-cov ; extra == 'tests'
- pytest-timeout ; extra == 'tests'
- pytest-xdist ; extra == 'tests'
- trove-classifiers>=2024.10.12 ; extra == 'tests'
+ - typing-extensions ; python_full_version < '3.10' and extra == 'typing'
- defusedxml ; extra == 'xmp'
- requires_python: '>=3.10'
-- conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.2-pyh8b19718_0.conda
- sha256: ec9ed3cef137679f3e3a68e286c6efd52144684e1be0b05004d9699882dadcdd
- md5: dfce4b2af4bfe90cdcaf56ca0b28ddf5
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda
+ sha256: 8e1497814a9997654ed7990a79c054ea5a42545679407acbc6f7e809c73c9120
+ md5: 67bdec43082fd8a9cffb9484420b39a2
depends:
- - python >=3.9,<3.13.0a0
+ - python >=3.10,<3.13.0a0
- setuptools
- wheel
license: MIT
license_family: MIT
purls:
- - pkg:pypi/pip?source=hash-mapping
- size: 1177168
- timestamp: 1753924973872
-- pypi: https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl
- name: platformdirs
- version: 4.5.0
- sha256: e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
- requires_dist:
- - furo>=2025.9.25 ; extra == 'docs'
- - proselint>=0.14 ; extra == 'docs'
- - sphinx-autodoc-typehints>=3.2 ; extra == 'docs'
- - sphinx>=8.2.3 ; extra == 'docs'
- - appdirs==1.4.4 ; extra == 'test'
- - covdefaults>=2.3 ; extra == 'test'
- - pytest-cov>=7 ; extra == 'test'
- - pytest-mock>=3.15.1 ; extra == 'test'
- - pytest>=8.4.2 ; extra == 'test'
- - mypy>=1.18.2 ; extra == 'type'
- requires_python: '>=3.10'
+ - pkg:pypi/pip?source=compressed-mapping
+ size: 1181790
+ timestamp: 1770270305795
- pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
name: pluggy
version: 1.6.0
@@ -3191,43 +2820,24 @@ packages:
- pytest-benchmark ; extra == 'testing'
- coverage ; extra == 'testing'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl
- name: pre-commit
- version: 4.3.0
- sha256: 2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8
- requires_dist:
- - cfgv>=2.0.0
- - identify>=1.0.0
- - nodeenv>=0.11.1
- - pyyaml>=5.1
- - virtualenv>=20.10.0
- requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: propcache
version: 0.4.1
sha256: 15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl
- name: proto-plus
- version: 1.26.1
- sha256: 13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66
- requires_dist:
- - protobuf>=3.19.0,<7.0.0
- - google-api-core>=1.31.5 ; extra == 'testing'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl
name: protobuf
- version: 6.33.0
- sha256: 35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef
+ version: 6.33.6
+ sha256: e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/30/28/5e27f4d5a0e347f8e3cc16cd7d35533dbce086c95807f1f0e9cd77e26c10/psutil-7.1.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
name: psutil
- version: 7.1.2
- sha256: 3efd8fc791492e7808a51cb2b94889db7578bfaea22df931424f874468e389e3
+ version: 7.2.2
+ sha256: 076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9
requires_dist:
+ - psleak ; extra == 'dev'
- pytest ; extra == 'dev'
- pytest-instafail ; extra == 'dev'
- - pytest-subtests ; extra == 'dev'
- pytest-xdist ; extra == 'dev'
- setuptools ; extra == 'dev'
- abi3audit ; extra == 'dev'
@@ -3250,18 +2860,19 @@ packages:
- virtualenv ; extra == 'dev'
- vulture ; extra == 'dev'
- wheel ; extra == 'dev'
- - pyreadline ; os_name == 'nt' and extra == 'dev'
- - pywin32 ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'dev'
- - wheel ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'dev'
- - wmi ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'dev'
+ - colorama ; os_name == 'nt' and extra == 'dev'
+ - pyreadline3 ; os_name == 'nt' and extra == 'dev'
+ - pywin32 ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev'
+ - wheel ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev'
+ - wmi ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev'
+ - psleak ; extra == 'test'
- pytest ; extra == 'test'
- pytest-instafail ; extra == 'test'
- - pytest-subtests ; extra == 'test'
- pytest-xdist ; extra == 'test'
- setuptools ; extra == 'test'
- - pywin32 ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'test'
- - wheel ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'test'
- - wmi ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'test'
+ - pywin32 ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test'
+ - wheel ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test'
+ - wmi ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test'
requires_python: '>=3.6'
- pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl
name: pyarabic
@@ -3269,21 +2880,15 @@ packages:
sha256: b9a530277876008f5fbe53249c6953b4513dbbf2ea8f4339694e87c8a75d7edf
requires_dist:
- six>=1.14.0
-- pypi: https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl
name: pyarrow
- version: 21.0.0
- sha256: b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e
- requires_dist:
- - pytest ; extra == 'test'
- - hypothesis ; extra == 'test'
- - cffi ; extra == 'test'
- - pytz ; extra == 'test'
- - pandas ; extra == 'test'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl
+ version: 23.0.1
+ sha256: 813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl
name: pyasn1
- version: 0.6.1
- sha256: 0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629
+ version: 0.6.3
+ sha256: a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde
requires_python: '>=3.8'
- pypi: https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl
name: pyasn1-modules
@@ -3292,22 +2897,27 @@ packages:
requires_dist:
- pyasn1>=0.6.1,<0.7.0
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl
+ name: pycparser
+ version: '3.0'
+ sha256: b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
name: pydantic
- version: 2.12.2
- sha256: 25ff718ee909acd82f1ff9b1a4acfd781bb23ab3739adaa7144f19a6a4e231ae
+ version: 2.12.5
+ sha256: e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d
requires_dist:
- annotated-types>=0.6.0
- - pydantic-core==2.41.4
+ - pydantic-core==2.41.5
- typing-extensions>=4.14.1
- typing-inspection>=0.4.2
- email-validator>=2.0.0 ; extra == 'email'
- tzdata ; python_full_version >= '3.9' and sys_platform == 'win32' and extra == 'timezone'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: pydantic-core
- version: 2.41.4
- sha256: 98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47
+ version: 2.41.5
+ sha256: eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c
requires_dist:
- typing-extensions>=4.14.1
requires_python: '>=3.9'
@@ -3318,28 +2928,25 @@ packages:
requires_dist:
- colorama>=0.4.6 ; extra == 'windows-terminal'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/5b/5a/1292a0df4ff71fbc00dfa8c08759d17c97e1e8ea9277eb5bc5f079ca188d/pymupdf-1.26.5-cp39-abi3-manylinux_2_28_x86_64.whl
- name: pymupdf
- version: 1.26.5
- sha256: caad0ffeb63dcc4a29ca40f3c68d7b78d32a932e834b0056b529cc0bdbaaffc9
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
- name: pyparsing
- version: 3.2.5
- sha256: e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e
- requires_dist:
- - railroad-diagrams ; extra == 'diagrams'
- - jinja2 ; extra == 'diagrams'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/ff/49/a640b288a48dab1752281dd9b72c0679fccea107874e80a65a606b00efa9/pypdfium2-5.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: pypdfium2
+ version: 5.6.0
+ sha256: 515be355222cc57ae9e62cd5c7c350b8e0c863efc539f80c7d75e2811ba45cb6
+ requires_python: '>=3.6'
+- pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl
+ name: pyproject-hooks
+ version: 1.2.0
+ sha256: 9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl
name: pytest
- version: 8.4.2
- sha256: 872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79
+ version: 9.0.2
+ sha256: 711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b
requires_dist:
- colorama>=0.4 ; sys_platform == 'win32'
- exceptiongroup>=1 ; python_full_version < '3.11'
- - iniconfig>=1
- - packaging>=20
+ - iniconfig>=1.0.1
+ - packaging>=22
- pluggy>=1.5,<2
- pygments>=2.7.2
- tomli>=1 ; python_full_version < '3.11'
@@ -3350,20 +2957,20 @@ packages:
- requests ; extra == 'dev'
- setuptools ; extra == 'dev'
- xmlschema ; extra == 'dev'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl
name: pytest-asyncio
- version: 1.2.0
- sha256: 8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99
+ version: 1.3.0
+ sha256: 611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5
requires_dist:
- backports-asyncio-runner>=1.1,<2 ; python_full_version < '3.11'
- - pytest>=8.2,<9
+ - pytest>=8.2,<10
- typing-extensions>=4.12 ; python_full_version < '3.13'
- sphinx>=5.3 ; extra == 'docs'
- sphinx-rtd-theme>=1 ; extra == 'docs'
- coverage>=6.2 ; extra == 'testing'
- hypothesis>=5.7.1 ; extra == 'testing'
- requires_python: '>=3.9'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
name: pytest-cov
version: 7.0.0
@@ -3376,61 +2983,33 @@ packages:
- pytest-xdist ; extra == 'testing'
- virtualenv ; extra == 'testing'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hd63d673_1_cpython.conda
- build_number: 1
- sha256: 39898d24769a848c057ab861052e50bdc266310a7509efa3514b840e85a2ae98
- md5: 5c00c8cea14ee8d02941cab9121dce41
+- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda
+ sha256: a44655c1c3e1d43ed8704890a91e12afd68130414ea2c0872e154e5633a13d7e
+ md5: 7eccb41177e15cc672e1babe9056018e
depends:
- __glibc >=2.17,<3.0.a0
- bzip2 >=1.0.8,<2.0a0
- ld_impl_linux-64 >=2.36.1
- - libexpat >=2.7.1,<3.0a0
+ - libexpat >=2.7.4,<3.0a0
- libffi >=3.5.2,<3.6.0a0
- libgcc >=14
- - liblzma >=5.8.1,<6.0a0
- - libnsl >=2.0.1,<2.1.0a0
- - libsqlite >=3.50.4,<4.0a0
- - libuuid >=2.41.2,<3.0a0
- - libxcrypt >=4.4.36
- - libzlib >=1.3.1,<2.0a0
- - ncurses >=6.5,<7.0a0
- - openssl >=3.5.4,<4.0a0
- - readline >=8.2,<9.0a0
- - tk >=8.6.13,<8.7.0a0
- - tzdata
- constrains:
- - python_abi 3.12.* *_cp312
- license: Python-2.0
- purls: []
- size: 31537229
- timestamp: 1761176876216
-- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hfe2f287_0_cpython.conda
- sha256: 5386d8c8230b6478ae165ff34f57d498891ac160e871629cbb4d4256e69cc542
- md5: ceada987beec823b3c702710ee073fba
- depends:
- - __glibc >=2.17,<3.0.a0
- - bzip2 >=1.0.8,<2.0a0
- - ld_impl_linux-64 >=2.36.1
- - libexpat >=2.7.1,<3.0a0
- - libffi >=3.4.6,<3.5.0a0
- - libgcc >=14
- - liblzma >=5.8.1,<6.0a0
+ - liblzma >=5.8.2,<6.0a0
- libnsl >=2.0.1,<2.1.0a0
- - libsqlite >=3.50.4,<4.0a0
- - libuuid >=2.41.2,<3.0a0
+ - libsqlite >=3.51.2,<4.0a0
+ - libuuid >=2.41.3,<3.0a0
- libxcrypt >=4.4.36
- libzlib >=1.3.1,<2.0a0
- ncurses >=6.5,<7.0a0
- - openssl >=3.5.4,<4.0a0
- - readline >=8.2,<9.0a0
+ - openssl >=3.5.5,<4.0a0
+ - readline >=8.3,<9.0a0
- tk >=8.6.13,<8.7.0a0
- tzdata
constrains:
- python_abi 3.12.* *_cp312
license: Python-2.0
purls: []
- size: 31547362
- timestamp: 1760367376467
+ size: 31608571
+ timestamp: 1772730708989
- pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
name: python-dateutil
version: 2.9.0.post0
@@ -3438,55 +3017,73 @@ packages:
requires_dist:
- six>=1.5
requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*'
-- pypi: https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl
name: python-dotenv
- version: 1.1.1
- sha256: 31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc
+ version: 1.2.2
+ sha256: 1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a
requires_dist:
- click>=5.0 ; extra == 'cli'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
- name: pytz
- version: '2025.2'
- sha256: 5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00
+ requires_python: '>=3.10'
- pypi: 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
name: pyyaml
version: 6.0.3
sha256: ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/20/14/7399c18c460e72d1b754e80dafc9f65cb42a46cc8f29cd57d11c0c4acc94/rapidfuzz-3.14.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/c4/43/80f67e0336cb2fc725f8e06f7fe35c1d0fe946f4d2b8b2175e797e07349e/qwen_vl_utils-0.0.14-py3-none-any.whl
+ name: qwen-vl-utils
+ version: 0.0.14
+ sha256: 5e28657bfd031e56bd447c5901b58ddfc3835285ed100f4c56580e0ade054e96
+ requires_dist:
+ - av
+ - packaging
+ - pillow
+ - requests
+ - decord ; extra == 'decord'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
name: rapidfuzz
- version: 3.14.1
- sha256: 26db0e815213d04234298dea0d884d92b9cb8d4ba954cab7cf67a35853128a33
+ version: 3.14.3
+ sha256: 6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382
requires_dist:
- numpy ; extra == 'all'
requires_python: '>=3.10'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/rdma-core-60.0-hecca717_0.conda
- sha256: 5c09b833b698ecd19da14f5ff903063cf174382d6b32c86166984a93d427d681
- md5: fe7412835a65cd99eacf3afbb124c7ac
+- conda: https://conda.anaconda.org/conda-forge/linux-64/rdma-core-61.0-h192683f_0.conda
+ sha256: 8e0b7962cf8bec9a016cd91a6c6dc1f9ebc8e7e316b1d572f7b9047d0de54717
+ md5: d487d93d170e332ab39803e05912a762
depends:
- __glibc >=2.17,<3.0.a0
- libgcc >=14
- libnl >=3.11.0,<4.0a0
- libstdcxx >=14
- - libsystemd0 >=257.9
- - libudev1 >=257.9
+ - libsystemd0 >=257.10
+ - libudev1 >=257.10
license: Linux-OpenIB
license_family: BSD
purls: []
- size: 1244282
- timestamp: 1761557737114
-- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda
- sha256: 2d6d0c026902561ed77cd646b5021aef2d4db22e57a5b0178dfc669231e06d2c
- md5: 283b96675859b20a825f8fa30f311446
+ size: 1268666
+ timestamp: 1769154883613
+- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda
+ sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002
+ md5: d7d95fc8287ea7bf33e0e7116d2b95ec
depends:
- - libgcc >=13
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
- ncurses >=6.5,<7.0a0
license: GPL-3.0-only
license_family: GPL
purls: []
- size: 282480
- timestamp: 1740379431762
+ size: 345073
+ timestamp: 1765813471974
+- pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl
+ name: readme-renderer
+ version: '44.0'
+ sha256: 2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151
+ requires_dist:
+ - nh3>=0.2.14
+ - docutils>=0.21.2
+ - pygments>=2.5.1
+ - cmarkgfm>=0.8.0 ; extra == 'md'
+ requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl
name: referencing
version: 0.37.0
@@ -3496,16 +3093,11 @@ packages:
- rpds-py>=0.7.0
- typing-extensions>=4.4.0 ; python_full_version < '3.13'
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/35/9e/a91b50332a9750519320ed30ec378b74c996f6befe282cfa6bb6cea7e9fd/regex-2025.9.18-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: regex
- version: 2025.9.18
- sha256: 4f130c3a7845ba42de42f380fff3c8aebe89a810747d91bcf56d40a069f15352
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/99/14/9a39b7c9e007968411bc3c843cc14cf15437510c0a9991f080cab654fd16/regex-2025.10.23-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- name: regex
- version: 2025.10.23
- sha256: d97d73818c642c938db14c0668167f8d39520ca9d983604575ade3fda193afcc
- requires_python: '>=3.9'
+ version: 2026.2.28
+ sha256: d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
name: requests
version: 2.32.5
@@ -3518,53 +3110,47 @@ packages:
- pysocks>=1.5.6,!=1.5.7 ; extra == 'socks'
- chardet>=3.0.2,<6 ; extra == 'use-chardet-on-py3'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl
+ name: requests-toolbelt
+ version: 1.0.0
+ sha256: cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06
+ requires_dist:
+ - requests>=2.0.1,<3.0.0
+ requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*'
+- pypi: https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl
+ name: rfc3986
+ version: 2.0.0
+ sha256: 50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd
+ requires_dist:
+ - idna ; extra == 'idna2008'
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl
name: rich
- version: 14.2.0
- sha256: 76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd
+ version: 13.9.4
+ sha256: 6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90
requires_dist:
- ipywidgets>=7.5.1,<9 ; extra == 'jupyter'
- markdown-it-py>=2.2.0
- pygments>=2.13.0,<3.0.0
+ - typing-extensions>=4.0.0,<5.0 ; python_full_version < '3.11'
requires_python: '>=3.8.0'
-- pypi: https://files.pythonhosted.org/packages/e2/c5/9136736c37022a6ad27fea38f3111eb8f02fe75d067f9a985cc358653102/rouge_score-0.1.2.tar.gz
- name: rouge-score
- version: 0.1.2
- sha256: c7d4da2683e68c9abf0135ef915d63a46643666f848e558a1b9f7ead17ff0f04
- requires_dist:
- - absl-py
- - nltk
- - numpy
- - six>=1.14.0
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: rpds-py
- version: 0.27.1
- sha256: 466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl
- name: rsa
- version: 4.9.1
- sha256: 68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762
- requires_dist:
- - pyasn1>=0.1.3
- requires_python: '>=3.6,<4'
-- pypi: https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
- name: rtree
- version: 1.4.1
- sha256: 12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ version: 0.30.0
+ sha256: 47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: ruff
- version: 0.11.13
- sha256: 4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9
+ version: 0.15.7
+ sha256: dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/fe/5d/5a514d7b88e310c8b146e2404e0dc161282e78634d9358975fd56dfd14be/safetensors-0.6.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: safetensors
- version: 0.6.2
- sha256: 8045db2c872db8f4cbe3faa0495932d89c38c899c603f21e9b6486951a5ecb8f
+ version: 0.7.0
+ sha256: dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48
requires_dist:
- numpy>=1.21.6 ; extra == 'numpy'
+ - packaging ; extra == 'torch'
- safetensors[numpy] ; extra == 'torch'
- torch>=1.10 ; extra == 'torch'
- safetensors[numpy] ; extra == 'tensorflow'
@@ -3601,139 +3187,25 @@ packages:
- safetensors[testing] ; extra == 'all'
- safetensors[all] ; extra == 'dev'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- name: scikit-image
- version: 0.25.2
- sha256: a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824
+- pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl
+ name: secretstorage
+ version: 3.5.0
+ sha256: 0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137
requires_dist:
- - numpy>=1.24
- - scipy>=1.11.4
- - networkx>=3.0
- - pillow>=10.1
- - imageio>=2.33,!=2.35.0
- - tifffile>=2022.8.12
- - packaging>=21
- - lazy-loader>=0.4
- - meson-python>=0.16 ; extra == 'build'
- - ninja>=1.11.1.1 ; extra == 'build'
- - cython>=3.0.8 ; extra == 'build'
- - pythran>=0.16 ; extra == 'build'
- - numpy>=2.0 ; extra == 'build'
- - spin==0.13 ; extra == 'build'
- - build>=1.2.1 ; extra == 'build'
- - pooch>=1.6.0 ; extra == 'data'
- - pre-commit ; extra == 'developer'
- - ipython ; extra == 'developer'
- - tomli ; python_full_version < '3.11' and extra == 'developer'
- - sphinx>=8.0 ; extra == 'docs'
- - sphinx-gallery[parallel]>=0.18 ; extra == 'docs'
- - numpydoc>=1.7 ; extra == 'docs'
- - sphinx-copybutton ; extra == 'docs'
- - matplotlib>=3.7 ; extra == 'docs'
- - dask[array]>=2023.2.0 ; extra == 'docs'
- - pandas>=2.0 ; extra == 'docs'
- - seaborn>=0.11 ; extra == 'docs'
- - pooch>=1.6 ; extra == 'docs'
- - tifffile>=2022.8.12 ; extra == 'docs'
- - myst-parser ; extra == 'docs'
- - intersphinx-registry>=0.2411.14 ; extra == 'docs'
- - ipywidgets ; extra == 'docs'
- - ipykernel ; extra == 'docs'
- - plotly>=5.20 ; extra == 'docs'
- - kaleido==0.2.1 ; extra == 'docs'
- - scikit-learn>=1.2 ; extra == 'docs'
- - sphinx-design>=0.5 ; extra == 'docs'
- - pydata-sphinx-theme>=0.16 ; extra == 'docs'
- - pywavelets>=1.6 ; extra == 'docs'
- - pytest-doctestplus ; extra == 'docs'
- - simpleitk ; extra == 'optional'
- - astropy>=5.0 ; extra == 'optional'
- - cloudpickle>=1.1.1 ; extra == 'optional'
- - dask[array]>=2023.2.0 ; extra == 'optional'
- - matplotlib>=3.7 ; extra == 'optional'
- - pooch>=1.6.0 ; extra == 'optional'
- - pyamg>=5.2 ; extra == 'optional'
- - pywavelets>=1.6 ; extra == 'optional'
- - scikit-learn>=1.2 ; extra == 'optional'
- - asv ; extra == 'test'
- - numpydoc>=1.7 ; extra == 'test'
- - pooch>=1.6.0 ; extra == 'test'
- - pytest>=8 ; extra == 'test'
- - pytest-cov>=2.11.0 ; extra == 'test'
- - pytest-localserver ; extra == 'test'
- - pytest-faulthandler ; extra == 'test'
- - pytest-doctestplus ; extra == 'test'
+ - cryptography>=2.0
+ - jeepney>=0.6
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/53/11/a0160990b82999b45874dc60c0c183d3a3a969a563fffc476d5a9995c407/scipy-1.16.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- name: scipy
- version: 1.16.2
- sha256: f66bd07ba6f84cd4a380b41d1bf3c59ea488b590a2ff96744845163309ee8e2f
- requires_dist:
- - numpy>=1.25.2,<2.6
- - pytest>=8.0.0 ; extra == 'test'
- - pytest-cov ; extra == 'test'
- - pytest-timeout ; extra == 'test'
- - pytest-xdist ; extra == 'test'
- - asv ; extra == 'test'
- - mpmath ; extra == 'test'
- - gmpy2 ; extra == 'test'
- - threadpoolctl ; extra == 'test'
- - scikit-umfpack ; extra == 'test'
- - pooch ; extra == 'test'
- - hypothesis>=6.30 ; extra == 'test'
- - array-api-strict>=2.3.1 ; extra == 'test'
- - cython ; extra == 'test'
- - meson ; extra == 'test'
- - ninja ; sys_platform != 'emscripten' and extra == 'test'
- - sphinx>=5.0.0,<8.2.0 ; extra == 'doc'
- - intersphinx-registry ; extra == 'doc'
- - pydata-sphinx-theme>=0.15.2 ; extra == 'doc'
- - sphinx-copybutton ; extra == 'doc'
- - sphinx-design>=0.4.0 ; extra == 'doc'
- - matplotlib>=3.5 ; extra == 'doc'
- - numpydoc ; extra == 'doc'
- - jupytext ; extra == 'doc'
- - myst-nb>=1.2.0 ; extra == 'doc'
- - pooch ; extra == 'doc'
- - jupyterlite-sphinx>=0.19.1 ; extra == 'doc'
- - jupyterlite-pyodide-kernel ; extra == 'doc'
- - linkify-it-py ; extra == 'doc'
- - mypy==1.10.0 ; extra == 'dev'
- - typing-extensions ; extra == 'dev'
- - types-psutil ; extra == 'dev'
- - pycodestyle ; extra == 'dev'
- - ruff>=0.0.292 ; extra == 'dev'
- - cython-lint>=0.12.2 ; extra == 'dev'
- - rich-click ; extra == 'dev'
- - doit>=0.36.0 ; extra == 'dev'
- - pydevtool ; extra == 'dev'
- requires_python: '>=3.11'
-- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda
- sha256: 972560fcf9657058e3e1f97186cc94389144b46dbdf58c807ce62e83f977e863
- md5: 4de79c071274a53dcaf2a8c749d1499e
+- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda
+ sha256: 82088a6e4daa33329a30bc26dc19a98c7c1d3f05c0f73ce9845d4eab4924e9e1
+ md5: 8e194e7b992f99a5015edbd4ebd38efd
depends:
- - python >=3.9
+ - python >=3.10
license: MIT
license_family: MIT
purls:
- - pkg:pypi/setuptools?source=hash-mapping
- size: 748788
- timestamp: 1748804951958
-- pypi: https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- name: shapely
- version: 2.1.2
- sha256: 1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b
- requires_dist:
- - numpy>=1.21
- - pytest ; extra == 'test'
- - pytest-cov ; extra == 'test'
- - scipy-doctest ; extra == 'test'
- - numpydoc==1.1.* ; extra == 'docs'
- - matplotlib ; extra == 'docs'
- - sphinx ; extra == 'docs'
- - sphinx-book-theme ; extra == 'docs'
- - sphinx-remove-toctrees ; extra == 'docs'
- requires_python: '>=3.10'
+ - pkg:pypi/setuptools?source=compressed-mapping
+ size: 639697
+ timestamp: 1773074868565
- pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl
name: shellingham
version: 1.5.4
@@ -3758,55 +3230,6 @@ packages:
- pytest>=7.1.0 ; extra == 'dev'
- hypothesis>=6.70.0 ; extra == 'dev'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl
- name: tenacity
- version: 9.1.2
- sha256: f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138
- requires_dist:
- - reno ; extra == 'doc'
- - sphinx ; extra == 'doc'
- - pytest ; extra == 'test'
- - tornado>=4.5 ; extra == 'test'
- - typeguard ; extra == 'test'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/6e/ff/e2f8dae90fb642b4b6f24464a2f96a3dc3b69151c51f7db24433be0f3f56/tifffile-2025.10.4-py3-none-any.whl
- name: tifffile
- version: 2025.10.4
- sha256: 7687d691e49026053181470cec70fa9250e3a586b2041041297e38b10bbd34e1
- requires_dist:
- - numpy
- - imagecodecs>=2024.12.30 ; extra == 'codecs'
- - defusedxml ; extra == 'xml'
- - lxml ; extra == 'xml'
- - zarr>=3.1.3 ; extra == 'zarr'
- - fsspec ; extra == 'zarr'
- - kerchunk ; extra == 'zarr'
- - matplotlib ; extra == 'plot'
- - imagecodecs>=2024.12.30 ; extra == 'all'
- - matplotlib ; extra == 'all'
- - defusedxml ; extra == 'all'
- - lxml ; extra == 'all'
- - zarr>=3.1.3 ; extra == 'all'
- - fsspec ; extra == 'all'
- - kerchunk ; extra == 'all'
- - cmapfile ; extra == 'test'
- - czifile ; extra == 'test'
- - dask ; extra == 'test'
- - defusedxml ; extra == 'test'
- - fsspec ; extra == 'test'
- - imagecodecs ; extra == 'test'
- - kerchunk ; extra == 'test'
- - lfdfiles ; extra == 'test'
- - lxml ; extra == 'test'
- - ndtiff ; extra == 'test'
- - oiffile ; extra == 'test'
- - psdtags ; extra == 'test'
- - pytest ; extra == 'test'
- - requests ; extra == 'test'
- - roifile ; extra == 'test'
- - xarray ; extra == 'test'
- - zarr>=3.1.3 ; extra == 'test'
- requires_python: '>=3.11'
- pypi: https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl
name: tiktoken
version: 0.12.0
@@ -3816,22 +3239,24 @@ packages:
- requests>=2.26.0
- blobfile>=2 ; extra == 'blobfile'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda
- sha256: a84ff687119e6d8752346d1d408d5cf360dee0badd487a472aa8ddedfdc219e1
- md5: a0116df4f4ed05c303811a837d5b39d8
+- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda
+ sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac
+ md5: cffd3bdd58090148f4cfcd831f4b26ab
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
+ - libgcc >=14
- libzlib >=1.3.1,<2.0a0
+ constrains:
+ - xorg-libx11 >=1.8.12,<2.0a0
license: TCL
license_family: BSD
purls: []
- size: 3285204
- timestamp: 1748387766691
-- pypi: https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ size: 3301196
+ timestamp: 1769460227866
+- pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: tokenizers
- version: 0.22.1
- sha256: e2ef6063d7a84994129732b47e7915e8710f27f99f3a3260b8a38fc7ccd083f4
+ version: 0.22.2
+ sha256: 369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67
requires_dist:
- huggingface-hub>=0.16.4,<2.0
- pytest ; extra == 'testing'
@@ -3839,17 +3264,17 @@ packages:
- requests ; extra == 'testing'
- numpy ; extra == 'testing'
- datasets ; extra == 'testing'
- - black==22.3 ; extra == 'testing'
- ruff ; extra == 'testing'
+ - ty ; extra == 'testing'
- sphinx ; extra == 'docs'
- sphinx-rtd-theme ; extra == 'docs'
- setuptools-rust ; extra == 'docs'
- tokenizers[testing] ; extra == 'dev'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/a5/4b/f4bb2e6c25d0272f798cd6d7a04ed315da76cec68c602d87040c7847287f/torch-2.9.0-cp312-cp312-manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl
name: torch
- version: 2.9.0
- sha256: 01cff95ecd9a212ea2f141db28acccdceb6a4c54f64e6c51091146f5e2a772c6
+ version: 2.10.0
+ sha256: 98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6
requires_dist:
- filelock
- typing-extensions>=4.10.0
@@ -3858,6 +3283,7 @@ packages:
- networkx>=2.5.1
- jinja2
- fsspec>=0.8.5
+ - cuda-bindings==12.9.4 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-cuda-nvrtc-cu12==12.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-cuda-runtime-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-cuda-cupti-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux'
@@ -3869,32 +3295,33 @@ packages:
- nvidia-cusparse-cu12==12.5.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-cusparselt-cu12==0.7.1 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-nccl-cu12==2.27.5 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- - nvidia-nvshmem-cu12==3.3.20 ; platform_machine == 'x86_64' and sys_platform == 'linux'
+ - nvidia-nvshmem-cu12==3.4.5 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-nvtx-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-nvjitlink-cu12==12.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-cufile-cu12==1.13.1.3 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- - triton==3.5.0 ; platform_machine == 'x86_64' and sys_platform == 'linux'
+ - triton==3.6.0 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- optree>=0.13.0 ; extra == 'optree'
- opt-einsum>=3.3 ; extra == 'opt-einsum'
- pyyaml ; extra == 'pyyaml'
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/7e/e6/7324ead6793075a8c75c56abeed1236d1750de16a5613cfe2ddad164a92a/torchvision-0.24.0-cp312-cp312-manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl
name: torchvision
- version: 0.24.0
- sha256: 26b9dd9c083f8e5f7ac827de6d5b88c615d9c582dc87666770fbdf16887e4c25
+ version: 0.25.0
+ sha256: f25aa9e380865b11ea6e9d99d84df86b9cc959f1a007cd966fc6f1ab2ed0e248
requires_dist:
- numpy
- - torch==2.9.0
+ - torch==2.10.0
- pillow>=5.3.0,!=8.3.*
- gdown>=4.7.3 ; extra == 'gdown'
- scipy ; extra == 'scipy'
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl
name: tqdm
- version: 4.67.1
- sha256: 26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2
+ version: 4.67.3
+ sha256: ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf
requires_dist:
- colorama ; sys_platform == 'win32'
+ - importlib-metadata ; python_full_version < '3.8'
- pytest>=6 ; extra == 'dev'
- pytest-cov ; extra == 'dev'
- pytest-timeout ; extra == 'dev'
@@ -3905,10 +3332,10 @@ packages:
- requests ; extra == 'telegram'
- ipywidgets>=6 ; extra == 'notebook'
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/71/d3/c16c3b3cf7655a67db1144da94b021c200ac1303f82428f2beef6c2e72bb/transformers-4.57.1-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl
name: transformers
- version: 4.57.1
- sha256: b10d05da8fa67dc41644dbbf9bc45a44cb86ae33da6f9295f5fbf5b7890bd267
+ version: 4.57.6
+ sha256: 4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550
requires_dist:
- filelock
- huggingface-hub>=0.34.0,<1.0
@@ -4368,10 +3795,10 @@ packages:
- opentelemetry-exporter-otlp ; extra == 'open-telemetry'
- opentelemetry-sdk ; extra == 'open-telemetry'
requires_python: '>=3.9.0'
-- pypi: https://files.pythonhosted.org/packages/f5/3a/e991574f3102147b642e49637e0281e9bb7c4ba254edb2bab78247c85e01/triton-3.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
name: triton
- version: 3.5.0
- sha256: c9e71db82261c4ffa3921cd050cd5faa18322d2d405c30eb56084afaff3b0833
+ version: 3.6.0
+ sha256: 74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca
requires_dist:
- importlib-metadata ; python_full_version < '3.10'
- cmake>=3.20,<4.0 ; extra == 'build'
@@ -4388,16 +3815,38 @@ packages:
- pandas ; extra == 'tutorials'
- tabulate ; extra == 'tutorials'
requires_python: '>=3.10,<3.15'
-- pypi: https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl
+ name: twine
+ version: 6.2.0
+ sha256: 418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8
+ requires_dist:
+ - readme-renderer>=35.0
+ - requests>=2.20
+ - requests-toolbelt>=0.8.0,!=0.9.0
+ - urllib3>=1.26.0
+ - importlib-metadata>=3.6 ; python_full_version < '3.10'
+ - keyring>=21.2.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'
+ - rfc3986>=1.4.0
+ - rich>=12.0.0
+ - packaging>=24.0
+ - id
+ - keyring>=21.2.0 ; extra == 'keyring'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: ty
+ version: 0.0.24
+ sha256: 7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl
name: typer
- version: 0.19.2
- sha256: 755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9
+ version: 0.24.1
+ sha256: 112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e
requires_dist:
- - click>=8.0.0
- - typing-extensions>=3.7.4.3
+ - click>=8.2.1
- shellingham>=1.3.0
- - rich>=10.11.0
- requires_python: '>=3.8'
+ - rich>=12.3.0
+ - annotated-doc>=0.0.2
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
name: typing-extensions
version: 4.15.0
@@ -4410,113 +3859,50 @@ packages:
requires_dist:
- typing-extensions>=4.12.0
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
- name: tzdata
- version: '2025.2'
- sha256: 1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8
- requires_python: '>=2'
-- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
- sha256: 5aaa366385d716557e365f0a4e9c3fca43ba196872abbbe3d56bb610d131e192
- md5: 4222072737ccff51314b5ece9c7d6f5a
+- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda
+ sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c
+ md5: ad659d0a2b3e47e38d829aa8cad2d610
license: LicenseRef-Public-Domain
purls: []
- size: 122968
- timestamp: 1742727099393
-- pypi: https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl
- name: uritemplate
- version: 4.2.0
- sha256: 962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ size: 119135
+ timestamp: 1767016325805
+- pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl
name: urllib3
- version: 2.5.0
- sha256: e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc
+ version: 2.6.3
+ sha256: bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4
requires_dist:
- - brotli>=1.0.9 ; platform_python_implementation == 'CPython' and extra == 'brotli'
- - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'brotli'
+ - brotli>=1.2.0 ; platform_python_implementation == 'CPython' and extra == 'brotli'
+ - brotlicffi>=1.2.0.0 ; platform_python_implementation != 'CPython' and extra == 'brotli'
- h2>=4,<5 ; extra == 'h2'
- pysocks>=1.5.6,!=1.5.7,<2.0 ; extra == 'socks'
- - zstandard>=0.18.0 ; extra == 'zstd'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl
- name: virtualenv
- version: 20.35.3
- sha256: 63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a
- requires_dist:
- - distlib>=0.3.7,<1
- - filelock>=3.12.2,<4
- - importlib-metadata>=6.6 ; python_full_version < '3.8'
- - platformdirs>=3.9.1,<5
- - typing-extensions>=4.13.2 ; python_full_version < '3.11'
- - furo>=2023.7.26 ; extra == 'docs'
- - proselint>=0.13 ; extra == 'docs'
- - sphinx>=7.1.2,!=7.3 ; extra == 'docs'
- - sphinx-argparse>=0.4 ; extra == 'docs'
- - sphinxcontrib-towncrier>=0.2.1a0 ; extra == 'docs'
- - towncrier>=23.6 ; extra == 'docs'
- - covdefaults>=2.3 ; extra == 'test'
- - coverage-enable-subprocess>=1 ; extra == 'test'
- - coverage>=7.2.7 ; extra == 'test'
- - flaky>=3.7 ; extra == 'test'
- - packaging>=23.1 ; extra == 'test'
- - pytest-env>=0.8.2 ; extra == 'test'
- - pytest-freezer>=0.4.8 ; (python_full_version >= '3.13' and platform_python_implementation == 'CPython' and sys_platform == 'win32' and extra == 'test') or (platform_python_implementation == 'GraalVM' and extra == 'test') or (platform_python_implementation == 'PyPy' and extra == 'test')
- - pytest-mock>=3.11.1 ; extra == 'test'
- - pytest-randomly>=3.12 ; extra == 'test'
- - pytest-timeout>=2.1 ; extra == 'test'
- - pytest>=7.4 ; extra == 'test'
- - setuptools>=68 ; extra == 'test'
- - time-machine>=2.10 ; platform_python_implementation == 'CPython' and extra == 'test'
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- name: websockets
- version: 15.0.1
- sha256: 64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65
+ - backports-zstd>=1.0.0 ; python_full_version < '3.14' and extra == 'zstd'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda
- sha256: 1b34021e815ff89a4d902d879c3bd2040bc1bd6169b32e9427497fa05c55f1ce
- md5: 75cb7132eb58d97896e173ef12ac9986
+- conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda
+ sha256: d6cf2f0ebd5e09120c28ecba450556ce553752652d91795442f0e70f837126ae
+ md5: bdbd7385b4a67025ac2dba4ef8cb6a8f
depends:
- - python >=3.9
+ - packaging >=24.0
+ - python >=3.10
license: MIT
license_family: MIT
purls:
- pkg:pypi/wheel?source=hash-mapping
- size: 62931
- timestamp: 1733130309598
-- pypi: https://files.pythonhosted.org/packages/dc/1a/81ac398d35be848f3655893b6260f227b29ca6acf5c2674dc5ed9af63027/xmlschema-4.2.0-py3-none-any.whl
- name: xmlschema
- version: 4.2.0
- sha256: 82d24a50eea5e7f2d603312813848cd66fddf8fa2b6730839c6aa3d66312e3b6
- requires_dist:
- - elementpath>=5.0.1,<6.0.0
- - jinja2 ; extra == 'codegen'
- - coverage ; extra == 'dev'
- - flake8 ; extra == 'dev'
- - lxml ; extra == 'dev'
- - lxml-stubs ; extra == 'dev'
- - mypy ; extra == 'dev'
- - psutil ; extra == 'dev'
- - tox ; extra == 'dev'
- - xmlschema[docs] ; extra == 'dev'
- - jinja2 ; extra == 'docs'
- - sphinx ; extra == 'docs'
- - sphinx-rtd-theme ; extra == 'docs'
- requires_python: '>=3.9'
+ size: 31858
+ timestamp: 1769139207397
- pypi: https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: xxhash
version: 3.6.0
sha256: 49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: yarl
- version: 1.22.0
- sha256: 50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a
+ version: 1.23.0
+ sha256: a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51
requires_dist:
- idna>=2.0
- multidict>=4.0
- propcache>=0.2.1
- requires_python: '>=3.9'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl
name: zipp
version: 3.23.0
@@ -4541,16 +3927,14 @@ packages:
- pytest-enabler>=2.2 ; extra == 'enabler'
- pytest-mypy ; extra == 'type'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda
- sha256: a4166e3d8ff4e35932510aaff7aa90772f84b4d07e9f6f83c614cba7ceefe0eb
- md5: 6432cb5d4ac0046c3ac0a8a0f95842f9
+- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda
+ sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7
+ md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
- - libstdcxx >=13
- libzlib >=1.3.1,<2.0a0
license: BSD-3-Clause
license_family: BSD
purls: []
- size: 567578
- timestamp: 1742433379869
+ size: 601375
+ timestamp: 1764777111296
diff --git a/pixi.toml b/pixi.toml
deleted file mode 100644
index e20dcf7..0000000
--- a/pixi.toml
+++ /dev/null
@@ -1,92 +0,0 @@
-#############################################
-# Churro pixi manifest
-#############################################
-
-[workspace]
- # Use conda-forge as primary channel; Linux x64 only
- channels = ["conda-forge", "main", "r", "msys2"]
- platforms = ["linux-64"]
-
-[tasks]
- # Custom task definitions (currently empty; add build/run tasks as needed)
-
-[dependencies]
- # Base conda packages shared by all environments
- python = "3.12.*"
- pip = "*"
-
-[feature.full.dependencies]
- cudnn = "9.*"
- cuda-libraries = "12.*"
-
-[feature.full.pypi-dependencies]
- # Local package for iterative development
- churro = { path = ".", editable = true }
-
- # Development tools
- ruff = ">=0.11.13,<0.12" # Linting and formatting (see ruff.toml)
- pre-commit = "*" # Git hooks for code quality
- pytest = "*" # Testing framework
- pytest-asyncio = "*" # Async test support
- coverage = ">=7.11.0, <8" # Code coverage measurement
- pytest-cov = ">=7.0.0, <8" # Coverage plugin for pytest
-
- # Utilities
- tqdm = "*" # Progress bars
- tenacity = "*" # Retry logic for API calls
- diskcache = "*" # Local caching for API responses
- rich = "*" # Beautiful terminal outputs
- loguru = "*" # Logging (used in utils.log_utils)
- python-dotenv = ">=1.1.0, <2" # Environment variable loading
-
- # Geometric/spatial operations
- rtree = "*" # Spatial indexing for bounding boxes
- shapely = "*" # Polygon operations for page elements
-
- # PDF and image processing
- pymupdf = "*" # PDF rasterization (utils/pdf/runner.py)
- Pillow = "*" # Image manipulation
- scikit-image = ">=0.25.2, <0.26" # Advanced image processing
-
- # OCR provider SDKs
- azure-ai-documentintelligence = "==1.0.2" # Azure Document Intelligence OCR
- google-cloud-documentai = "==3.6.0" # Google Document AI OCR
- mistralai = ">=1.6.0, <2" # Mistral OCR system
- google-api-python-client = ">=2.161.0, <3" # Google Cloud APIs
- google-cloud-aiplatform = ">=1.85.0, <2" # Vertex AI (Gemini models)
- anthropic = ">=0.49.0, <0.50" # Claude models
- litellm = "==1.78.0" # Unified LLM API (pinned for stability)
- aiofiles = ">=24.1.0, <25" # Async file operations
-
- # Evaluation and metrics
- evaluate = "*" # HuggingFace evaluate library
- nltk = ">=3.9.1, <4" # NLP preprocessing for metrics
- rouge-score = "*" # ROUGE metric computation
- rapidfuzz = ">=3.12.1, <4" # Fast string processing for Levenshtein similarity metric
- pyarabic = ">=0.6.15, <0.7" # Arabic text normalization
-
- # Data loading
- datasets = "*" # HuggingFace datasets (Churro dataset)
-
- # XML validation and parsing
- xmlschema = ">=4.0.1, <5" # Schema validation (historical_doc.xsd)
- lxml = "*" # Fast XML processing
-
- # Container management, for vLLM orchestration
- docker = "*" # Docker SDK for vLLM container orchestration
-
- # CLI utilities
- typer = ">=0.19.2, <0.20" # CLI utilities (used in churro/cli.py)
-
- onnxruntime-gpu = ">=1.23.1, <2" # CUDA-enabled ONNX Runtime build. Used for ImageBinarizer model inference.
-
-
-[feature.minimal.pypi-dependencies]
- # Minimal dependencies to run inference with transformers instead of vLLM
- transformers = { version = ">=4.57.0, <5", extras = ["torch"] }
- Pillow = "*"
- torchvision = "*"
-
-[environments]
- default = ["full"]
- minimal = ["minimal"]
diff --git a/pyproject.toml b/pyproject.toml
index adf18bd..ff75f3a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,15 +1,159 @@
[build-system]
-requires = ["setuptools>=61"]
+requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
-name = "churro"
+name = "churro-ocr"
version = "0.1.0"
-description = "Historical OCR tooling"
+description = "Library-first OCR and layout detection for historical documents"
+readme = "README.md"
requires-python = ">=3.12"
-dependencies = []
+license = "Apache-2.0"
+authors = [{ name = "History Genie" }]
+keywords = ["ocr", "layout-detection", "historical-documents", "llm"]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Typing :: Typed",
+]
+dependencies = [
+ "loguru>=0.7.2,<1",
+ "Pillow>=10.4.0,<12",
+ "rich>=13.9.2,<14",
+ "typer>=0.12.3,<1",
+]
+
+[project.optional-dependencies]
+llm = [
+ "google-auth>=2.41.1,<3",
+ "litellm[caching]==1.82.3",
+]
+azure = [
+ "azure-ai-documentintelligence==1.0.2",
+]
+huggingface = [
+ "qwen-vl-utils",
+ "transformers[torch]>=4.57.0,<5",
+ "torchvision",
+]
+hf = [
+ "qwen-vl-utils",
+ "transformers[torch]>=4.57.0,<5",
+ "torchvision",
+]
+vllm = [
+ "transformers>=4.57.0,<5",
+ "torchvision",
+ "vllm>=0.18,<1",
+]
+mistral = [
+ "mistralai>=1.6.0,<2",
+]
+local = [
+ "litellm[caching]==1.82.3",
+]
+pdf = [
+ "pypdfium2>=5,<6",
+]
+all = [
+ "google-auth>=2.41.1,<3",
+ "litellm[caching]==1.82.3",
+ "azure-ai-documentintelligence==1.0.2",
+ "qwen-vl-utils",
+ "transformers[torch]>=4.57.0,<5",
+ "torchvision",
+ "vllm>=0.18,<1",
+ "mistralai>=1.6.0,<2",
+ "pypdfium2>=5,<6",
+]
+
+[dependency-groups]
+dev = [
+ "build",
+ "coverage>=7.11.0,<8",
+ "datasets>=4.4.1,<5",
+ "evaluate>=0.4.6,<0.5",
+ "google-auth>=2.41.1,<3",
+ "nltk>=3.9.2,<4",
+ "pyarabic>=0.6.15,<0.7",
+ "pytest",
+ "pytest-asyncio",
+ "pytest-cov>=7.0.0,<8",
+ "rapidfuzz>=3.14.3,<4",
+ "ruff",
+ "tqdm>=4.67.1,<5",
+ "twine",
+ "ty>=0.0.24,<0.0.25",
+]
+minimal = [
+ "qwen-vl-utils",
+ "transformers[torch]>=4.57.0,<5",
+ "torchvision",
+]
+
+[project.scripts]
+churro-ocr = "churro_ocr.cli:main"
+
+[project.urls]
+Homepage = "https://github.com/stanford-oval/Churro"
+Repository = "https://github.com/stanford-oval/Churro"
+Issues = "https://github.com/stanford-oval/Churro/issues"
[tool.setuptools]
-packages = ["churro"]
-package-dir = { "churro" = "." }
-include-package-data = true
\ No newline at end of file
+package-dir = { "" = "src" }
+include-package-data = true
+
+[tool.setuptools.packages.find]
+where = ["src"]
+include = ["churro_ocr*"]
+
+[tool.setuptools.package-data]
+"churro_ocr" = ["py.typed"]
+
+[tool.pixi.workspace]
+channels = ["conda-forge", "main", "r", "msys2"]
+name = "churro-ocr"
+platforms = ["linux-64"]
+
+[tool.pixi.dependencies]
+pip = "*"
+
+[tool.pixi.feature.repo-default.pypi-dependencies]
+"churro-ocr" = { path = ".", editable = true, extras = ["llm", "azure", "hf", "mistral", "pdf"] }
+
+[tool.pixi.feature.repo-minimal.pypi-dependencies]
+"churro-ocr" = { path = ".", editable = true, extras = ["hf"] }
+
+[tool.pixi.feature.cuda.dependencies]
+cudnn = "9.*"
+cuda-libraries = "12.*"
+
+[tool.pixi.environments]
+default = { features = ["repo-default", "dev"], solve-group = "default" }
+minimal = { features = ["dev", "repo-minimal"], solve-group = "default" }
+cuda = { features = ["repo-default", "cuda", "dev"], solve-group = "default" }
+
+[tool.pixi.tasks]
+format = "ruff format src tests"
+lint = "ruff check src tests"
+typecheck = "ty check src tests"
+test = "python -m pytest"
+coverage = "python -m pytest --cov=src/churro_ocr --cov=tooling --cov-report=term-missing"
+package-check = "python scripts/package_check.py"
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+asyncio_mode = "auto"
+markers = [
+ "integration: live provider integration tests that require network and credentials",
+]
+
+[tool.ruff]
+target-version = "py312"
+line-length = 110
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "B", "UP", "ASYNC", "SIM"]
+
+[tool.ruff.lint.per-file-ignores]
+"src/churro_ocr/cli.py" = ["B008"]
diff --git a/scripts/package_check.py b/scripts/package_check.py
new file mode 100644
index 0000000..682a193
--- /dev/null
+++ b/scripts/package_check.py
@@ -0,0 +1,308 @@
+"""Build, inspect, and smoke-test churro-ocr publish artifacts."""
+
+from __future__ import annotations
+
+import re
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+import zipfile
+from email import message_from_string
+from email.message import Message
+from importlib import metadata
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+DIST_DIR = ROOT / "dist"
+BUILD_DIR = ROOT / "build"
+EGG_INFO_DIR = ROOT / "src" / "churro_ocr.egg-info"
+EXPECTED_PROJECT_URLS = {
+ "Homepage": "https://github.com/stanford-oval/Churro",
+ "Repository": "https://github.com/stanford-oval/Churro",
+ "Issues": "https://github.com/stanford-oval/Churro/issues",
+}
+EXPECTED_EXTRAS = {
+ "all",
+ "azure",
+ "hf",
+ "huggingface",
+ "llm",
+ "local",
+ "mistral",
+ "pdf",
+ "vllm",
+}
+FORBIDDEN_ARTIFACT_SEGMENTS = ("/tests/", "/tooling/", "/scripts/")
+FORBIDDEN_ARTIFACT_SUFFIXES = ("REPO_WORKFLOWS.md", "PYPI_AUDIT.md")
+REQUIREMENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+")
+ALLOWED_LICENSE_TOKENS = (
+ "apache",
+ "bsd",
+ "isc",
+ "mit",
+ "mozilla public license",
+ "mpl-2.0",
+ "python software foundation",
+)
+INCOMPATIBLE_LICENSE_TOKENS = (
+ "agpl",
+ "commercial",
+ "gnu affero",
+ "gpl",
+ "lgpl",
+)
+
+
+def _run(*args: str, cwd: Path | None = None) -> str:
+ completed = subprocess.run(
+ list(args),
+ check=True,
+ cwd=cwd or ROOT,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ return completed.stdout
+
+
+def _remove_if_exists(path: Path) -> None:
+ if path.is_dir():
+ shutil.rmtree(path)
+ return
+ if path.exists():
+ path.unlink()
+
+
+def _clean_build_artifacts() -> None:
+ for path in (BUILD_DIR, DIST_DIR, EGG_INFO_DIR):
+ _remove_if_exists(path)
+
+
+def _build_distributions() -> tuple[Path, Path]:
+ _run(sys.executable, "-m", "build", cwd=ROOT)
+ wheel = next(DIST_DIR.glob("*.whl"), None)
+ sdist = next(DIST_DIR.glob("*.tar.gz"), None)
+ if wheel is None or sdist is None:
+ raise RuntimeError("Expected both wheel and sdist artifacts in dist/.")
+ return wheel, sdist
+
+
+def _read_wheel_metadata(wheel: Path) -> tuple[Message, str]:
+ with zipfile.ZipFile(wheel) as zip_file:
+ metadata_name = next(name for name in zip_file.namelist() if name.endswith(".dist-info/METADATA"))
+ entry_points_name = next(
+ name for name in zip_file.namelist() if name.endswith(".dist-info/entry_points.txt")
+ )
+ metadata_message = message_from_string(zip_file.read(metadata_name).decode("utf-8"))
+ entry_points_text = zip_file.read(entry_points_name).decode("utf-8")
+ return metadata_message, entry_points_text
+
+
+def _twine_check(wheel: Path, sdist: Path) -> None:
+ _run(sys.executable, "-m", "twine", "check", str(wheel), str(sdist), cwd=ROOT)
+
+
+def _assert_metadata(metadata_message: Message, entry_points_text: str) -> None:
+ name = metadata_message["Name"]
+ if name != "churro-ocr":
+ raise RuntimeError(f"Unexpected package name {name!r}.")
+ if metadata_message["Requires-Python"] != ">=3.12":
+ raise RuntimeError("Requires-Python metadata no longer matches the documented support policy.")
+
+ project_urls: dict[str, str] = {}
+ for raw_value in metadata_message.get_all("Project-URL", []):
+ label, value = raw_value.split(", ", maxsplit=1)
+ project_urls[label] = value
+ if project_urls != EXPECTED_PROJECT_URLS:
+ raise RuntimeError(f"Project URLs do not match the expected package repository: {project_urls!r}.")
+
+ provides_extra = set(metadata_message.get_all("Provides-Extra", []))
+ if provides_extra != EXPECTED_EXTRAS:
+ raise RuntimeError(f"Unexpected extras set: {sorted(provides_extra)!r}.")
+
+ requires_dist = metadata_message.get_all("Requires-Dist", [])
+ if not any(
+ requirement.startswith("vllm") and 'extra == "all"' in requirement for requirement in requires_dist
+ ):
+ raise RuntimeError("The all extra does not include vllm.")
+
+ if (
+ "[console_scripts]" not in entry_points_text
+ or "churro-ocr = churro_ocr.cli:main" not in entry_points_text
+ ):
+ raise RuntimeError("Console script entry point is missing or incorrect.")
+
+
+def _assert_runtime_only_artifacts(wheel: Path, sdist: Path) -> None:
+ with zipfile.ZipFile(wheel) as zip_file:
+ wheel_names = zip_file.namelist()
+ for name in wheel_names:
+ normalized = f"/{name}"
+ if any(segment in normalized for segment in FORBIDDEN_ARTIFACT_SEGMENTS):
+ raise RuntimeError(f"Wheel unexpectedly includes repo-only content: {name}")
+ if any(normalized.endswith(suffix) for suffix in FORBIDDEN_ARTIFACT_SUFFIXES):
+ raise RuntimeError(f"Wheel unexpectedly includes repo-only documentation: {name}")
+
+ with tarfile.open(sdist) as tar_file:
+ sdist_names = tar_file.getnames()
+ for name in sdist_names:
+ normalized = f"/{name}"
+ if any(segment in normalized for segment in FORBIDDEN_ARTIFACT_SEGMENTS):
+ raise RuntimeError(f"sdist unexpectedly includes repo-only content: {name}")
+ if any(normalized.endswith(suffix) for suffix in FORBIDDEN_ARTIFACT_SUFFIXES):
+ raise RuntimeError(f"sdist unexpectedly includes repo-only documentation: {name}")
+
+
+def _venv_python(venv_dir: Path) -> Path:
+ return venv_dir / "bin" / "python"
+
+
+def _smoke_install(requirement: str, *, label: str, import_check: str) -> None:
+ with tempfile.TemporaryDirectory(prefix=f"churro-{label}-") as temp_dir:
+ temp_path = Path(temp_dir)
+ venv_dir = temp_path / "venv"
+ workspace_dir = temp_path / "workspace"
+ workspace_dir.mkdir()
+
+ _run(sys.executable, "-m", "venv", str(venv_dir), cwd=workspace_dir)
+ python = _venv_python(venv_dir)
+ _run(str(python), "-m", "pip", "install", "--upgrade", "pip", cwd=workspace_dir)
+ _run(str(python), "-m", "pip", "install", requirement, cwd=workspace_dir)
+ _run(str(python), "-c", import_check, cwd=workspace_dir)
+ if (workspace_dir / "debug.log").exists():
+ raise RuntimeError(f"{label} created an unexpected debug.log file.")
+ _run(str(python), "-m", "churro_ocr", "--help", cwd=workspace_dir)
+ if (workspace_dir / "debug.log").exists():
+ raise RuntimeError(f"{label} CLI help created an unexpected debug.log file.")
+
+
+def _requirement_name(requirement: str) -> str | None:
+ match = REQUIREMENT_NAME_PATTERN.match(requirement)
+ if match is None:
+ return None
+ return match.group(0).replace("_", "-").lower()
+
+
+def _read_distribution_license_text(distribution: metadata.Distribution) -> str:
+ metadata_message = distribution.metadata
+ parts: list[str] = []
+
+ for key in ("License-Expression", "License"):
+ parts.extend(value for value in metadata_message.get_all(key, []) if value)
+ parts.extend(
+ classifier
+ for classifier in metadata_message.get_all("Classifier", [])
+ if classifier.startswith("License ::")
+ )
+
+ for license_file in metadata_message.get_all("License-File", []):
+ path = distribution.locate_file(license_file)
+ if path.exists():
+ parts.append(path.read_text(errors="ignore")[:4_000])
+
+ if parts:
+ return "\n".join(parts).lower()
+
+ for file_path in distribution.files or []:
+ lowered = str(file_path).lower()
+ if "license" not in lowered and "copying" not in lowered:
+ continue
+ path = distribution.locate_file(file_path)
+ if path.exists():
+ parts.append(path.read_text(errors="ignore")[:4_000])
+ return "\n".join(parts).lower()
+
+
+def _audit_dependency_licenses(metadata_message: Message) -> None:
+ direct_dependencies = {
+ name
+ for requirement in metadata_message.get_all("Requires-Dist", [])
+ for name in [_requirement_name(requirement)]
+ if name is not None
+ }
+ incompatible: list[str] = []
+ unknown: list[str] = []
+ for dependency_name in sorted(direct_dependencies):
+ try:
+ distribution = metadata.distribution(dependency_name)
+ except metadata.PackageNotFoundError:
+ unknown.append(f"{dependency_name} (not installed in the Pixi audit environment)")
+ continue
+
+ license_text = _read_distribution_license_text(distribution)
+ if any(token in license_text for token in INCOMPATIBLE_LICENSE_TOKENS):
+ incompatible.append(f"{dependency_name}=={distribution.version}")
+ continue
+ if any(token in license_text for token in ALLOWED_LICENSE_TOKENS):
+ continue
+ unknown.append(f"{dependency_name}=={distribution.version}")
+
+ if incompatible:
+ raise RuntimeError(
+ "Incompatible direct dependency licenses detected: " + ", ".join(incompatible) + "."
+ )
+ if unknown:
+ raise RuntimeError("Unknown direct dependency licenses detected: " + ", ".join(unknown) + ".")
+
+
+def main() -> int:
+ print("==> Cleaning build artifacts")
+ _clean_build_artifacts()
+
+ print("==> Building wheel and sdist")
+ wheel, sdist = _build_distributions()
+
+ print("==> Running twine check")
+ _twine_check(wheel, sdist)
+
+ print("==> Validating wheel metadata")
+ metadata_message, entry_points_text = _read_wheel_metadata(wheel)
+ _assert_metadata(metadata_message, entry_points_text)
+
+ print("==> Validating artifact contents")
+ _assert_runtime_only_artifacts(wheel, sdist)
+
+ print("==> Smoke-testing base wheel install")
+ _smoke_install(
+ wheel.resolve().as_uri(),
+ label="wheel",
+ import_check=(
+ "import churro_ocr; import churro_ocr.providers as providers; "
+ "assert hasattr(providers, 'OCRBackendSpec')"
+ ),
+ )
+
+ print("==> Smoke-testing base sdist install")
+ _smoke_install(
+ sdist.resolve().as_uri(),
+ label="sdist",
+ import_check=(
+ "import churro_ocr; import churro_ocr.providers as providers; "
+ "assert hasattr(providers, 'OCRBackendSpec')"
+ ),
+ )
+
+ print("==> Smoke-testing lightweight extras")
+ wheel_uri = wheel.resolve().as_uri()
+ _smoke_install(
+ f"churro-ocr[local] @ {wheel_uri}",
+ label="wheel-local",
+ import_check="import churro_ocr; import litellm",
+ )
+ _smoke_install(
+ f"churro-ocr[pdf] @ {wheel_uri}",
+ label="wheel-pdf",
+ import_check="import churro_ocr; import pypdfium2",
+ )
+
+ print("==> Auditing direct dependency licenses")
+ _audit_dependency_licenses(metadata_message)
+
+ print("==> package-check passed")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/sitecustomize.py b/sitecustomize.py
new file mode 100644
index 0000000..91953b0
--- /dev/null
+++ b/sitecustomize.py
@@ -0,0 +1,13 @@
+"""Prefer the repo-local `src/` package tree over unrelated site-packages installs."""
+
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+
+_REPO_SRC = Path(__file__).resolve().parent / "src"
+_REPO_SRC_STR = str(_REPO_SRC)
+
+if _REPO_SRC_STR in sys.path:
+ sys.path.remove(_REPO_SRC_STR)
+sys.path.insert(0, _REPO_SRC_STR)
diff --git a/src/churro_ocr/__init__.py b/src/churro_ocr/__init__.py
new file mode 100644
index 0000000..3d9fc09
--- /dev/null
+++ b/src/churro_ocr/__init__.py
@@ -0,0 +1,45 @@
+"""Library-first public API for churro-ocr."""
+
+from churro_ocr.document import DocumentOCRPipeline, DocumentOCRResult
+from churro_ocr.errors import ChurroError, ConfigurationError, ProviderError
+from churro_ocr.ocr import BatchOCRBackend, OCRBackend, OCRClient, OCRResult
+from churro_ocr.page_detection import (
+ DocumentPage,
+ DocumentPageDetector,
+ PageCandidate,
+ PageDetectionBackend,
+ PageDetectionRequest,
+ PageDetectionResult,
+ PageDetector,
+)
+from churro_ocr.templates import (
+ CHURRO_3B_MODEL_ID,
+ CHURRO_3B_XML_TEMPLATE,
+ DEFAULT_OCR_TEMPLATE,
+ HFChatTemplate,
+ OCRPromptTemplate,
+)
+
+__all__ = [
+ "CHURRO_3B_MODEL_ID",
+ "CHURRO_3B_XML_TEMPLATE",
+ "BatchOCRBackend",
+ "ChurroError",
+ "ConfigurationError",
+ "DocumentPage",
+ "DocumentOCRPipeline",
+ "DocumentOCRResult",
+ "DocumentPageDetector",
+ "DEFAULT_OCR_TEMPLATE",
+ "HFChatTemplate",
+ "OCRPromptTemplate",
+ "OCRBackend",
+ "OCRClient",
+ "OCRResult",
+ "PageDetectionBackend",
+ "PageDetector",
+ "PageCandidate",
+ "PageDetectionRequest",
+ "PageDetectionResult",
+ "ProviderError",
+]
diff --git a/src/churro_ocr/__main__.py b/src/churro_ocr/__main__.py
new file mode 100644
index 0000000..570c9d8
--- /dev/null
+++ b/src/churro_ocr/__main__.py
@@ -0,0 +1,6 @@
+"""Module entrypoint for `python -m churro_ocr`."""
+
+from churro_ocr.cli import main
+
+if __name__ == "__main__":
+ main()
diff --git a/src/churro_ocr/_internal/__init__.py b/src/churro_ocr/_internal/__init__.py
new file mode 100644
index 0000000..219a569
--- /dev/null
+++ b/src/churro_ocr/_internal/__init__.py
@@ -0,0 +1 @@
+"""Private implementation helpers for the churro-ocr package."""
diff --git a/src/churro_ocr/_internal/image.py b/src/churro_ocr/_internal/image.py
new file mode 100644
index 0000000..7a915a8
--- /dev/null
+++ b/src/churro_ocr/_internal/image.py
@@ -0,0 +1,61 @@
+"""Image loading, encoding, and normalization helpers."""
+
+from __future__ import annotations
+
+import base64
+from io import BytesIO
+from pathlib import Path
+
+from PIL import Image, ImageOps
+
+from churro_ocr.errors import ConfigurationError
+
+MAX_INLINE_IMAGE_DIM = 2_500
+
+
+def load_image(path: str | Path) -> Image.Image:
+ """Load an image from disk and normalize EXIF orientation."""
+ resolved = Path(path)
+ if not resolved.exists():
+ raise ConfigurationError(f"Image path does not exist: {resolved}")
+ with Image.open(resolved) as image:
+ normalized = ImageOps.exif_transpose(image)
+ assert normalized is not None
+ return normalized.copy()
+
+
+def ensure_rgb(image: Image.Image) -> Image.Image:
+ """Return an RGB image copy when needed."""
+ if image.mode == "RGB":
+ return image.copy()
+ return image.convert("RGB")
+
+
+def resize_image_to_fit(image: Image.Image, max_width: int, max_height: int) -> Image.Image:
+ """Resize an image to fit within the provided bounds."""
+ width, height = image.size
+ if width <= max_width and height <= max_height:
+ return image
+ scale = min(max_width / width, max_height / height)
+ return image.resize(
+ (max(1, int(width * scale)), max(1, int(height * scale))),
+ resample=Image.Resampling.LANCZOS,
+ )
+
+
+def prepare_ocr_image(image: Image.Image) -> Image.Image:
+ """Normalize and resize an image for OCR provider transport."""
+ return ensure_rgb(resize_image_to_fit(image, MAX_INLINE_IMAGE_DIM, MAX_INLINE_IMAGE_DIM))
+
+
+def image_to_base64(image: Image.Image, format_name: str | None = None) -> tuple[str, str]:
+ """Encode an image for provider transport."""
+ resolved_format = (format_name or image.format or "PNG").upper()
+ if resolved_format not in {"PNG", "JPEG", "WEBP"}:
+ resolved_format = "PNG"
+ mime_type = f"image/{resolved_format.lower()}"
+ buffer = BytesIO()
+ save_kwargs = {"quality": 95, "optimize": True} if resolved_format == "JPEG" else {}
+ image.save(buffer, format=resolved_format, **save_kwargs)
+ encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
+ return mime_type, encoded
diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py
new file mode 100644
index 0000000..df2455d
--- /dev/null
+++ b/src/churro_ocr/_internal/litellm.py
@@ -0,0 +1,356 @@
+"""Minimal LiteLLM wrapper with shared transport configuration."""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Sequence
+from contextlib import suppress
+from importlib import import_module
+from pathlib import Path
+from typing import Any, cast
+
+from PIL import Image
+
+from churro_ocr._internal.image import image_to_base64
+from churro_ocr.errors import ConfigurationError, ProviderError
+from churro_ocr.providers.specs import LiteLLMTransportConfig
+from churro_ocr.templates import OCRConversation
+
+_INITIALIZED = False
+_DISK_CACHE_DIR: str | None = None
+
+
+def _ensure_initialized() -> None:
+ global _INITIALIZED
+ if _INITIALIZED:
+ return
+ try:
+ import litellm
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ "LiteLLM-backed providers require the 'llm' extra. "
+ 'Install with `pip install "churro-ocr[llm]"`.'
+ ) from exc
+
+ litellm.turn_off_message_logging = True
+ litellm.success_callback = []
+ litellm.failure_callback = []
+ with suppress(Exception):
+ litellm._logging._logged_requests = [] # type: ignore[attr-defined]
+ litellm_any = cast("Any", litellm)
+ litellm_any.drop_params = True
+ litellm_any.suppress_debug_info = True
+ litellm_any.set_verbose = False
+
+ for logger_name in ("LiteLLM", "litellm", "LiteLLM Router", "LiteLLM Proxy"):
+ provider_logger = logging.getLogger(logger_name)
+ provider_logger.setLevel(logging.WARNING)
+ provider_logger.propagate = False
+
+ try: # pragma: no cover - defensive against LiteLLM internal changes
+ logging_worker = import_module("litellm.litellm_core_utils.logging_worker")
+ global_logging_worker = getattr(logging_worker, "GLOBAL_LOGGING_WORKER", None)
+ if global_logging_worker is not None:
+ original_enqueue = global_logging_worker.ensure_initialized_and_enqueue
+
+ def _enqueue_if_enabled(async_coroutine: object) -> None:
+ if getattr(litellm, "turn_off_message_logging", False):
+ with suppress(Exception):
+ async_coroutine.close() # type: ignore[attr-defined]
+ return
+ original_enqueue(async_coroutine)
+
+ global_logging_worker.ensure_initialized_and_enqueue = _enqueue_if_enabled
+ except Exception:
+ pass
+
+ _INITIALIZED = True
+
+
+def configure_disk_cache(*, disk_cache_dir: str | Path) -> None:
+ """Enable LiteLLM disk caching for subsequent requests."""
+ global _DISK_CACHE_DIR
+
+ _ensure_initialized()
+
+ from litellm import cache, input_callback
+ from litellm.caching.caching import enable_cache, update_cache
+
+ cache_dir = str(Path(disk_cache_dir).expanduser().resolve())
+ Path(cache_dir).mkdir(parents=True, exist_ok=True)
+
+ if cache_dir == _DISK_CACHE_DIR and cache is not None:
+ return
+
+ cache_kwargs: dict[str, Any] = {
+ "type": "disk",
+ "disk_cache_dir": cache_dir,
+ }
+ if cache is None or "cache" not in input_callback:
+ enable_cache(**cache_kwargs)
+ else:
+ update_cache(**cache_kwargs)
+
+ _DISK_CACHE_DIR = cache_dir
+
+
+class LiteLLMTransport:
+ """Shared LiteLLM transport for OCR and LLM page detection."""
+
+ def __init__(self, config: LiteLLMTransportConfig | None = None) -> None:
+ self._config = config or LiteLLMTransportConfig()
+ self._last_cost_usd: float | None = None
+ self._total_cost_usd: float = 0.0
+ self._request_count: int = 0
+ self._untracked_request_count: int = 0
+
+ @property
+ def config(self) -> LiteLLMTransportConfig:
+ """Return the transport config."""
+ return self._config
+
+ @property
+ def last_cost_usd(self) -> float | None:
+ """Return the cost of the most recent successful request, when available."""
+ return self._last_cost_usd
+
+ @property
+ def total_cost_usd(self) -> float:
+ """Return the cumulative cost of all successfully costed requests."""
+ return self._total_cost_usd
+
+ @property
+ def request_count(self) -> int:
+ """Return the number of successful completion requests made by this transport."""
+ return self._request_count
+
+ @property
+ def untracked_request_count(self) -> int:
+ """Return the number of successful requests whose cost could not be determined."""
+ return self._untracked_request_count
+
+ def prepare_messages(
+ self,
+ *,
+ system_prompt: str | None,
+ user_prompt: str | None,
+ images: Sequence[Image.Image] | None = None,
+ ) -> list[dict[str, Any]]:
+ """Build chat-style messages for multimodal provider calls."""
+ return _prepare_messages(
+ system_prompt=system_prompt,
+ user_prompt=user_prompt,
+ images=images,
+ image_detail=self._resolved_image_detail(),
+ )
+
+ def prepare_messages_from_conversation(
+ self,
+ conversation: OCRConversation,
+ ) -> list[dict[str, Any]]:
+ """Convert an OCR conversation into LiteLLM/OpenAI-style messages."""
+ return _prepare_messages_from_conversation(
+ conversation,
+ image_detail=self._resolved_image_detail(),
+ )
+
+ async def complete_text(
+ self,
+ *,
+ model: str,
+ messages: list[dict[str, Any]],
+ timeout_seconds: int = 600,
+ output_json: bool = False,
+ ) -> str:
+ """Run a LiteLLM completion and return the text content."""
+ if self._config.cache_dir is not None:
+ configure_disk_cache(disk_cache_dir=self._config.cache_dir)
+
+ _ensure_initialized()
+ from litellm import acompletion
+
+ kwargs: dict[str, object] = {
+ "model": model,
+ "messages": messages,
+ "timeout": timeout_seconds,
+ }
+ if self._config.api_base:
+ kwargs["api_base"] = self._config.api_base
+ if self._config.api_key:
+ kwargs["api_key"] = self._config.api_key
+ if self._config.api_version:
+ kwargs["api_version"] = self._config.api_version
+ if output_json:
+ kwargs["response_format"] = {"type": "json_object"}
+ if self._config.completion_kwargs:
+ kwargs.update(self._config.completion_kwargs)
+
+ try:
+ response = await acompletion(**kwargs)
+ except Exception as exc: # pragma: no cover - provider-specific failure path
+ raise ProviderError(f"LiteLLM request failed for model '{model}': {exc}") from exc
+ self._record_response_cost(model=model, response=response)
+
+ answer = response.choices[0].message.content
+ if not isinstance(answer, str) or not answer.strip():
+ raise ProviderError(f"LiteLLM returned empty output for model '{model}'.")
+ return answer
+
+ def _resolved_image_detail(self) -> str | None:
+ return "high" if self._config.image_detail is None else self._config.image_detail
+
+ def _record_response_cost(self, *, model: str, response: object) -> None:
+ """Update cumulative request/cost counters from a successful LiteLLM response."""
+ self._request_count += 1
+ cost = _extract_response_cost(model=model, response=response)
+ self._last_cost_usd = cost
+ if cost is None:
+ self._untracked_request_count += 1
+ return
+ self._total_cost_usd += cost
+
+
+def _prepare_messages(
+ *,
+ system_prompt: str | None,
+ user_prompt: str | None,
+ images: Sequence[Image.Image] | None = None,
+ image_detail: str | None = None,
+) -> list[dict[str, Any]]:
+ """Build chat-style messages for multimodal provider calls."""
+ content: list[dict[str, Any]] = []
+ for image in images or []:
+ mime_type, encoded = image_to_base64(image)
+ payload: dict[str, Any] = {
+ "url": f"data:{mime_type};base64,{encoded}",
+ }
+ if image_detail:
+ payload["detail"] = image_detail
+ content.append({"type": "image_url", "image_url": payload})
+ if user_prompt:
+ content.append({"type": "text", "text": user_prompt})
+
+ messages: list[dict[str, Any]] = []
+ if system_prompt:
+ messages.append(
+ {
+ "role": "system",
+ "content": [{"type": "text", "text": system_prompt}],
+ }
+ )
+ messages.append({"role": "user", "content": content})
+ return messages
+
+
+def _extract_response_cost(*, model: str, response: object) -> float | None:
+ """Best-effort extraction of a response's USD cost from LiteLLM metadata."""
+ hidden_params = getattr(response, "_hidden_params", None)
+ if isinstance(hidden_params, dict):
+ raw_cost = hidden_params.get("response_cost")
+ if isinstance(raw_cost, (int, float)):
+ return float(raw_cost)
+
+ try:
+ from litellm import completion_cost
+ except Exception:
+ return None
+
+ try:
+ cost = completion_cost(completion_response=response, model=model)
+ except Exception:
+ return None
+ if not isinstance(cost, (int, float)):
+ return None
+ return float(cost)
+
+
+def _prepare_messages_from_conversation(
+ conversation: OCRConversation,
+ *,
+ image_detail: str | None = None,
+) -> list[dict[str, Any]]:
+ """Convert a structured OCR conversation into LiteLLM/OpenAI-style messages."""
+ messages: list[dict[str, Any]] = []
+ for message in conversation:
+ content_items = cast("list[dict[str, Any]]", message["content"])
+ content: list[dict[str, Any]] = []
+ for item in content_items:
+ if item.get("type") == "image":
+ image = cast("Image.Image", item["image"])
+ mime_type, encoded = image_to_base64(image)
+ payload: dict[str, Any] = {"url": f"data:{mime_type};base64,{encoded}"}
+ if image_detail:
+ payload["detail"] = image_detail
+ content.append({"type": "image_url", "image_url": payload})
+ continue
+ if item.get("type") == "text":
+ content.append({"type": "text", "text": item["text"]})
+ continue
+ content.append(dict(item))
+ messages.append({"role": message["role"], "content": content})
+ return messages
+
+
+def prepare_messages(
+ *,
+ system_prompt: str | None,
+ user_prompt: str | None,
+ images: Sequence[Image.Image] | None = None,
+ image_detail: str | None = None,
+) -> list[dict[str, Any]]:
+ """Build chat-style messages for multimodal provider calls."""
+ return _prepare_messages(
+ system_prompt=system_prompt,
+ user_prompt=user_prompt,
+ images=images,
+ image_detail=image_detail,
+ )
+
+
+def prepare_messages_from_conversation(
+ conversation: OCRConversation,
+ *,
+ image_detail: str | None = None,
+) -> list[dict[str, Any]]:
+ """Convert a structured OCR conversation into LiteLLM/OpenAI-style messages."""
+ return _prepare_messages_from_conversation(
+ conversation,
+ image_detail=image_detail,
+ )
+
+
+async def complete_text(
+ *,
+ model: str,
+ messages: list[dict[str, Any]],
+ api_base: str | None = None,
+ api_key: str | None = None,
+ api_version: str | None = None,
+ timeout_seconds: int = 600,
+ output_json: bool = False,
+ completion_kwargs: dict[str, object] | None = None,
+) -> str:
+ """Run a LiteLLM completion and return the text content."""
+ transport = LiteLLMTransport(
+ LiteLLMTransportConfig(
+ api_base=api_base,
+ api_key=api_key,
+ api_version=api_version,
+ completion_kwargs=dict(completion_kwargs or {}),
+ )
+ )
+ return await transport.complete_text(
+ model=model,
+ messages=messages,
+ timeout_seconds=timeout_seconds,
+ output_json=output_json,
+ )
+
+
+__all__ = [
+ "complete_text",
+ "configure_disk_cache",
+ "LiteLLMTransport",
+ "prepare_messages",
+ "prepare_messages_from_conversation",
+]
diff --git a/src/churro_ocr/_internal/logging.py b/src/churro_ocr/_internal/logging.py
new file mode 100644
index 0000000..f11de42
--- /dev/null
+++ b/src/churro_ocr/_internal/logging.py
@@ -0,0 +1,64 @@
+"""Internal logging utilities for the standalone churro-ocr package."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from loguru import logger as _loguru_logger
+from rich.logging import RichHandler
+
+
+def _configure_default_logger() -> Any:
+ _loguru_logger.remove()
+ _loguru_logger.add(
+ RichHandler(markup=True, show_time=False),
+ level="WARNING",
+ format="{message}",
+ )
+ return _loguru_logger
+
+
+_default_logger = _configure_default_logger().bind(app="churro-ocr")
+
+
+class _LoggerAdapter:
+ """Compatibility wrapper for stdlib-style formatting on top of the default logger."""
+
+ __slots__ = ("_logger",)
+
+ def __init__(self, wrapped_logger: Any) -> None:
+ self._logger = wrapped_logger
+
+ def _format(self, message: str, *args: object) -> str:
+ return message % args if args else message
+
+ def debug(self, message: str, *args: object) -> None:
+ self._logger.debug(self._format(message, *args))
+
+ def info(self, message: str, *args: object) -> None:
+ self._logger.info(self._format(message, *args))
+
+ def success(self, message: str, *args: object) -> None:
+ success = getattr(self._logger, "success", None)
+ if success is not None:
+ success(self._format(message, *args))
+ return
+ self._logger.info(self._format(message, *args))
+
+ def warning(self, message: str, *args: object) -> None:
+ self._logger.warning(self._format(message, *args))
+
+ def error(self, message: str, *args: object) -> None:
+ self._logger.error(self._format(message, *args))
+
+ def critical(self, message: str, *args: object) -> None:
+ self._logger.critical(self._format(message, *args))
+
+ def exception(self, message: str, *args: object) -> None:
+ self._logger.exception(self._format(message, *args))
+
+ def log(self, level: str, message: str, *args: object) -> None:
+ self._logger.log(level, self._format(message, *args))
+
+
+logger = _LoggerAdapter(_default_logger)
diff --git a/src/churro_ocr/_internal/pdf.py b/src/churro_ocr/_internal/pdf.py
new file mode 100644
index 0000000..4f89194
--- /dev/null
+++ b/src/churro_ocr/_internal/pdf.py
@@ -0,0 +1,44 @@
+"""PDF rasterization helpers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from PIL import Image
+
+from churro_ocr.errors import ConfigurationError
+
+
+def rasterize_pdf(path: str | Path, *, dpi: int = 300) -> list[Image.Image]:
+ """Rasterize a PDF into PIL images."""
+ try:
+ import pypdfium2
+ except ImportError as exc: # pragma: no cover - depends on optional extra
+ raise ConfigurationError(
+ "PDF support requires the 'pdf' extra. Install with `pip install \"churro-ocr[pdf]\"`."
+ ) from exc
+
+ resolved = Path(path)
+ if not resolved.exists():
+ raise ConfigurationError(f"PDF path does not exist: {resolved}")
+
+ images: list[Image.Image] = []
+ scale = max(dpi, 1) / 72.0
+ document = pypdfium2.PdfDocument(str(resolved))
+ try:
+ for page in document:
+ try:
+ bitmap = page.render(scale=scale)
+ try:
+ rendered = bitmap.to_pil()
+ try:
+ images.append(rendered.convert("RGB"))
+ finally:
+ rendered.close()
+ finally:
+ bitmap.close()
+ finally:
+ page.close()
+ finally:
+ document.close()
+ return images
diff --git a/src/churro_ocr/_internal/prompt_logging.py b/src/churro_ocr/_internal/prompt_logging.py
new file mode 100644
index 0000000..6c84906
--- /dev/null
+++ b/src/churro_ocr/_internal/prompt_logging.py
@@ -0,0 +1,84 @@
+"""Helpers for one-time OCR prompt payload logging."""
+
+from __future__ import annotations
+
+import json
+from base64 import b64encode
+from collections.abc import Callable
+from threading import Lock
+
+from PIL import Image
+
+from churro_ocr._internal.image import image_to_base64
+from churro_ocr._internal.logging import logger
+
+_IMAGE_PREVIEW_CHARS = 96
+
+
+def _truncate_text(value: str, *, limit: int = _IMAGE_PREVIEW_CHARS) -> str:
+ if len(value) <= limit:
+ return value
+ return f"{value[:limit]}..."
+
+
+def _encode_image_preview(image: Image.Image, *, format_name: str | None = None) -> dict[str, object]:
+ mime_type, encoded = image_to_base64(image, format_name)
+ return {
+ "image_size": image.size,
+ "image_mode": image.mode,
+ "image_preview": _truncate_text(f"data:{mime_type};base64,{encoded}"),
+ }
+
+
+def _encode_bytes_preview(payload: bytes, *, mime_type: str) -> str:
+ encoded = b64encode(payload).decode("utf-8")
+ return _truncate_text(f"data:{mime_type};base64,{encoded}")
+
+
+def _sanitize_prompt_payload(payload: object) -> object:
+ if isinstance(payload, Image.Image):
+ return {
+ "type": "image",
+ **_encode_image_preview(payload),
+ }
+ if isinstance(payload, str) and payload.startswith("data:") and ";base64," in payload:
+ return _truncate_text(payload)
+ if isinstance(payload, bytes):
+ return {
+ "type": "bytes",
+ "byte_length": len(payload),
+ "data_preview": _encode_bytes_preview(payload, mime_type="application/octet-stream"),
+ }
+ if isinstance(payload, dict):
+ return {str(key): _sanitize_prompt_payload(value) for key, value in payload.items()}
+ if isinstance(payload, list):
+ return [_sanitize_prompt_payload(item) for item in payload]
+ if isinstance(payload, tuple):
+ return [_sanitize_prompt_payload(item) for item in payload]
+ return payload
+
+
+def log_prompt_payload_once(
+ *,
+ payload: object,
+ provider_name: str,
+ has_logged: Callable[[], bool],
+ lock: Lock,
+ set_logged: Callable[[], None],
+) -> None:
+ """Log one OCR prompt payload preview for a backend instance."""
+ if has_logged():
+ return
+ with lock:
+ if has_logged():
+ return
+ sanitized_payload = _sanitize_prompt_payload(payload)
+ logger.debug(
+ "First OCR prompt payload for %s:\n%s",
+ provider_name,
+ json.dumps(sanitized_payload, ensure_ascii=False, indent=2, default=str),
+ )
+ set_logged()
+
+
+__all__ = ["log_prompt_payload_once"]
diff --git a/src/churro_ocr/_internal/runtime.py b/src/churro_ocr/_internal/runtime.py
new file mode 100644
index 0000000..24f3c5e
--- /dev/null
+++ b/src/churro_ocr/_internal/runtime.py
@@ -0,0 +1,24 @@
+"""Runtime helpers shared by sync wrappers."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Coroutine
+from typing import Any, TypeVar
+
+T = TypeVar("T")
+
+
+def run_sync[T](awaitable: Coroutine[Any, Any, T]) -> T:
+ """Run an awaitable from sync code.
+
+ Raises:
+ RuntimeError: If called from a running event loop.
+ """
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return asyncio.run(awaitable)
+ raise RuntimeError(
+ "Synchronous churro-ocr APIs cannot be used from an active event loop. Use the async API instead."
+ )
diff --git a/src/churro_ocr/cli.py b/src/churro_ocr/cli.py
new file mode 100644
index 0000000..8715bcd
--- /dev/null
+++ b/src/churro_ocr/cli.py
@@ -0,0 +1,235 @@
+"""Minimal public CLI for OCR and page detection."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import typer
+
+from churro_ocr.ocr import OCRClient
+from churro_ocr.page_detection import DocumentPage, DocumentPageDetector, PageDetectionRequest
+from churro_ocr.providers import (
+ AzureDocumentIntelligenceOptions,
+ AzurePageDetector,
+ HuggingFaceOptions,
+ LiteLLMTransportConfig,
+ LLMPageDetector,
+ MistralOptions,
+ OCRBackendSpec,
+ OpenAICompatibleOptions,
+ VLLMOptions,
+ build_ocr_backend,
+)
+
+app = typer.Typer(help="churro-ocr library-first CLI")
+
+
+def _build_ocr_backend(
+ *,
+ backend: str,
+ model: str | None,
+ endpoint: str | None,
+ api_key: str | None,
+ base_url: str | None,
+ api_version: str | None,
+):
+ if backend == "litellm":
+ if not model:
+ raise typer.BadParameter("--model is required for backend=litellm")
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model=model,
+ transport=LiteLLMTransportConfig(
+ api_base=base_url,
+ api_key=api_key,
+ api_version=api_version,
+ ),
+ )
+ )
+ if backend == "openai-compatible":
+ if not model or not base_url or not api_key:
+ raise typer.BadParameter(
+ "--model, --base-url, and --api-key are required for backend=openai-compatible"
+ )
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="openai-compatible",
+ model=model,
+ transport=LiteLLMTransportConfig(
+ api_base=base_url,
+ api_key=api_key,
+ api_version=api_version,
+ ),
+ options=OpenAICompatibleOptions(),
+ )
+ )
+ if backend == "azure":
+ if not endpoint or not api_key:
+ raise typer.BadParameter("--endpoint and --api-key are required for backend=azure")
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="azure",
+ model=model,
+ options=AzureDocumentIntelligenceOptions(
+ endpoint=endpoint,
+ api_key=api_key,
+ ),
+ )
+ )
+ if backend == "mistral":
+ if not api_key:
+ raise typer.BadParameter("--api-key is required for backend=mistral")
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="mistral",
+ model=model or "mistral-ocr-latest",
+ options=MistralOptions(api_key=api_key),
+ )
+ )
+ if backend == "hf":
+ if not model:
+ raise typer.BadParameter("--model is required for backend=hf")
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="hf",
+ model=model,
+ options=HuggingFaceOptions(model_kwargs={"device_map": "auto", "torch_dtype": "auto"}),
+ )
+ )
+ if backend == "vllm":
+ if not model:
+ raise typer.BadParameter("--model is required for backend=vllm")
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="vllm",
+ model=model,
+ options=VLLMOptions(),
+ )
+ )
+ raise typer.BadParameter(f"Unsupported backend: {backend}")
+
+
+def _build_page_detector(
+ *,
+ page_detector: str,
+ model: str | None,
+ endpoint: str | None,
+ api_key: str | None,
+ base_url: str | None,
+ api_version: str | None,
+):
+ transport = None
+ if base_url or api_key or api_version:
+ transport = LiteLLMTransportConfig(
+ api_base=base_url,
+ api_key=api_key,
+ api_version=api_version,
+ )
+ detector_backend = None
+ if page_detector == "llm":
+ if not model:
+ raise typer.BadParameter("--model is required when --page-detector=llm")
+ detector_backend = LLMPageDetector(
+ model=model,
+ transport=transport,
+ )
+ elif page_detector == "azure":
+ if not endpoint or not api_key:
+ raise typer.BadParameter("--endpoint and --api-key are required when --page-detector=azure")
+ detector_backend = AzurePageDetector(endpoint=endpoint, api_key=api_key)
+ return detector_backend
+
+
+@app.command("transcribe")
+def transcribe_command(
+ image: Path = typer.Option(..., exists=True, dir_okay=False, readable=True),
+ backend: str = typer.Option("litellm"),
+ model: str | None = typer.Option(None),
+ endpoint: str | None = typer.Option(None),
+ api_key: str | None = typer.Option(None),
+ base_url: str | None = typer.Option(None),
+ api_version: str | None = typer.Option(None),
+ output: Path | None = typer.Option(None),
+) -> None:
+ """Transcribe text from a single image."""
+ ocr_backend = _build_ocr_backend(
+ backend=backend,
+ model=model,
+ endpoint=endpoint,
+ api_key=api_key,
+ base_url=base_url,
+ api_version=api_version,
+ )
+ result = OCRClient(ocr_backend).ocr(DocumentPage.from_image_path(image))
+ if output:
+ output.write_text(result.text or "")
+ typer.echo(str(output))
+ return
+ typer.echo(result.text or "")
+
+
+@app.command("extract-pages")
+def extract_pages_command(
+ image: Path | None = typer.Option(
+ None,
+ exists=True,
+ dir_okay=False,
+ readable=True,
+ help="Input image to split into page crops.",
+ ),
+ pdf: Path | None = typer.Option(
+ None,
+ exists=True,
+ dir_okay=False,
+ readable=True,
+ help="Input PDF to rasterize and split into page crops.",
+ ),
+ output_dir: Path = typer.Option(
+ ...,
+ file_okay=False,
+ writable=True,
+ help=(
+ "Directory where extracted pages are written as sequential PNG files such as "
+ "`page_0000.png`, `page_0001.png`, and so on."
+ ),
+ ),
+ page_detector: str = typer.Option("none"),
+ model: str | None = typer.Option(None),
+ endpoint: str | None = typer.Option(None),
+ api_key: str | None = typer.Option(None),
+ base_url: str | None = typer.Option(None),
+ api_version: str | None = typer.Option(None),
+ dpi: int = typer.Option(300),
+ trim_margin: int = typer.Option(30),
+) -> None:
+ """Extract page crops as PNG files and print each written path."""
+ if (image is None) == (pdf is None):
+ raise typer.BadParameter("Provide exactly one of --image or --pdf.")
+ detector_backend = _build_page_detector(
+ page_detector=page_detector,
+ model=model,
+ endpoint=endpoint,
+ api_key=api_key,
+ base_url=base_url,
+ api_version=api_version,
+ )
+ page_detector_client = DocumentPageDetector(backend=detector_backend)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ if image is not None:
+ result = page_detector_client.detect_image_sync(
+ PageDetectionRequest(image_path=image, trim_margin=trim_margin)
+ )
+ else:
+ assert pdf is not None
+ result = page_detector_client.detect_pdf_sync(pdf, dpi=dpi, trim_margin=trim_margin)
+
+ for page in result.pages:
+ output_path = output_dir / f"page_{page.page_index:04d}.png"
+ page.image.save(output_path)
+ typer.echo(str(output_path))
+
+
+def main() -> None:
+ """Console-script entrypoint."""
+ app()
diff --git a/src/churro_ocr/document.py b/src/churro_ocr/document.py
new file mode 100644
index 0000000..9a466b9
--- /dev/null
+++ b/src/churro_ocr/document.py
@@ -0,0 +1,168 @@
+"""Document-level OCR pipeline built on the page detection and OCR APIs."""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from churro_ocr._internal.runtime import run_sync
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.ocr import OCRBackendLike, OCRClient, OCRResult
+from churro_ocr.page_detection import (
+ DocumentPage,
+ DocumentPageDetector,
+ PageDetectionBackendLike,
+ PageDetectionRequest,
+)
+
+
+@dataclass(slots=True)
+class DocumentOCRResult:
+ """Document OCR output across all detected pages."""
+
+ pages: list[DocumentPage]
+ source_type: str
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def texts(self) -> list[str]:
+ """Return OCR text for each page in order."""
+ return [page.text or "" for page in self.pages]
+
+ def as_ocr_results(self) -> list[OCRResult]:
+ """Return plain OCR results in page order."""
+ return [
+ OCRResult(
+ text=page.text or "",
+ provider_name=page.provider_name or "",
+ model_name=page.model_name or "",
+ metadata=dict(page.ocr_metadata),
+ )
+ for page in self.pages
+ ]
+
+
+class DocumentOCRPipeline:
+ """Run page detection and OCR as one document-level pipeline."""
+
+ def __init__(
+ self,
+ ocr_backend: OCRBackendLike,
+ *,
+ page_detector: DocumentPageDetector | None = None,
+ detection_backend: PageDetectionBackendLike | None = None,
+ max_concurrency: int = 8,
+ ) -> None:
+ if max_concurrency < 1:
+ raise ConfigurationError("DocumentOCRPipeline max_concurrency must be at least 1.")
+ self._ocr_client = OCRClient(ocr_backend)
+ self._page_detector = page_detector or DocumentPageDetector(backend=detection_backend)
+ self.max_concurrency = max_concurrency
+
+ async def process_image(
+ self,
+ request: PageDetectionRequest,
+ *,
+ ocr_metadata: dict[str, Any] | None = None,
+ ) -> DocumentOCRResult:
+ """Detect pages and OCR a single input image."""
+ detection_result = await self._page_detector.detect_image(request)
+ return await self._ocr_detection_result(
+ detection_result.pages,
+ detection_result.source_type,
+ detection_result.metadata,
+ ocr_metadata,
+ )
+
+ def process_image_sync(
+ self,
+ request: PageDetectionRequest,
+ *,
+ ocr_metadata: dict[str, Any] | None = None,
+ ) -> DocumentOCRResult:
+ """Synchronous wrapper for image OCR."""
+ return run_sync(self.process_image(request, ocr_metadata=ocr_metadata))
+
+ async def process_pdf(
+ self,
+ path: str | Path,
+ *,
+ dpi: int = 300,
+ trim_margin: int = 30,
+ ocr_metadata: dict[str, Any] | None = None,
+ ) -> DocumentOCRResult:
+ """Rasterize, detect pages, and OCR a PDF."""
+ detection_result = await self._page_detector.detect_pdf(
+ path,
+ dpi=dpi,
+ trim_margin=trim_margin,
+ )
+ return await self._ocr_detection_result(
+ detection_result.pages,
+ detection_result.source_type,
+ detection_result.metadata,
+ ocr_metadata,
+ )
+
+ def process_pdf_sync(
+ self,
+ path: str | Path,
+ *,
+ dpi: int = 300,
+ trim_margin: int = 30,
+ ocr_metadata: dict[str, Any] | None = None,
+ ) -> DocumentOCRResult:
+ """Synchronous wrapper for PDF OCR."""
+ return run_sync(
+ self.process_pdf(
+ path,
+ dpi=dpi,
+ trim_margin=trim_margin,
+ ocr_metadata=ocr_metadata,
+ )
+ )
+
+ async def _ocr_detection_result(
+ self,
+ detected_pages: list[DocumentPage],
+ source_type: str,
+ metadata: dict[str, Any],
+ ocr_metadata: dict[str, Any] | None,
+ ) -> DocumentOCRResult:
+ semaphore = asyncio.Semaphore(self.max_concurrency)
+
+ async def _ocr_page_with_limit(page: DocumentPage) -> DocumentPage:
+ async with semaphore:
+ return await self._ocr_page(page, ocr_metadata=ocr_metadata)
+
+ results = await asyncio.gather(*(_ocr_page_with_limit(page) for page in detected_pages))
+ return DocumentOCRResult(
+ pages=results,
+ source_type=source_type,
+ metadata=dict(metadata),
+ )
+
+ async def _ocr_page(
+ self,
+ page: DocumentPage,
+ *,
+ ocr_metadata: dict[str, Any] | None,
+ ) -> DocumentPage:
+ page_metadata = dict(page.metadata)
+ page_metadata.update(ocr_metadata or {})
+ page_metadata.setdefault("page_index", page.page_index)
+ page_metadata.setdefault("source_index", page.source_index)
+ ocr_page = page.__class__(
+ page_index=page.page_index,
+ source_index=page.source_index,
+ image=page.image,
+ bbox=page.bbox,
+ polygon=page.polygon,
+ metadata=page_metadata,
+ text=page.text,
+ provider_name=page.provider_name,
+ model_name=page.model_name,
+ ocr_metadata=dict(page.ocr_metadata),
+ )
+ return await self._ocr_client.aocr(ocr_page)
diff --git a/src/churro_ocr/errors.py b/src/churro_ocr/errors.py
new file mode 100644
index 0000000..cddebc2
--- /dev/null
+++ b/src/churro_ocr/errors.py
@@ -0,0 +1,15 @@
+"""Public exception types for the churro-ocr package."""
+
+from __future__ import annotations
+
+
+class ChurroError(RuntimeError):
+ """Base exception for package-level failures."""
+
+
+class ConfigurationError(ChurroError):
+ """Raised when a backend is missing required runtime configuration."""
+
+
+class ProviderError(ChurroError):
+ """Raised when an OCR or page detection provider returns an unusable response."""
diff --git a/src/churro_ocr/ocr.py b/src/churro_ocr/ocr.py
new file mode 100644
index 0000000..e5cf839
--- /dev/null
+++ b/src/churro_ocr/ocr.py
@@ -0,0 +1,139 @@
+"""Public OCR interfaces."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field, replace
+from pathlib import Path
+from typing import Any, Protocol, runtime_checkable
+
+from PIL import Image
+
+from churro_ocr._internal.image import prepare_ocr_image
+from churro_ocr._internal.runtime import run_sync
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.page_detection import DocumentPage
+
+
+@dataclass(slots=True)
+class OCRResult:
+ """Provider-agnostic OCR result."""
+
+ text: str
+ provider_name: str
+ model_name: str
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@runtime_checkable
+class OCRBackend(Protocol):
+ """Async OCR backend interface."""
+
+ async def ocr(self, page: DocumentPage) -> OCRResult: ...
+
+
+@runtime_checkable
+class BatchOCRBackend(Protocol):
+ """Async batch OCR backend interface."""
+
+ async def ocr_batch(self, pages: list[DocumentPage]) -> list[OCRResult]: ...
+
+
+OCRCallable = Callable[[DocumentPage], Awaitable[OCRResult]]
+OCRBackendLike = OCRBackend | OCRCallable
+
+
+def prepare_ocr_page(page: DocumentPage) -> DocumentPage:
+ """Return a page copy with the shared OCR image preprocessing applied."""
+ return replace(page, image=prepare_ocr_image(page.image))
+
+
+class OCRClient:
+ """User-facing OCR client with page-first sync and async entrypoints."""
+
+ def __init__(self, backend: OCRBackendLike) -> None:
+ self._backend = backend
+
+ async def aocr(self, page: DocumentPage) -> DocumentPage:
+ """Run OCR asynchronously for one page."""
+ if callable(self._backend) and not isinstance(self._backend, OCRBackend):
+ result = await self._backend(page)
+ else:
+ assert isinstance(self._backend, OCRBackend)
+ result = await self._backend.ocr(page)
+ return page.with_ocr(
+ text=result.text,
+ provider_name=result.provider_name,
+ model_name=result.model_name,
+ ocr_metadata=result.metadata,
+ )
+
+ def ocr(self, page: DocumentPage) -> DocumentPage:
+ """Run OCR synchronously for one page."""
+ return run_sync(self.aocr(page))
+
+ async def aocr_image(
+ self,
+ *,
+ image: Image.Image | None = None,
+ image_path: str | Path | None = None,
+ page_index: int = 0,
+ source_index: int = 0,
+ metadata: dict[str, Any] | None = None,
+ ) -> DocumentPage:
+ """Create a single page from an image input and OCR it."""
+ page = _page_from_image_input(
+ image=image,
+ image_path=image_path,
+ page_index=page_index,
+ source_index=source_index,
+ metadata=metadata,
+ )
+ return await self.aocr(page)
+
+ def ocr_image(
+ self,
+ *,
+ image: Image.Image | None = None,
+ image_path: str | Path | None = None,
+ page_index: int = 0,
+ source_index: int = 0,
+ metadata: dict[str, Any] | None = None,
+ ) -> DocumentPage:
+ """Create a single page from an image input and OCR it synchronously."""
+ return run_sync(
+ self.aocr_image(
+ image=image,
+ image_path=image_path,
+ page_index=page_index,
+ source_index=source_index,
+ metadata=metadata,
+ )
+ )
+
+
+def _page_from_image_input(
+ *,
+ image: Image.Image | None,
+ image_path: str | Path | None,
+ page_index: int,
+ source_index: int,
+ metadata: dict[str, Any] | None,
+) -> DocumentPage:
+ if (image is None) == (image_path is None):
+ raise ConfigurationError("OCR image helpers require exactly one of `image` or `image_path`.")
+ if image is not None:
+ return DocumentPage.from_image(
+ image,
+ page_index=page_index,
+ source_index=source_index,
+ metadata=metadata,
+ )
+ if image_path is not None:
+ return DocumentPage.from_image_path(
+ image_path,
+ page_index=page_index,
+ source_index=source_index,
+ metadata=metadata,
+ )
+ raise AssertionError("Unreachable exact-one image input guard.")
diff --git a/src/churro_ocr/page_detection.py b/src/churro_ocr/page_detection.py
new file mode 100644
index 0000000..ad5fe10
--- /dev/null
+++ b/src/churro_ocr/page_detection.py
@@ -0,0 +1,294 @@
+"""Public page detection interfaces."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field, replace
+from pathlib import Path
+from typing import Any, Protocol, runtime_checkable
+
+from PIL import Image, ImageDraw
+
+from churro_ocr._internal.image import load_image
+from churro_ocr._internal.pdf import rasterize_pdf
+from churro_ocr._internal.runtime import run_sync
+from churro_ocr.errors import ConfigurationError
+
+
+@dataclass(slots=True)
+class PageCandidate:
+ """Intermediate page candidate returned by a page detector."""
+
+ bbox: tuple[float, float, float, float] | None = None
+ image: Image.Image | None = None
+ polygon: tuple[tuple[float, float], ...] = ()
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(slots=True)
+class DocumentPage:
+ """A document page image with optional OCR output attached."""
+
+ page_index: int
+ image: Image.Image
+ source_index: int
+ bbox: tuple[float, float, float, float] | None = None
+ polygon: tuple[tuple[float, float], ...] = ()
+ metadata: dict[str, Any] = field(default_factory=dict)
+ text: str | None = None
+ provider_name: str | None = None
+ model_name: str | None = None
+ ocr_metadata: dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def width(self) -> int:
+ return self.image.width
+
+ @property
+ def height(self) -> int:
+ return self.image.height
+
+ @classmethod
+ def from_image(
+ cls,
+ image: Image.Image,
+ *,
+ page_index: int = 0,
+ source_index: int = 0,
+ metadata: dict[str, Any] | None = None,
+ ) -> DocumentPage:
+ """Create a document page from an in-memory image."""
+ return cls(
+ page_index=page_index,
+ source_index=source_index,
+ image=image.copy(),
+ metadata=dict(metadata or {}),
+ )
+
+ @classmethod
+ def from_image_path(
+ cls,
+ path: str | Path,
+ *,
+ page_index: int = 0,
+ source_index: int = 0,
+ metadata: dict[str, Any] | None = None,
+ ) -> DocumentPage:
+ """Create a document page from an image path."""
+ return cls.from_image(
+ load_image(path),
+ page_index=page_index,
+ source_index=source_index,
+ metadata=metadata,
+ )
+
+ def with_ocr(
+ self,
+ *,
+ text: str,
+ provider_name: str,
+ model_name: str,
+ ocr_metadata: dict[str, Any] | None = None,
+ ) -> DocumentPage:
+ """Return a copy of the page with OCR output attached."""
+ return replace(
+ self,
+ text=text,
+ provider_name=provider_name,
+ model_name=model_name,
+ ocr_metadata=dict(ocr_metadata or {}),
+ )
+
+
+@dataclass(slots=True)
+class PageDetectionRequest:
+ """Request payload for image page detection."""
+
+ image: Image.Image | None = None
+ image_path: str | Path | None = None
+ trim_margin: int = 30
+
+ def require_image(self) -> Image.Image:
+ """Return the input image, loading it from disk when needed."""
+ if (self.image is None) == (self.image_path is None):
+ raise ConfigurationError("PageDetectionRequest requires exactly one of `image` or `image_path`.")
+ if self.image is not None:
+ return self.image.copy()
+ if self.image_path is not None:
+ return load_image(self.image_path)
+ raise AssertionError("Unreachable exact-one image input guard.")
+
+
+@dataclass(slots=True)
+class PageDetectionResult:
+ """Page detection output for an image or PDF."""
+
+ pages: list[DocumentPage]
+ source_type: str
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@runtime_checkable
+class PageDetectionBackend(Protocol):
+ """Async interface for page detection."""
+
+ async def detect(self, image: Image.Image) -> list[PageCandidate]: ...
+
+
+PageDetectionCallable = Callable[[Image.Image], Awaitable[list[PageCandidate]]]
+PageDetectionBackendLike = PageDetectionBackend | PageDetectionCallable
+
+
+class PageDetector:
+ """Detect one or more page crops from an input image."""
+
+ def __init__(self, backend: PageDetectionBackendLike | None = None) -> None:
+ self._backend = backend
+
+ async def adetect(self, request: PageDetectionRequest) -> list[DocumentPage]:
+ """Asynchronously detect pages for a single image."""
+ image = request.require_image()
+ candidates = await self._detect_candidates(image)
+ detected_pages: list[DocumentPage] = []
+ for candidate in candidates:
+ detected_pages.append(
+ DocumentPage(
+ page_index=len(detected_pages),
+ image=self._materialize_candidate(
+ source_image=image,
+ candidate=candidate,
+ trim_margin=request.trim_margin,
+ ),
+ source_index=0,
+ bbox=candidate.bbox,
+ polygon=candidate.polygon,
+ metadata=dict(candidate.metadata),
+ )
+ )
+ return detected_pages
+
+ def detect(self, request: PageDetectionRequest) -> list[DocumentPage]:
+ """Synchronous wrapper for page detection."""
+ return run_sync(self.adetect(request))
+
+ async def _detect_candidates(self, image: Image.Image) -> list[PageCandidate]:
+ if self._backend is None:
+ return [PageCandidate(bbox=(0.0, 0.0, float(image.width), float(image.height)))]
+ if callable(self._backend) and not isinstance(self._backend, PageDetectionBackend):
+ candidates = await self._backend(image)
+ else:
+ assert isinstance(self._backend, PageDetectionBackend)
+ candidates = await self._backend.detect(image)
+ return candidates or [PageCandidate(bbox=(0.0, 0.0, float(image.width), float(image.height)))]
+
+ def _materialize_candidate(
+ self,
+ *,
+ source_image: Image.Image,
+ candidate: PageCandidate,
+ trim_margin: int,
+ ) -> Image.Image:
+ if candidate.image is not None:
+ return candidate.image.copy()
+ if candidate.polygon:
+ return _crop_polygon(source_image, candidate.polygon, trim_margin=trim_margin)
+ if candidate.bbox is None:
+ return source_image.copy()
+ return _crop_bbox(source_image, candidate.bbox, trim_margin=trim_margin)
+
+
+class DocumentPageDetector:
+ """Detect pages from raw images or PDFs."""
+
+ def __init__(
+ self,
+ *,
+ backend: PageDetectionBackendLike | None = None,
+ ) -> None:
+ self._page_detector = PageDetector(backend)
+
+ async def detect_image(self, request: PageDetectionRequest) -> PageDetectionResult:
+ """Detect pages in a single image."""
+ pages = await self._page_detector.adetect(request)
+ return PageDetectionResult(pages=pages, source_type="image")
+
+ def detect_image_sync(self, request: PageDetectionRequest) -> PageDetectionResult:
+ """Synchronous wrapper for image page detection."""
+ return run_sync(self.detect_image(request))
+
+ async def detect_pdf(
+ self,
+ path: str | Path,
+ *,
+ dpi: int = 300,
+ trim_margin: int = 30,
+ ) -> PageDetectionResult:
+ """Rasterize a PDF and detect pages on each image."""
+ images = rasterize_pdf(path, dpi=dpi)
+ pages: list[DocumentPage] = []
+ for pdf_index, image in enumerate(images):
+ detected_pages = await self._page_detector.adetect(
+ PageDetectionRequest(image=image, trim_margin=trim_margin)
+ )
+ for page in detected_pages:
+ pages.append(
+ DocumentPage(
+ page_index=len(pages),
+ image=page.image,
+ source_index=pdf_index,
+ bbox=page.bbox,
+ polygon=page.polygon,
+ metadata=dict(page.metadata),
+ )
+ )
+ return PageDetectionResult(
+ pages=pages,
+ source_type="pdf",
+ metadata={"dpi": dpi, "path": str(path)},
+ )
+
+ def detect_pdf_sync(
+ self,
+ path: str | Path,
+ *,
+ dpi: int = 300,
+ trim_margin: int = 30,
+ ) -> PageDetectionResult:
+ """Synchronous wrapper for PDF page detection."""
+ return run_sync(self.detect_pdf(path, dpi=dpi, trim_margin=trim_margin))
+
+
+def _crop_bbox(
+ source_image: Image.Image,
+ bbox: tuple[float, float, float, float],
+ *,
+ trim_margin: int,
+) -> Image.Image:
+ left, top, right, bottom = bbox
+ expanded_left = max(int(left - trim_margin), 0)
+ expanded_top = max(int(top - trim_margin), 0)
+ expanded_right = min(int(right + trim_margin), source_image.width)
+ expanded_bottom = min(int(bottom + trim_margin), source_image.height)
+ return source_image.crop((expanded_left, expanded_top, expanded_right, expanded_bottom))
+
+
+def _crop_polygon(
+ source_image: Image.Image,
+ polygon: tuple[tuple[float, float], ...],
+ *,
+ trim_margin: int,
+) -> Image.Image:
+ xs = [point[0] for point in polygon]
+ ys = [point[1] for point in polygon]
+ bbox = (min(xs), min(ys), max(xs), max(ys))
+ cropped = _crop_bbox(source_image, bbox, trim_margin=trim_margin)
+ left = max(int(bbox[0] - trim_margin), 0)
+ top = max(int(bbox[1] - trim_margin), 0)
+
+ mask = Image.new("L", cropped.size, 0)
+ relative_points = [(x - left, y - top) for x, y in polygon]
+ ImageDraw.Draw(mask).polygon(relative_points, fill=255)
+
+ background = Image.new(cropped.mode, cropped.size, color="white")
+ background.paste(cropped, mask=mask)
+ return background
diff --git a/src/churro_ocr/prompts/__init__.py b/src/churro_ocr/prompts/__init__.py
new file mode 100644
index 0000000..578679d
--- /dev/null
+++ b/src/churro_ocr/prompts/__init__.py
@@ -0,0 +1,21 @@
+"""Public prompt defaults used by churro-ocr backends."""
+
+from churro_ocr.prompts.layout import (
+ DEFAULT_BOUNDARY_DETECTION_PROMPT,
+)
+from churro_ocr.prompts.ocr import (
+ DEFAULT_MARKDOWN_OCR_USER_PROMPT,
+ DEFAULT_OCR_OUTPUT_TAG,
+ DEFAULT_OCR_SYSTEM_PROMPT,
+ DEFAULT_OCR_USER_PROMPT,
+ strip_ocr_output_tag,
+)
+
+__all__ = [
+ "DEFAULT_BOUNDARY_DETECTION_PROMPT",
+ "DEFAULT_MARKDOWN_OCR_USER_PROMPT",
+ "DEFAULT_OCR_OUTPUT_TAG",
+ "DEFAULT_OCR_SYSTEM_PROMPT",
+ "DEFAULT_OCR_USER_PROMPT",
+ "strip_ocr_output_tag",
+]
diff --git a/src/churro_ocr/prompts/layout.py b/src/churro_ocr/prompts/layout.py
new file mode 100644
index 0000000..e7b5c3f
--- /dev/null
+++ b/src/churro_ocr/prompts/layout.py
@@ -0,0 +1,175 @@
+"""Default prompts used by page-detection backends."""
+
+from __future__ import annotations
+
+import json
+
+PAGE_RESPONSE_INSTRUCTIONS = """- Return a JSON object with a key "pages".
+- "pages" must be a list containing zero or more objects.
+- Each object must have:
+ * "page_index": 1-based index in reading order.
+ * "left": integer normalized 0-1000 describing the minimum horizontal coordinate.
+ * "top": integer normalized 0-1000 describing the minimum vertical coordinate.
+ * "right": integer normalized 0-1000 describing the maximum horizontal coordinate.
+ * "bottom": integer normalized 0-1000 describing the maximum vertical coordinate.
+- Provide all coordinates as integers (no decimals) and keep them normalized to the
+ 0-1000 range.
+- CRITICAL GOAL: Find the **tightest possible bounding box** that contains **ALL**
+ meaningful content on the page.
+- INCLUDE:
+ * All printed text (headers, footers, page numbers, body text).
+ * All handwriting (signatures, marginalia, corrections).
+ * All stamps, seals, logos, and drawings.
+ * Any content that conveys information.
+- EXCLUDE:
+ * Empty page margins (white space).
+ * Content belonging to a different page (even if it appears in the same image).
+ * Partial/overflowing text or graphics from a neighboring page.
+ * Dark edges or background from the scanner/camera.
+ * Binding rings, spiral binding, or book spines.
+ * Shadows, scanner noise, or artifacts outside the content area.
+- The box should be as small as possible while still containing every pixel of ink/content.
+- Each returned box must correspond to exactly one page's content region.
+- If no page is visible, return {"pages": []}.
+"""
+
+DEFAULT_BOUNDARY_DETECTION_PROMPT = (
+ "You are an expert document analysis AI. Your task is to detect the precise boundaries\n"
+ "of document pages to prepare them for OCR.\n"
+ "The goal is to crop the image to the **tightest possible rectangle** that contains all\n"
+ "the content, removing as much empty margin as possible.\n\n"
+ "IMPORTANT TERMINOLOGY: In this task, 'page' means the tightest bounding box around the\n"
+ "visible content on a page, NOT the full sheet of paper. Exclude blank/white paper margins\n"
+ "whenever they do not contain meaningful content.\n\n"
+ "If a neighboring page intrudes into the image (or overlaps visually), do NOT include that\n"
+ "spillover content in this page's box. Each box should isolate one page only.\n\n"
+ "Identify every document page in this image (usually 1 or 2). For each page, define a\n"
+ "bounding box following these strict rules:\n"
+ f"{PAGE_RESPONSE_INSTRUCTIONS}"
+)
+
+
+def build_boundary_review_prompt(
+ *,
+ edge_name: str,
+ page_index: int,
+ strip_axis: str,
+) -> str:
+ """Build the per-edge review prompt used for iterative page-boundary refinement."""
+ return (
+ "You are an expert reviewer of a document page boundary annotation.\n"
+ "You are reviewing ONE EDGE of the red rectangle using an edge strip crop.\n"
+ "The crop is a narrow strip centered on the target edge of the current red boundary.\n\n"
+ "IMPORTANT TERMINOLOGY: 'Page boundary' means the tightest box around page CONTENT\n"
+ "(ink/text/stamps/handwriting), not the full paper edge. Exclude white/blank paper margins\n"
+ "unless they contain meaningful content.\n\n"
+ "CRITICAL ISOLATION RULE: Exclude content from neighboring pages. If this strip shows\n"
+ "partial/overflowing text or graphics from another page crossing near the target edge,\n"
+ "do not expand the target page box to include that other page's content.\n\n"
+ f"Target page_index: {page_index}\n"
+ f"Target edge: {edge_name}\n\n"
+ "Task:\n"
+ f"- Judge ONLY the {edge_name} edge of the red rectangle.\n"
+ "- Decide whether that edge should expand, shrink, or stay unchanged.\n"
+ "- Base the decision on visible content near that edge in this strip crop.\n"
+ "- Keep only the target page's content; exclude neighboring-page spillover.\n"
+ "- Do not make decisions for the other three edges.\n\n"
+ "Output JSON only with this schema:\n"
+ "{\n"
+ ' "page_index": ,\n'
+ f' "edge": "{edge_name}",\n'
+ ' "action": "expand" | "shrink" | "no_change",\n'
+ ' "amount": \n'
+ "}\n\n"
+ "Amount semantics:\n"
+ f"- 'amount' is a normalized 0-1000 delta along the strip's {strip_axis}.\n"
+ "- For 'no_change', return amount = 0.\n"
+ "- Prefer 'no_change' unless there is a clear improvement.\n"
+ "- Do NOT return absolute box coordinates.\n"
+ )
+
+
+def build_text_block_localization_prompt(*, block_tag: str, block_text: str) -> str:
+ """Build the initial prompt for locating a single rendered text block."""
+ target_tag = json.dumps(block_tag, ensure_ascii=False)
+ target_text = json.dumps(block_text, ensure_ascii=False)
+ return (
+ "You are an expert reviewer of historical document layout.\n"
+ "Your task is to find the SINGLE rendered content block in the image that matches the\n"
+ "target HDML block below.\n\n"
+ f"Target block tag (JSON string): {target_tag}\n"
+ f"Target block text (JSON string): {target_text}\n\n"
+ "MATCHING RULES:\n"
+ "- The target may be a heading, paragraph, date line, marginal note, block quotation,\n"
+ " list, table, figure caption, or page number depending on the tag.\n"
+ "- Match the same block even if whitespace, punctuation, line breaks, ligatures, or\n"
+ " historical glyphs differ slightly from the normalized transcription.\n"
+ "- Return one box for the WHOLE target block, not for an individual line, word, cell,\n"
+ " or inline span inside it.\n"
+ "- If multiple visually identical matches exist and you cannot confidently choose one,\n"
+ " return not found.\n\n"
+ "BOUNDING BOX RULES:\n"
+ "- Return the tightest possible bounding box around the entire target block only.\n"
+ "- INCLUDE all ink that belongs to that block.\n"
+ "- For tables, include all cells and ruling lines belonging to the target table.\n"
+ "- For lists, include all items belonging to the target list.\n"
+ "- Exclude neighboring paragraphs, headings, notes, page numbers, tables, and unrelated\n"
+ " marks or decorations.\n\n"
+ "Output JSON only with this schema:\n"
+ "{\n"
+ ' "block_found": ,\n'
+ ' "block": {\n'
+ ' "left": ,\n'
+ ' "top": ,\n'
+ ' "right": ,\n'
+ ' "bottom": \n'
+ " } | null\n"
+ "}\n\n"
+ "- If the target block is absent or cannot be uniquely identified, return "
+ '{"block_found": false, "block": null}.\n'
+ "- Provide all coordinates as integers in the 0-1000 range.\n"
+ "- Do not return any explanation.\n"
+ )
+
+
+def build_text_block_boundary_review_prompt(
+ *,
+ edge_name: str,
+ block_tag: str,
+ block_text: str,
+ strip_axis: str,
+) -> str:
+ """Build the per-edge review prompt used for iterative text-block refinement."""
+ target_tag = json.dumps(block_tag, ensure_ascii=False)
+ target_text = json.dumps(block_text, ensure_ascii=False)
+ return (
+ "You are an expert reviewer of a content-block bounding box annotation.\n"
+ "You are reviewing ONE EDGE of the red rectangle using an edge strip crop.\n"
+ "The crop is a narrow strip centered on the target edge of the current red boundary.\n\n"
+ f"Target block tag (JSON string): {target_tag}\n"
+ f"Target block text (JSON string): {target_text}\n"
+ f"Target edge: {edge_name}\n\n"
+ "CRITICAL GOAL:\n"
+ "- The rectangle must tightly contain only the single target block.\n"
+ "- Include all ink from that block and exclude neighboring blocks.\n"
+ "- For tables, include all table text and ruling lines belonging to that table.\n"
+ "- For lists, include all items belonging to that list.\n"
+ "- Exclude nearby paragraphs, headings, marginal notes, page numbers, stains, borders,\n"
+ " and unrelated annotations.\n\n"
+ "Task:\n"
+ f"- Judge ONLY the {edge_name} edge of the red rectangle.\n"
+ "- Decide whether that edge should expand, shrink, or stay unchanged.\n"
+ "- Base the decision on the target block only.\n"
+ "- Do not make decisions for the other three edges.\n\n"
+ "Output JSON only with this schema:\n"
+ "{\n"
+ f' "edge": "{edge_name}",\n'
+ ' "action": "expand" | "shrink" | "no_change",\n'
+ ' "amount": \n'
+ "}\n\n"
+ "Amount semantics:\n"
+ f"- 'amount' is a normalized 0-1000 delta along the strip's {strip_axis}.\n"
+ "- For 'no_change', return amount = 0.\n"
+ "- Prefer 'no_change' unless there is a clear improvement.\n"
+ "- Do NOT return absolute box coordinates.\n"
+ )
diff --git a/src/churro_ocr/prompts/ocr.py b/src/churro_ocr/prompts/ocr.py
new file mode 100644
index 0000000..9404200
--- /dev/null
+++ b/src/churro_ocr/prompts/ocr.py
@@ -0,0 +1,54 @@
+"""Default OCR prompts."""
+
+from __future__ import annotations
+
+import re
+
+DEFAULT_OCR_OUTPUT_TAG = "output"
+
+DEFAULT_OCR_SYSTEM_PROMPT = (
+ "You are an expert in diplomatic transcription of historical documents from various "
+ "languages. Your task is to extract the full text from a given page. Only output the "
+ f"transcribed text between <{DEFAULT_OCR_OUTPUT_TAG}> and {DEFAULT_OCR_OUTPUT_TAG}> tags."
+)
+
+DEFAULT_OCR_USER_PROMPT = (
+ "Follow these instructions:\n\n"
+ "1. You will be provided with a scanned document page.\n\n"
+ "2. Perform transcription on the entirety of the page, converting all visible text into "
+ "the following format. Include handwritten and print text, if any. Include tables, "
+ "captions, headers, main text and all other visible text.\n\n"
+ "3. If you encounter any non-text elements, simply skip them without attempting to "
+ "describe them.\n\n"
+ "4. Do not modernize or standardize the text. For example, if the transcription is using "
+ '"ſ" instead of "s" or "а" instead of "a", keep it that way.\n\n'
+ "5. When you come across text in languages other than English, transcribe it as "
+ "accurately as possible without translation.\n\n"
+ "6. Output the OCR result in the following format:\n\n"
+ f"<{DEFAULT_OCR_OUTPUT_TAG}>\n"
+ "extracted text here\n"
+ f"{DEFAULT_OCR_OUTPUT_TAG}>\n\n"
+ "Remember, your goal is to accurately transcribe the text from the scanned page as much "
+ "as possible. Process the entire page, even if it contains a large amount of text, and "
+ "provide clear, well-formatted output. Pay attention to the appropriate reading order "
+ "and layout of the text."
+)
+
+DEFAULT_MARKDOWN_OCR_USER_PROMPT = (
+ "Transcribe the full page in reading order as Markdown. Preserve headings, lists, "
+ "tables, and line breaks when they are visible."
+)
+
+
+def strip_ocr_output_tag(text: str, *, output_tag: str = DEFAULT_OCR_OUTPUT_TAG) -> str:
+ """Remove outer OCR output tags and any stray tag tokens when present."""
+ outer_wrapper_pattern = re.compile(
+ rf"^\s*<{re.escape(output_tag)}>\s*(.*?)\s*{re.escape(output_tag)}>\s*$",
+ flags=re.DOTALL,
+ )
+ match = outer_wrapper_pattern.match(text)
+ if match is not None:
+ return match.group(1).strip()
+
+ stray_tag_pattern = re.compile(rf"?{re.escape(output_tag)}\b[^>]*>", flags=re.IGNORECASE)
+ return stray_tag_pattern.sub("", text).strip()
diff --git a/src/churro_ocr/providers/__init__.py b/src/churro_ocr/providers/__init__.py
new file mode 100644
index 0000000..ecab2bd
--- /dev/null
+++ b/src/churro_ocr/providers/__init__.py
@@ -0,0 +1,93 @@
+"""Public OCR builders and page detection backends."""
+
+from __future__ import annotations
+
+from importlib import import_module
+from typing import TYPE_CHECKING, Any
+
+from churro_ocr.ocr import BatchOCRBackend
+
+if TYPE_CHECKING:
+ from churro_ocr.providers.builder import build_ocr_backend
+ from churro_ocr.providers.page_detection import (
+ AzurePageDetector,
+ LLMPageDetector,
+ locate_text_block_bbox_with_llm,
+ locate_text_block_bbox_with_llm_sync,
+ )
+ from churro_ocr.providers.specs import (
+ DEFAULT_OCR_MAX_TOKENS,
+ AzureDocumentIntelligenceOptions,
+ HuggingFaceOptions,
+ LiteLLMTransportConfig,
+ MistralOptions,
+ OCRBackendSpec,
+ OCRModelProfile,
+ OpenAICompatibleOptions,
+ VLLMOptions,
+ resolve_ocr_profile,
+ )
+
+
+_LAZY_EXPORTS = {
+ "AzureDocumentIntelligenceOptions": (
+ "churro_ocr.providers.specs",
+ "AzureDocumentIntelligenceOptions",
+ ),
+ "AzurePageDetector": ("churro_ocr.providers.page_detection", "AzurePageDetector"),
+ "build_ocr_backend": ("churro_ocr.providers.builder", "build_ocr_backend"),
+ "DEFAULT_OCR_MAX_TOKENS": ("churro_ocr.providers.specs", "DEFAULT_OCR_MAX_TOKENS"),
+ "HuggingFaceOptions": ("churro_ocr.providers.specs", "HuggingFaceOptions"),
+ "LiteLLMTransportConfig": ("churro_ocr.providers.specs", "LiteLLMTransportConfig"),
+ "LLMPageDetector": ("churro_ocr.providers.page_detection", "LLMPageDetector"),
+ "locate_text_block_bbox_with_llm": (
+ "churro_ocr.providers.page_detection",
+ "locate_text_block_bbox_with_llm",
+ ),
+ "locate_text_block_bbox_with_llm_sync": (
+ "churro_ocr.providers.page_detection",
+ "locate_text_block_bbox_with_llm_sync",
+ ),
+ "MistralOptions": ("churro_ocr.providers.specs", "MistralOptions"),
+ "OCRBackendSpec": ("churro_ocr.providers.specs", "OCRBackendSpec"),
+ "OCRModelProfile": ("churro_ocr.providers.specs", "OCRModelProfile"),
+ "OpenAICompatibleOptions": ("churro_ocr.providers.specs", "OpenAICompatibleOptions"),
+ "resolve_ocr_profile": ("churro_ocr.providers.specs", "resolve_ocr_profile"),
+ "VLLMOptions": ("churro_ocr.providers.specs", "VLLMOptions"),
+}
+
+__all__ = [
+ "AzureDocumentIntelligenceOptions",
+ "AzurePageDetector",
+ "BatchOCRBackend",
+ "build_ocr_backend",
+ "DEFAULT_OCR_MAX_TOKENS",
+ "HuggingFaceOptions",
+ "LiteLLMTransportConfig",
+ "LLMPageDetector",
+ "locate_text_block_bbox_with_llm",
+ "locate_text_block_bbox_with_llm_sync",
+ "MistralOptions",
+ "OCRBackendSpec",
+ "OCRModelProfile",
+ "OpenAICompatibleOptions",
+ "resolve_ocr_profile",
+ "VLLMOptions",
+]
+
+
+def __getattr__(name: str) -> Any:
+ """Lazy-load provider exports to avoid circular imports during package init."""
+ try:
+ module_name, attr_name = _LAZY_EXPORTS[name]
+ except KeyError as exc:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
+
+ value = getattr(import_module(module_name), attr_name)
+ globals()[name] = value
+ return value
+
+
+def __dir__() -> list[str]:
+ """Expose the lazy exports to interactive tooling."""
+ return sorted(set(globals()) | set(__all__))
diff --git a/src/churro_ocr/providers/_shared.py b/src/churro_ocr/providers/_shared.py
new file mode 100644
index 0000000..9d72254
--- /dev/null
+++ b/src/churro_ocr/providers/_shared.py
@@ -0,0 +1,93 @@
+"""Internal shared helpers for OCR provider adapters."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from typing import Any
+
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.ocr import OCRResult
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.providers.specs import ImagePreprocessor, TextPostprocessor
+from churro_ocr.templates import (
+ OCRConversation,
+ OCRPromptTemplateLike,
+ build_ocr_conversation,
+)
+
+
+def preprocess_backend_page(
+ page: DocumentPage,
+ *,
+ image_preprocessor: ImagePreprocessor,
+) -> DocumentPage:
+ """Return a page copy with the backend's explicit image preprocessing applied."""
+ return replace(page, image=image_preprocessor(page.image))
+
+
+def render_ocr_prompt(
+ processor: object,
+ template: OCRPromptTemplateLike,
+ page: DocumentPage,
+ *,
+ add_generation_prompt: bool,
+) -> tuple[str, OCRConversation]:
+ """Render a provider prompt and preserve the structured OCR conversation."""
+ conversation = build_ocr_conversation(template, page)
+ processor_apply = getattr(processor, "apply_chat_template", None)
+ if callable(processor_apply):
+ rendered = processor_apply(
+ conversation,
+ add_generation_prompt=add_generation_prompt,
+ tokenize=False,
+ )
+ return rendered, conversation
+
+ tokenizer = getattr(processor, "tokenizer", None)
+ tokenizer_apply = getattr(tokenizer, "apply_chat_template", None)
+ if callable(tokenizer_apply):
+ rendered = tokenizer_apply(
+ conversation,
+ add_generation_prompt=add_generation_prompt,
+ tokenize=False,
+ )
+ return rendered, conversation
+
+ raise ConfigurationError(
+ "OCR prompt rendering requires either `processor.apply_chat_template(...)`, "
+ "or `processor.tokenizer.apply_chat_template(...)`."
+ )
+
+
+def normalize_media_inputs(media_inputs: object | None) -> object | None:
+ """Normalize provider media inputs to list form when needed."""
+ if media_inputs is None:
+ return None
+ if isinstance(media_inputs, (list, tuple)):
+ return media_inputs
+ return [media_inputs]
+
+
+def build_ocr_result(
+ text: str,
+ *,
+ provider_name: str,
+ model_name: str,
+ text_postprocessor: TextPostprocessor,
+ metadata: dict[str, Any] | None = None,
+) -> OCRResult:
+ """Build a normalized OCR result after postprocessing."""
+ return OCRResult(
+ text=text_postprocessor(text),
+ provider_name=provider_name,
+ model_name=model_name,
+ metadata=dict(metadata or {}),
+ )
+
+
+__all__ = [
+ "build_ocr_result",
+ "normalize_media_inputs",
+ "preprocess_backend_page",
+ "render_ocr_prompt",
+]
diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py
new file mode 100644
index 0000000..2ab7585
--- /dev/null
+++ b/src/churro_ocr/providers/builder.py
@@ -0,0 +1,280 @@
+"""Public OCR backend builder."""
+
+from __future__ import annotations
+
+from churro_ocr._internal.litellm import LiteLLMTransport
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.ocr import OCRBackend
+from churro_ocr.providers.hf import (
+ DotsOCR15OCRBackend,
+ HuggingFaceVisionOCRBackend,
+ _default_dots_ocr_1_5_model_kwargs,
+)
+from churro_ocr.providers.ocr import (
+ AzureDocumentIntelligenceOCRBackend,
+ LiteLLMVisionOCRBackend,
+ MistralOCRBackend,
+ OpenAICompatibleOCRBackend,
+)
+from churro_ocr.providers.specs import (
+ AzureDocumentIntelligenceOptions,
+ HuggingFaceOptions,
+ LiteLLMTransportConfig,
+ MistralOptions,
+ OCRBackendSpec,
+ OCRModelProfile,
+ OpenAICompatibleOptions,
+ VLLMOptions,
+ resolve_ocr_profile,
+)
+from churro_ocr.providers.vllm import VLLMVisionOCRBackend
+
+
+def _merge_mapping(
+ base: dict[str, object],
+ override: dict[str, object],
+) -> dict[str, object]:
+ merged = dict(base)
+ for key, value in override.items():
+ existing = merged.get(key)
+ if isinstance(existing, dict) and isinstance(value, dict):
+ merged[key] = {**existing, **value}
+ continue
+ merged[key] = value
+ return merged
+
+
+def _merge_transport_config(
+ base: LiteLLMTransportConfig,
+ override: LiteLLMTransportConfig | None,
+) -> LiteLLMTransportConfig:
+ if override is None:
+ return LiteLLMTransportConfig(
+ api_base=base.api_base,
+ api_key=base.api_key,
+ api_version=base.api_version,
+ image_detail=base.image_detail,
+ completion_kwargs=dict(base.completion_kwargs),
+ cache_dir=base.cache_dir,
+ )
+ return LiteLLMTransportConfig(
+ api_base=override.api_base if override.api_base is not None else base.api_base,
+ api_key=override.api_key if override.api_key is not None else base.api_key,
+ api_version=override.api_version if override.api_version is not None else base.api_version,
+ image_detail=override.image_detail if override.image_detail is not None else base.image_detail,
+ completion_kwargs=_merge_mapping(base.completion_kwargs, override.completion_kwargs),
+ cache_dir=override.cache_dir if override.cache_dir is not None else base.cache_dir,
+ )
+
+
+def _merge_huggingface_options(
+ base: HuggingFaceOptions,
+ override: HuggingFaceOptions | None,
+) -> HuggingFaceOptions:
+ if override is None:
+ return HuggingFaceOptions(
+ trust_remote_code=base.trust_remote_code,
+ processor_kwargs=dict(base.processor_kwargs),
+ model_kwargs=dict(base.model_kwargs),
+ generation_kwargs=dict(base.generation_kwargs),
+ vision_input_builder=base.vision_input_builder,
+ backend_variant=base.backend_variant,
+ )
+ return HuggingFaceOptions(
+ trust_remote_code=(
+ override.trust_remote_code if override.trust_remote_code is not None else base.trust_remote_code
+ ),
+ processor_kwargs=_merge_mapping(base.processor_kwargs, override.processor_kwargs),
+ model_kwargs=_merge_mapping(base.model_kwargs, override.model_kwargs),
+ generation_kwargs=_merge_mapping(base.generation_kwargs, override.generation_kwargs),
+ vision_input_builder=override.vision_input_builder or base.vision_input_builder,
+ backend_variant=override.backend_variant or base.backend_variant,
+ )
+
+
+def _merge_vllm_options(
+ base: VLLMOptions,
+ override: VLLMOptions | None,
+) -> VLLMOptions:
+ if override is None:
+ return VLLMOptions(
+ trust_remote_code=base.trust_remote_code,
+ processor_kwargs=dict(base.processor_kwargs),
+ llm_kwargs=dict(base.llm_kwargs),
+ sampling_kwargs=dict(base.sampling_kwargs),
+ limit_mm_per_prompt=dict(base.limit_mm_per_prompt),
+ )
+ return VLLMOptions(
+ trust_remote_code=(
+ override.trust_remote_code if override.trust_remote_code is not None else base.trust_remote_code
+ ),
+ processor_kwargs=_merge_mapping(base.processor_kwargs, override.processor_kwargs),
+ llm_kwargs=_merge_mapping(base.llm_kwargs, override.llm_kwargs),
+ sampling_kwargs=_merge_mapping(base.sampling_kwargs, override.sampling_kwargs),
+ limit_mm_per_prompt={
+ **base.limit_mm_per_prompt,
+ **override.limit_mm_per_prompt,
+ },
+ )
+
+
+def _merge_openai_options(
+ override: OpenAICompatibleOptions | None,
+) -> OpenAICompatibleOptions:
+ if override is None:
+ return OpenAICompatibleOptions()
+ return OpenAICompatibleOptions(model_prefix=override.model_prefix)
+
+
+def _ensure_options_type[T](options: object | None, expected: type[T], *, provider: str) -> T | None:
+ if options is None:
+ return None
+ if not isinstance(options, expected):
+ raise ConfigurationError(
+ f"OCR provider '{provider}' requires options of type {expected.__name__}, "
+ f"got {type(options).__name__}."
+ )
+ return options
+
+
+def _resolve_model_name(profile: OCRModelProfile, model: str | None, *, fallback: str) -> str:
+ if profile.display_name is not None:
+ return profile.display_name
+ if model is not None:
+ return model
+ return fallback
+
+
+def _build_litellm_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend:
+ if spec.model is None:
+ raise ConfigurationError("OCR provider 'litellm' requires `model`.")
+ transport_config = _merge_transport_config(profile.transport, spec.transport)
+ return LiteLLMVisionOCRBackend(
+ model=spec.model,
+ template=profile.template,
+ model_name=_resolve_model_name(profile, spec.model, fallback=spec.model),
+ transport=LiteLLMTransport(transport_config),
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+def _build_openai_compatible_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend:
+ if spec.model is None:
+ raise ConfigurationError("OCR provider 'openai-compatible' requires `model`.")
+ options = _merge_openai_options(
+ _ensure_options_type(spec.options, OpenAICompatibleOptions, provider=spec.provider)
+ )
+ transport_config = _merge_transport_config(profile.transport, spec.transport)
+ if not transport_config.api_base or not transport_config.api_key:
+ raise ConfigurationError(
+ "OCR provider 'openai-compatible' requires `transport.api_base` and `transport.api_key`."
+ )
+ return OpenAICompatibleOCRBackend(
+ model=spec.model,
+ model_prefix=options.model_prefix or "openai",
+ model_name=_resolve_model_name(profile, spec.model, fallback=spec.model),
+ template=profile.template,
+ transport=LiteLLMTransport(transport_config),
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend:
+ if spec.model is None:
+ raise ConfigurationError("OCR provider 'hf' requires `model`.")
+ options = _merge_huggingface_options(
+ profile.huggingface,
+ _ensure_options_type(spec.options, HuggingFaceOptions, provider=spec.provider),
+ )
+ backend_cls: type[HuggingFaceVisionOCRBackend] = HuggingFaceVisionOCRBackend
+ model_kwargs = dict(options.model_kwargs)
+ if options.backend_variant == "dots-ocr-1.5":
+ backend_cls = DotsOCR15OCRBackend
+ model_kwargs = _merge_mapping(_default_dots_ocr_1_5_model_kwargs(), model_kwargs)
+ return backend_cls(
+ model_id=spec.model,
+ template=profile.template,
+ model_name=_resolve_model_name(profile, spec.model, fallback=spec.model),
+ trust_remote_code=bool(options.trust_remote_code),
+ processor_kwargs=dict(options.processor_kwargs),
+ model_kwargs=model_kwargs,
+ generation_kwargs=dict(options.generation_kwargs),
+ vision_input_builder=options.vision_input_builder,
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+def _build_vllm_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend:
+ if spec.model is None:
+ raise ConfigurationError("OCR provider 'vllm' requires `model`.")
+ options = _merge_vllm_options(
+ profile.vllm,
+ _ensure_options_type(spec.options, VLLMOptions, provider=spec.provider),
+ )
+ return VLLMVisionOCRBackend(
+ model_id=spec.model,
+ template=profile.template,
+ model_name=_resolve_model_name(profile, spec.model, fallback=spec.model),
+ trust_remote_code=bool(options.trust_remote_code),
+ processor_kwargs=dict(options.processor_kwargs),
+ llm_kwargs=dict(options.llm_kwargs),
+ sampling_kwargs=dict(options.sampling_kwargs),
+ limit_mm_per_prompt=dict(options.limit_mm_per_prompt) or {"image": 1},
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+def _build_azure_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend:
+ options = _ensure_options_type(spec.options, AzureDocumentIntelligenceOptions, provider=spec.provider)
+ if options is None or not options.endpoint or not options.api_key:
+ raise ConfigurationError(
+ "OCR provider 'azure' requires AzureDocumentIntelligenceOptions(endpoint=..., api_key=...)."
+ )
+ model_id = spec.model or "prebuilt-layout"
+ return AzureDocumentIntelligenceOCRBackend(
+ endpoint=options.endpoint,
+ api_key=options.api_key,
+ model_id=model_id,
+ model_name=_resolve_model_name(profile, spec.model, fallback=model_id),
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+def _build_mistral_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend:
+ options = _ensure_options_type(spec.options, MistralOptions, provider=spec.provider)
+ if options is None or not options.api_key:
+ raise ConfigurationError("OCR provider 'mistral' requires MistralOptions(api_key=...).")
+ model = spec.model or "mistral-ocr-latest"
+ return MistralOCRBackend(
+ api_key=options.api_key,
+ model=model,
+ model_name=_resolve_model_name(profile, spec.model, fallback=model),
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+def build_ocr_backend(spec: OCRBackendSpec) -> OCRBackend:
+ """Build an OCR backend from a declarative spec."""
+ profile = resolve_ocr_profile(model_id=spec.model, profile=spec.profile)
+ if spec.provider == "litellm":
+ return _build_litellm_backend(spec, profile)
+ if spec.provider == "openai-compatible":
+ return _build_openai_compatible_backend(spec, profile)
+ if spec.provider == "hf":
+ return _build_huggingface_backend(spec, profile)
+ if spec.provider == "vllm":
+ return _build_vllm_backend(spec, profile)
+ if spec.provider == "azure":
+ return _build_azure_backend(spec, profile)
+ if spec.provider == "mistral":
+ return _build_mistral_backend(spec, profile)
+ raise ConfigurationError(f"Unsupported OCR provider '{spec.provider}'.")
+
+
+__all__ = ["build_ocr_backend"]
diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py
new file mode 100644
index 0000000..2e2ce63
--- /dev/null
+++ b/src/churro_ocr/providers/hf.py
@@ -0,0 +1,459 @@
+"""Hugging Face OCR backends for template-aware multimodal models."""
+
+from __future__ import annotations
+
+import asyncio
+import threading
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from churro_ocr._internal.prompt_logging import log_prompt_payload_once
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.ocr import OCRBackend, OCRResult
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.providers._shared import (
+ build_ocr_result,
+ normalize_media_inputs,
+ preprocess_backend_page,
+ render_ocr_prompt,
+)
+from churro_ocr.providers.specs import (
+ DEFAULT_OCR_MAX_TOKENS,
+ ImagePreprocessor,
+ TextPostprocessor,
+ VisionInputBuilder,
+ default_ocr_image_preprocessor,
+ identity_text_postprocessor,
+)
+from churro_ocr.templates import (
+ CHURRO_3B_MODEL_ID,
+ CHURRO_3B_XML_TEMPLATE,
+ DOTS_OCR_1_5_MODEL_ID,
+ DOTS_OCR_1_5_OCR_TEMPLATE,
+ OCRConversation,
+ OCRPromptTemplateLike,
+)
+
+
+@dataclass(slots=True)
+class _HFRuntime:
+ processor_cls: Any
+ model_cls: Any
+ process_vision_info: Any
+
+
+def _load_hf_runtime() -> _HFRuntime:
+ try:
+ from qwen_vl_utils import process_vision_info
+ from transformers import AutoModelForImageTextToText, AutoProcessor
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ "Hugging Face OCR requires the 'hf' extra. Install with `pip install \"churro-ocr[hf]\"`."
+ ) from exc
+
+ return _HFRuntime(
+ processor_cls=AutoProcessor,
+ model_cls=AutoModelForImageTextToText,
+ process_vision_info=process_vision_info,
+ )
+
+
+def _load_hf_causal_runtime() -> _HFRuntime:
+ try:
+ from qwen_vl_utils import process_vision_info
+ from transformers import AutoModelForCausalLM, AutoProcessor
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ "Hugging Face OCR requires the 'hf' extra. Install with `pip install \"churro-ocr[hf]\"`."
+ ) from exc
+
+ return _HFRuntime(
+ processor_cls=AutoProcessor,
+ model_cls=AutoModelForCausalLM,
+ process_vision_info=process_vision_info,
+ )
+
+
+_DOTS_OCR_1_5_LOCAL_DIRNAME = "DotsOCR_1_5"
+_DOTS_FLASH_ATTN_IMPORT = "from flash_attn import flash_attn_varlen_func"
+_DOTS_FLASH_ATTN_FALLBACK = """try:
+ from flash_attn import flash_attn_varlen_func
+except ImportError:
+ flash_attn_varlen_func = None
+"""
+_DOTS_FORCE_BFLOAT16_LINE = " hidden_states = hidden_states.bfloat16()"
+_DOTS_WEIGHT_DTYPE_LINE = (
+ " hidden_states = hidden_states.to(self.patch_embed.patchifier.proj.weight.dtype)"
+)
+
+
+def _patch_dots_ocr_vision_module(model_dir: Path) -> None:
+ vision_module_path = model_dir / "modeling_dots_vision.py"
+ vision_module = vision_module_path.read_text()
+ if _DOTS_FLASH_ATTN_IMPORT not in vision_module and _DOTS_FLASH_ATTN_FALLBACK in vision_module:
+ return
+ vision_lines = vision_module.splitlines()
+ import_index = next(
+ (index for index, line in enumerate(vision_lines) if _DOTS_FLASH_ATTN_IMPORT in line),
+ None,
+ )
+ if import_index is None:
+ return
+
+ block_tokens = {"", "try:", "except ImportError:", "flash_attn_varlen_func = None"}
+ block_start = import_index
+ while block_start > 0 and vision_lines[block_start - 1].strip() in block_tokens:
+ block_start -= 1
+
+ block_end = import_index + 1
+ while block_end < len(vision_lines) and vision_lines[block_end].strip() in block_tokens:
+ block_end += 1
+
+ patched_lines = (
+ vision_lines[:block_start]
+ + _DOTS_FLASH_ATTN_FALLBACK.rstrip("\n").splitlines()
+ + vision_lines[block_end:]
+ )
+ patched_vision_module = "\n".join(patched_lines) + "\n"
+ if _DOTS_FORCE_BFLOAT16_LINE in patched_vision_module:
+ patched_vision_module = patched_vision_module.replace(
+ _DOTS_FORCE_BFLOAT16_LINE,
+ _DOTS_WEIGHT_DTYPE_LINE,
+ )
+ vision_module_path.write_text(patched_vision_module)
+
+
+def _prepare_dots_ocr_model_dir(model_id: str) -> str:
+ try:
+ from huggingface_hub import snapshot_download
+ except ImportError as exc: # pragma: no cover - transitively provided by transformers
+ raise ConfigurationError(
+ 'Hugging Face OCR requires huggingface_hub. Install with `pip install "churro-ocr[hf]"`.'
+ ) from exc
+
+ model_dir = (
+ Path.home()
+ / ".cache"
+ / "churro-ocr"
+ / "hf"
+ / _DOTS_OCR_1_5_LOCAL_DIRNAME
+ / model_id.replace("/", "__").replace(".", "_")
+ )
+ snapshot_download(repo_id=model_id, local_dir=model_dir)
+ _patch_dots_ocr_vision_module(model_dir)
+ return str(model_dir)
+
+
+def _default_dots_ocr_1_5_model_kwargs() -> dict[str, object]:
+ model_kwargs: dict[str, object] = {"dtype": "auto"}
+ try:
+ import torch
+ except ImportError: # pragma: no cover - torch comes from the hf extra
+ return model_kwargs
+
+ if not torch.cuda.is_available():
+ return model_kwargs
+
+ free_bytes, _ = torch.cuda.mem_get_info()
+ free_gib = max(1, int(free_bytes / (1024**3)) - 1)
+ if free_gib < 8:
+ return {"dtype": "float32"}
+
+ model_kwargs["device_map"] = "auto"
+ model_kwargs["max_memory"] = {0: f"{free_gib}GiB", "cpu": "128GiB"}
+ return model_kwargs
+
+
+@dataclass(slots=True)
+class HuggingFaceVisionOCRBackend(OCRBackend):
+ """OCR backend for local Hugging Face multimodal models with custom templates."""
+
+ model_id: str
+ template: OCRPromptTemplateLike
+ model_name: str | None = None
+ trust_remote_code: bool = False
+ processor_kwargs: dict[str, object] = field(default_factory=dict)
+ model_kwargs: dict[str, object] = field(default_factory=dict)
+ generation_kwargs: dict[str, object] = field(default_factory=dict)
+ vision_input_builder: VisionInputBuilder | None = None
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor
+ text_postprocessor: TextPostprocessor = identity_text_postprocessor
+ provider_name: str = "huggingface-transformers"
+ _processor: object | None = field(default=None, init=False, repr=False)
+ _model: object | None = field(default=None, init=False, repr=False)
+ _model_source: str | None = field(default=None, init=False, repr=False)
+ _init_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
+ _has_logged_prompt: bool = field(default=False, init=False, repr=False)
+ _prompt_log_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ self.generation_kwargs = {
+ "max_new_tokens": DEFAULT_OCR_MAX_TOKENS,
+ **self.generation_kwargs,
+ }
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ return await asyncio.to_thread(self._ocr_sync, page)
+
+ async def ocr_batch(self, pages: list[DocumentPage]) -> list[OCRResult]:
+ return await asyncio.to_thread(self._ocr_batch_sync, pages)
+
+ def _ocr_sync(self, page: DocumentPage) -> OCRResult:
+ prepared_page = preprocess_backend_page(
+ page,
+ image_preprocessor=self.image_preprocessor,
+ )
+ runtime = self._load_runtime()
+ processor = self._get_processor(runtime)
+ model = self._get_model(runtime)
+
+ rendered, conversation = render_ocr_prompt(
+ processor,
+ self.template,
+ prepared_page,
+ add_generation_prompt=True,
+ )
+ self._log_prompt_payload(
+ rendered_prompt=rendered,
+ conversation=conversation,
+ batch_size=1,
+ )
+ image_inputs, video_inputs = self._build_vision_inputs(runtime, conversation)
+ batch_kwargs: dict[str, object] = {
+ "text": [rendered],
+ "images": normalize_media_inputs(image_inputs),
+ "return_tensors": "pt",
+ "padding": True,
+ }
+ normalized_video_inputs = normalize_media_inputs(video_inputs)
+ if normalized_video_inputs is not None:
+ batch_kwargs["videos"] = normalized_video_inputs
+ batch = processor(**batch_kwargs)
+ model_device = getattr(model, "device", None)
+ if hasattr(batch, "to") and model_device is not None:
+ batch = batch.to(model_device)
+ model_dtype = getattr(model, "dtype", None)
+ if model_dtype is not None:
+ for key, value in batch.items():
+ if hasattr(value, "dtype") and getattr(value.dtype, "is_floating_point", False):
+ batch[key] = value.to(dtype=model_dtype)
+
+ generated_ids = model.generate(**batch, **self.generation_kwargs)
+ prompt_length = batch["input_ids"].shape[1]
+ completion_ids = generated_ids[:, prompt_length:]
+ text = processor.batch_decode(
+ completion_ids,
+ skip_special_tokens=True,
+ clean_up_tokenization_spaces=False,
+ )[0]
+ return build_ocr_result(
+ text,
+ provider_name=self.provider_name,
+ model_name=self.model_name or self.model_id,
+ text_postprocessor=self.text_postprocessor,
+ )
+
+ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]:
+ if not pages:
+ return []
+
+ runtime = self._load_runtime()
+ processor = self._get_processor(runtime)
+ model = self._get_model(runtime)
+ rendered_prompts: list[str] = []
+ image_batch: list[object] = []
+ video_batch: list[object] = []
+ has_videos = False
+
+ for page in pages:
+ prepared_page = preprocess_backend_page(
+ page,
+ image_preprocessor=self.image_preprocessor,
+ )
+ rendered, conversation = render_ocr_prompt(
+ processor,
+ self.template,
+ prepared_page,
+ add_generation_prompt=True,
+ )
+ image_inputs, video_inputs = self._build_vision_inputs(runtime, conversation)
+ rendered_prompts.append(rendered)
+ image_batch.append(normalize_media_inputs(image_inputs))
+ normalized_video_inputs = normalize_media_inputs(video_inputs)
+ if normalized_video_inputs is not None:
+ has_videos = True
+ video_batch.append(normalized_video_inputs)
+ if not self._has_logged_prompt:
+ self._log_prompt_payload(
+ rendered_prompt=rendered,
+ conversation=conversation,
+ batch_size=len(pages),
+ )
+
+ batch_kwargs: dict[str, object] = {
+ "text": rendered_prompts,
+ "images": image_batch,
+ "return_tensors": "pt",
+ "padding": True,
+ }
+ if has_videos:
+ batch_kwargs["videos"] = video_batch
+ batch = processor(**batch_kwargs)
+ model_device = getattr(model, "device", None)
+ if hasattr(batch, "to") and model_device is not None:
+ batch = batch.to(model_device)
+ model_dtype = getattr(model, "dtype", None)
+ if model_dtype is not None:
+ for key, value in batch.items():
+ if hasattr(value, "dtype") and getattr(value.dtype, "is_floating_point", False):
+ batch[key] = value.to(dtype=model_dtype)
+
+ generated_ids = model.generate(**batch, **self.generation_kwargs)
+ prompt_lengths = batch["attention_mask"].sum(dim=1).tolist()
+ completion_ids = [
+ output_ids[int(prompt_length) :]
+ for prompt_length, output_ids in zip(prompt_lengths, generated_ids, strict=True)
+ ]
+ texts = processor.batch_decode(
+ completion_ids,
+ skip_special_tokens=True,
+ clean_up_tokenization_spaces=False,
+ )
+ return [
+ build_ocr_result(
+ text,
+ provider_name=self.provider_name,
+ model_name=self.model_name or self.model_id,
+ text_postprocessor=self.text_postprocessor,
+ )
+ for text in texts
+ ]
+
+ def _load_runtime(self) -> _HFRuntime:
+ return _load_hf_runtime()
+
+ def _resolve_model_source(self) -> str:
+ return self.model_id
+
+ def _get_model_source(self) -> str:
+ if self._model_source is None:
+ with self._init_lock:
+ if self._model_source is None:
+ self._model_source = self._resolve_model_source()
+ return self._model_source
+
+ def _build_vision_inputs(
+ self,
+ runtime: _HFRuntime,
+ conversation: OCRConversation,
+ ) -> tuple[object | None, object | None]:
+ if self.vision_input_builder is not None:
+ built_inputs = self.vision_input_builder(conversation)
+ if isinstance(built_inputs, tuple) and len(built_inputs) == 2:
+ return built_inputs[0], built_inputs[1]
+ return built_inputs, None
+ image_inputs, video_inputs, _ = runtime.process_vision_info(
+ conversation,
+ return_video_kwargs=True,
+ return_video_metadata=True,
+ )
+ return image_inputs, video_inputs
+
+ def _get_processor(self, runtime: _HFRuntime) -> Any:
+ if self._processor is None:
+ with self._init_lock:
+ if self._processor is None:
+ self._processor = runtime.processor_cls.from_pretrained(
+ self._get_model_source(),
+ trust_remote_code=self.trust_remote_code,
+ **self.processor_kwargs,
+ )
+ return self._processor
+
+ def _get_model(self, runtime: _HFRuntime) -> Any:
+ if self._model is None:
+ with self._init_lock:
+ if self._model is None:
+ self._model = runtime.model_cls.from_pretrained(
+ self._get_model_source(),
+ trust_remote_code=self.trust_remote_code,
+ **self.model_kwargs,
+ )
+ return self._model
+
+ def _log_prompt_payload(
+ self,
+ *,
+ rendered_prompt: str,
+ conversation: OCRConversation,
+ batch_size: int,
+ ) -> None:
+ log_prompt_payload_once(
+ payload={
+ "batch_size": batch_size,
+ "conversation": conversation,
+ "rendered_prompt": rendered_prompt,
+ },
+ provider_name=self.provider_name,
+ has_logged=lambda: self._has_logged_prompt,
+ lock=self._prompt_log_lock,
+ set_logged=lambda: setattr(self, "_has_logged_prompt", True),
+ )
+
+
+@dataclass(slots=True)
+class Churro3BOCRBackend(HuggingFaceVisionOCRBackend):
+ """Preset OCR backend for `stanford-oval/churro-3B`."""
+
+ model_id: str = CHURRO_3B_MODEL_ID
+ template: OCRPromptTemplateLike = CHURRO_3B_XML_TEMPLATE
+ model_name: str | None = "churro-3B"
+
+
+@dataclass(slots=True)
+class DotsOCR15OCRBackend(HuggingFaceVisionOCRBackend):
+ """Preset OCR backend for `kristaller486/dots.ocr-1.5`."""
+
+ model_id: str = DOTS_OCR_1_5_MODEL_ID
+ template: OCRPromptTemplateLike = DOTS_OCR_1_5_OCR_TEMPLATE
+ model_name: str | None = "dots.ocr-1.5"
+ trust_remote_code: bool = True
+ model_kwargs: dict[str, object] = field(default_factory=_default_dots_ocr_1_5_model_kwargs)
+
+ def _load_runtime(self) -> _HFRuntime:
+ return _load_hf_causal_runtime()
+
+ def _resolve_model_source(self) -> str:
+ return _prepare_dots_ocr_model_dir(self.model_id)
+
+ def _get_model(self, runtime: _HFRuntime) -> Any:
+ if self._model is None:
+ with self._init_lock:
+ if self._model is None:
+ from transformers import AutoConfig
+
+ model_source = self._get_model_source()
+ config = AutoConfig.from_pretrained(
+ model_source,
+ trust_remote_code=self.trust_remote_code,
+ )
+ vision_config = getattr(config, "vision_config", None)
+ if isinstance(vision_config, dict):
+ vision_config["attn_implementation"] = "sdpa"
+ elif vision_config is not None:
+ vision_config.attn_implementation = "sdpa"
+ self._model = runtime.model_cls.from_pretrained(
+ model_source,
+ config=config,
+ trust_remote_code=self.trust_remote_code,
+ **self.model_kwargs,
+ )
+ return self._model
+
+
+__all__ = [
+ "Churro3BOCRBackend",
+ "DotsOCR15OCRBackend",
+ "HuggingFaceVisionOCRBackend",
+]
diff --git a/src/churro_ocr/providers/ocr.py b/src/churro_ocr/providers/ocr.py
new file mode 100644
index 0000000..797e927
--- /dev/null
+++ b/src/churro_ocr/providers/ocr.py
@@ -0,0 +1,262 @@
+"""Built-in OCR backends."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+from dataclasses import dataclass, field
+from io import BytesIO
+from threading import Lock
+from typing import Any
+
+from churro_ocr._internal.image import image_to_base64
+from churro_ocr._internal.litellm import LiteLLMTransport
+from churro_ocr._internal.prompt_logging import log_prompt_payload_once
+from churro_ocr.errors import ConfigurationError, ProviderError
+from churro_ocr.ocr import OCRBackend, OCRResult
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.providers._shared import build_ocr_result, preprocess_backend_page
+from churro_ocr.providers.specs import (
+ DEFAULT_OCR_MAX_TOKENS,
+ ImagePreprocessor,
+ LiteLLMTransportConfig,
+ TextPostprocessor,
+ default_ocr_image_preprocessor,
+ identity_text_postprocessor,
+)
+from churro_ocr.templates import (
+ DEFAULT_OCR_TEMPLATE,
+ OCRPromptTemplateLike,
+ build_ocr_conversation,
+)
+
+
+def _with_default_ocr_completion_kwargs(config: LiteLLMTransportConfig) -> LiteLLMTransportConfig:
+ completion_kwargs: dict[str, object] = {"max_tokens": DEFAULT_OCR_MAX_TOKENS}
+ completion_kwargs.update(config.completion_kwargs)
+ if completion_kwargs == config.completion_kwargs:
+ return config
+ return LiteLLMTransportConfig(
+ api_base=config.api_base,
+ api_key=config.api_key,
+ api_version=config.api_version,
+ image_detail=config.image_detail,
+ completion_kwargs=completion_kwargs,
+ cache_dir=config.cache_dir,
+ )
+
+
+@dataclass(slots=True)
+class LiteLLMVisionOCRBackend(OCRBackend):
+ """OCR backend for any LiteLLM-supported multimodal provider."""
+
+ model: str
+ template: OCRPromptTemplateLike = DEFAULT_OCR_TEMPLATE
+ model_name: str | None = None
+ transport: LiteLLMTransport = field(default_factory=LiteLLMTransport)
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor
+ text_postprocessor: TextPostprocessor = identity_text_postprocessor
+ provider_name: str = "litellm"
+ _has_logged_prompt: bool = field(default=False, init=False, repr=False)
+ _prompt_log_lock: Lock = field(default_factory=Lock, init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ config = _with_default_ocr_completion_kwargs(self.transport.config)
+ if config != self.transport.config:
+ object.__setattr__(self.transport, "_config", config)
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ prepared_page = preprocess_backend_page(
+ page,
+ image_preprocessor=self.image_preprocessor,
+ )
+ conversation = build_ocr_conversation(self.template, prepared_page)
+ messages = await asyncio.to_thread(
+ self.transport.prepare_messages_from_conversation,
+ conversation,
+ )
+ log_prompt_payload_once(
+ payload={"messages": messages},
+ provider_name=self.provider_name,
+ has_logged=lambda: self._has_logged_prompt,
+ lock=self._prompt_log_lock,
+ set_logged=lambda: setattr(self, "_has_logged_prompt", True),
+ )
+ text = await self.transport.complete_text(
+ model=self.model,
+ messages=messages,
+ )
+ return build_ocr_result(
+ text,
+ provider_name=self.provider_name,
+ model_name=self.model_name or self.model,
+ text_postprocessor=self.text_postprocessor,
+ )
+
+
+@dataclass(slots=True, init=False)
+class OpenAICompatibleOCRBackend(LiteLLMVisionOCRBackend):
+ """OCR backend for OpenAI-compatible servers."""
+
+ def __init__(
+ self,
+ *,
+ model: str,
+ transport: LiteLLMTransport | None = None,
+ model_prefix: str = "openai",
+ template: OCRPromptTemplateLike = DEFAULT_OCR_TEMPLATE,
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor,
+ text_postprocessor: TextPostprocessor = identity_text_postprocessor,
+ model_name: str | None = None,
+ ) -> None:
+ LiteLLMVisionOCRBackend.__init__(
+ self,
+ model=f"{model_prefix}/{model}",
+ template=template,
+ model_name=model_name or model,
+ transport=transport or LiteLLMTransport(),
+ image_preprocessor=image_preprocessor,
+ text_postprocessor=text_postprocessor,
+ provider_name="openai-compatible",
+ )
+
+
+@dataclass(slots=True)
+class AzureDocumentIntelligenceOCRBackend(OCRBackend):
+ """Azure Document Intelligence OCR backend."""
+
+ endpoint: str
+ api_key: str
+ model_id: str = "prebuilt-layout"
+ model_name: str | None = None
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor
+ text_postprocessor: TextPostprocessor = identity_text_postprocessor
+ _client: Any | None = field(default=None, init=False, repr=False)
+ _client_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
+ _has_logged_prompt: bool = field(default=False, init=False, repr=False)
+ _prompt_log_lock: Lock = field(default_factory=Lock, init=False, repr=False)
+
+ async def _get_client(self) -> Any:
+ client = self._client
+ if client is not None:
+ return client
+
+ async with self._client_lock:
+ client = self._client
+ if client is not None:
+ return client
+ try:
+ from azure.ai.documentintelligence.aio import DocumentIntelligenceClient
+ from azure.core.credentials import AzureKeyCredential
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ "Azure OCR requires the 'azure' extra. Install with `pip install \"churro-ocr[azure]\"`."
+ ) from exc
+
+ client = DocumentIntelligenceClient(
+ endpoint=self.endpoint,
+ credential=AzureKeyCredential(self.api_key),
+ )
+ self._client = client
+ return client
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ prepared_page = preprocess_backend_page(
+ page,
+ image_preprocessor=self.image_preprocessor,
+ )
+ buffer = (await asyncio.to_thread(image_to_base64, prepared_page.image, "JPEG"))[1]
+ image_bytes = base64.b64decode(buffer)
+ log_prompt_payload_once(
+ payload={
+ "model_id": self.model_id,
+ "content_type": "application/octet-stream",
+ "document": {"type": "image", "image": prepared_page.image},
+ },
+ provider_name="azure-document-intelligence",
+ has_logged=lambda: self._has_logged_prompt,
+ lock=self._prompt_log_lock,
+ set_logged=lambda: setattr(self, "_has_logged_prompt", True),
+ )
+ client = await self._get_client()
+ poller = await client.begin_analyze_document(
+ model_id=self.model_id,
+ body=BytesIO(image_bytes),
+ content_type="application/octet-stream",
+ )
+ result = await poller.result()
+ if not isinstance(result.content, str):
+ raise ProviderError("Azure Document Intelligence returned no OCR text.")
+ return build_ocr_result(
+ result.content,
+ provider_name="azure-document-intelligence",
+ model_name=self.model_name or self.model_id,
+ text_postprocessor=self.text_postprocessor,
+ )
+
+
+@dataclass(slots=True)
+class MistralOCRBackend(OCRBackend):
+ """Mistral OCR backend."""
+
+ api_key: str
+ model: str = "mistral-ocr-latest"
+ model_name: str | None = None
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor
+ text_postprocessor: TextPostprocessor = identity_text_postprocessor
+ _client: Any | None = field(default=None, init=False, repr=False)
+ _client_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
+ _has_logged_prompt: bool = field(default=False, init=False, repr=False)
+ _prompt_log_lock: Lock = field(default_factory=Lock, init=False, repr=False)
+
+ async def _get_client(self) -> Any:
+ client = self._client
+ if client is not None:
+ return client
+
+ async with self._client_lock:
+ client = self._client
+ if client is not None:
+ return client
+ try:
+ from mistralai import Mistral
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ "Mistral OCR requires the 'mistral' extra. "
+ 'Install with `pip install "churro-ocr[mistral]"`.'
+ ) from exc
+
+ client = Mistral(api_key=self.api_key)
+ self._client = client
+ return client
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ prepared_page = preprocess_backend_page(
+ page,
+ image_preprocessor=self.image_preprocessor,
+ )
+ _, encoded = await asyncio.to_thread(image_to_base64, prepared_page.image, "JPEG")
+ image_url = f"data:image/jpeg;base64,{encoded}"
+ log_prompt_payload_once(
+ payload={
+ "model": self.model,
+ "document": {"type": "image_url", "image_url": image_url},
+ },
+ provider_name="mistral",
+ has_logged=lambda: self._has_logged_prompt,
+ lock=self._prompt_log_lock,
+ set_logged=lambda: setattr(self, "_has_logged_prompt", True),
+ )
+ client = await self._get_client()
+ response = await client.ocr.process_async(
+ model=self.model,
+ document={"type": "image_url", "image_url": image_url},
+ )
+ if not response.pages:
+ raise ProviderError("Mistral OCR returned no pages.")
+ return build_ocr_result(
+ response.pages[0].markdown,
+ provider_name="mistral",
+ model_name=self.model_name or self.model,
+ text_postprocessor=self.text_postprocessor,
+ )
diff --git a/src/churro_ocr/providers/page_detection.py b/src/churro_ocr/providers/page_detection.py
new file mode 100644
index 0000000..501eaf8
--- /dev/null
+++ b/src/churro_ocr/providers/page_detection.py
@@ -0,0 +1,1471 @@
+"""Built-in page detection backends."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from dataclasses import dataclass
+from io import BytesIO
+from typing import TYPE_CHECKING, Any, Literal, cast
+
+from PIL import Image, ImageDraw, ImageOps
+
+from churro_ocr._internal.litellm import LiteLLMTransport
+from churro_ocr._internal.logging import logger
+from churro_ocr._internal.runtime import run_sync
+from churro_ocr.errors import ConfigurationError, ProviderError
+from churro_ocr.page_detection import PageCandidate, PageDetectionBackend
+from churro_ocr.prompts import DEFAULT_BOUNDARY_DETECTION_PROMPT
+from churro_ocr.prompts.layout import (
+ build_boundary_review_prompt,
+ build_text_block_boundary_review_prompt,
+ build_text_block_localization_prompt,
+)
+from churro_ocr.providers.specs import LiteLLMTransportConfig
+
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable, Sequence
+
+_BORDER_FRACTION = 0.05
+_PROCESSED_MAX_DIM = 2500
+_PAGE_DETECTION_BOX_WIDTH = 10
+_TEXT_BLOCK_DETECTION_BOX_WIDTH = 6
+_REVIEW_CROP_MARGIN_FRACTION = 0.12
+_TEXT_BLOCK_REVIEW_CROP_MARGIN_FRACTION = 0.22
+_REVIEW_EDGE_STOP_DEADBAND = 6
+_REVIEW_EDGE_STOP_STABLE_ROUNDS = 2
+_REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MIN = 0.5
+_REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MAX = 2.0
+_GUIDELINE_COLOR = "#ff3b30"
+_SCALE_WITH_BORDER = 1 + (2 * _BORDER_FRACTION)
+_NORMALIZED_MIN_COORD = (_BORDER_FRACTION / _SCALE_WITH_BORDER) * 1000
+_NORMALIZED_MAX_COORD = ((1 + _BORDER_FRACTION) / _SCALE_WITH_BORDER) * 1000
+_EDGE_NAMES = ("left", "top", "right", "bottom")
+LiteLLMTransportLike = LiteLLMTransportConfig | LiteLLMTransport | None
+
+
+def _full_image_candidate(image: Image.Image) -> PageCandidate:
+ return PageCandidate(bbox=(0.0, 0.0, float(image.width), float(image.height)))
+
+
+def _bbox_from_polygon(
+ polygon: tuple[tuple[float, float], ...],
+) -> tuple[float, float, float, float]:
+ xs = [point[0] for point in polygon]
+ ys = [point[1] for point in polygon]
+ return (min(xs), min(ys), max(xs), max(ys))
+
+
+def _normalize_polygon(
+ coordinates: Sequence[float] | None,
+) -> tuple[tuple[float, float], ...]:
+ if not coordinates or len(coordinates) < 6:
+ return ()
+ pairs = [
+ (float(coordinates[index]), float(coordinates[index + 1]))
+ for index in range(0, len(coordinates) - 1, 2)
+ ]
+ if len(pairs) > 1 and pairs[0] == pairs[-1]:
+ pairs.pop()
+ return tuple(pairs)
+
+
+def _clamp_normalized(value: float) -> int:
+ clamped = max(_NORMALIZED_MIN_COORD, min(_NORMALIZED_MAX_COORD, value))
+ rounded = int(round(clamped))
+ return max(0, min(1000, rounded))
+
+
+@dataclass(slots=True)
+class _PageDetectionTransform:
+ original_size: tuple[int, int]
+ border: tuple[int, int]
+ padded_size: tuple[int, int]
+ processed_size: tuple[int, int]
+ scale_x: float
+ scale_y: float
+
+ def map_box_to_original(self, box: _PageBox) -> tuple[float, float, float, float]:
+ processed_width, processed_height = self.processed_size
+ original_width, original_height = self.original_size
+ border_width, border_height = self.border
+
+ left_processed, top_processed, right_processed, bottom_processed = box.denormalize(
+ processed_width,
+ processed_height,
+ )
+ left_padded = left_processed / (self.scale_x or 1.0)
+ top_padded = top_processed / (self.scale_y or 1.0)
+ right_padded = right_processed / (self.scale_x or 1.0)
+ bottom_padded = bottom_processed / (self.scale_y or 1.0)
+
+ left_original = max(0.0, min(original_width, left_padded - border_width))
+ top_original = max(0.0, min(original_height, top_padded - border_height))
+ right_original = max(0.0, min(original_width, right_padded - border_width))
+ bottom_original = max(0.0, min(original_height, bottom_padded - border_height))
+ return left_original, top_original, right_original, bottom_original
+
+
+@dataclass(slots=True)
+class _PageBox:
+ page_index: int
+ ymin: int
+ xmin: int
+ ymax: int
+ xmax: int
+
+ @classmethod
+ def from_json(cls, payload: dict[str, Any]) -> _PageBox:
+ if "page_index" not in payload:
+ raise ValueError("Expected 'page_index' key in page-detection response.")
+ required_keys = {"left", "top", "right", "bottom"}
+ if not required_keys.issubset(payload):
+ missing = required_keys - set(payload)
+ raise ValueError(
+ f"Page-detection response must include keys {sorted(required_keys)}, "
+ f"missing {sorted(missing)}."
+ )
+ return cls(
+ page_index=int(payload["page_index"]),
+ ymin=_clamp_normalized(float(payload["top"])),
+ xmin=_clamp_normalized(float(payload["left"])),
+ ymax=_clamp_normalized(float(payload["bottom"])),
+ xmax=_clamp_normalized(float(payload["right"])),
+ )
+
+ def denormalize(self, width: int, height: int) -> tuple[int, int, int, int]:
+ top = max(0, min(height, int(round(self.ymin * height / 1000))))
+ left = max(0, min(width, int(round(self.xmin * width / 1000))))
+ bottom = max(0, min(height, int(round(self.ymax * height / 1000))))
+ right = max(0, min(width, int(round(self.xmax * width / 1000))))
+ return left, top, right, bottom
+
+
+EdgeDecisionAction = Literal["expand", "shrink", "no_change"]
+
+
+@dataclass(slots=True, frozen=True)
+class _EdgeReviewDecision:
+ action: EdgeDecisionAction
+ amount: int
+
+
+@dataclass(slots=True, frozen=True)
+class _BoxReviewDecision:
+ page_index: int
+ left: _EdgeReviewDecision
+ top: _EdgeReviewDecision
+ right: _EdgeReviewDecision
+ bottom: _EdgeReviewDecision
+
+
+def _add_white_border(
+ image: Image.Image,
+ *,
+ fraction: float = _BORDER_FRACTION,
+) -> tuple[Image.Image, int, int]:
+ if fraction <= 0:
+ return image, 0, 0
+ border_width = max(1, int(round(image.width * fraction)))
+ border_height = max(1, int(round(image.height * fraction)))
+ expanded = ImageOps.expand(
+ image,
+ border=(border_width, border_height, border_width, border_height),
+ fill="white",
+ )
+ return expanded, border_width, border_height
+
+
+def _resize_image_to_fit(image: Image.Image, *, max_dim: int = _PROCESSED_MAX_DIM) -> Image.Image:
+ width, height = image.size
+ longest_side = max(width, height)
+ if longest_side <= max_dim:
+ return image
+ scale = max_dim / longest_side
+ return image.resize((max(1, int(round(width * scale))), max(1, int(round(height * scale)))))
+
+
+def _prepare_detection_image(image: Image.Image) -> tuple[Image.Image, _PageDetectionTransform]:
+ rgb_image = image.convert("RGB")
+ bordered, border_width, border_height = _add_white_border(rgb_image)
+ processed = _resize_image_to_fit(bordered)
+ transform = _PageDetectionTransform(
+ original_size=image.size,
+ border=(border_width, border_height),
+ padded_size=bordered.size,
+ processed_size=processed.size,
+ scale_x=processed.width / bordered.width if bordered.width else 1.0,
+ scale_y=processed.height / bordered.height if bordered.height else 1.0,
+ )
+ return processed, transform
+
+
+def _strip_code_fence(raw: str) -> str:
+ text = raw.strip()
+ if text.startswith("```"):
+ lines = text.splitlines()
+ if len(lines) >= 2:
+ lines = lines[1:]
+ if lines and lines[-1].startswith("```"):
+ lines = lines[:-1]
+ text = "\n".join(lines).strip()
+ return text
+
+
+def _parse_page_boxes_json(output: str) -> list[_PageBox]:
+ response_text = _strip_code_fence(output)
+ try:
+ payload = json.loads(response_text)
+ except json.JSONDecodeError as exc:
+ raise ProviderError("LLM page detection returned invalid JSON.") from exc
+
+ if not isinstance(payload, dict):
+ raise ProviderError("LLM page detection response must be a JSON object.")
+
+ pages = payload.get("pages")
+ if not isinstance(pages, list):
+ raise ProviderError("LLM page detection response must include a `pages` list.")
+
+ boxes: list[_PageBox] = []
+ for page_index, page in enumerate(pages):
+ if not isinstance(page, dict):
+ raise ProviderError(f"LLM page detection entry {page_index} must be an object.")
+ try:
+ boxes.append(_PageBox.from_json(cast("dict[str, Any]", page)))
+ except (TypeError, ValueError) as exc:
+ raise ProviderError(f"LLM page detection entry {page_index} is invalid: {exc}") from exc
+ return sorted(boxes, key=lambda box: box.page_index)
+
+
+def _build_target_box_from_payload(payload: dict[str, Any], *, target_index: int) -> _PageBox:
+ return _PageBox.from_json(
+ {
+ "page_index": target_index,
+ "left": payload["left"],
+ "top": payload["top"],
+ "right": payload["right"],
+ "bottom": payload["bottom"],
+ }
+ )
+
+
+def _parse_target_box_json(
+ output: str,
+ *,
+ target_key: str,
+ found_key: str,
+ error_context: str,
+) -> _PageBox | None:
+ response_text = _strip_code_fence(output)
+ try:
+ payload = json.loads(response_text)
+ except json.JSONDecodeError as exc:
+ raise ProviderError(f"{error_context} returned invalid JSON.") from exc
+
+ if not isinstance(payload, dict):
+ raise ProviderError(f"{error_context} response must be a JSON object.")
+ payload_dict = cast("dict[str, Any]", payload)
+
+ if {"left", "top", "right", "bottom"}.issubset(payload_dict):
+ try:
+ return _build_target_box_from_payload(payload_dict, target_index=1)
+ except (TypeError, ValueError) as exc:
+ raise ProviderError(f"{error_context} bbox is invalid: {exc}") from exc
+
+ raw_target = payload_dict.get(target_key)
+ if raw_target is None:
+ raw_target = payload_dict.get("bbox")
+ if isinstance(raw_target, dict):
+ try:
+ return _build_target_box_from_payload(cast("dict[str, Any]", raw_target), target_index=1)
+ except (TypeError, ValueError) as exc:
+ raise ProviderError(f"{error_context} bbox is invalid: {exc}") from exc
+ if raw_target is not None:
+ raise ProviderError(f"{error_context} response `{target_key}` must be an object or null.")
+
+ if (
+ payload_dict.get(found_key) is False
+ or payload_dict.get("found") is False
+ or (target_key in payload_dict and payload_dict[target_key] is None)
+ or ("bbox" in payload_dict and payload_dict["bbox"] is None)
+ ):
+ return None
+
+ raise ProviderError(
+ f"{error_context} response must include a `{target_key}` object "
+ f"or explicitly mark `{found_key}` false."
+ )
+
+
+def _parse_text_block_box_json(output: str) -> _PageBox | None:
+ return _parse_target_box_json(
+ output,
+ target_key="block",
+ found_key="block_found",
+ error_context="LLM text-block localization",
+ )
+
+
+def _parse_edge_review_decision(
+ payload: object,
+ *,
+ edge_name: str,
+) -> _EdgeReviewDecision:
+ if not isinstance(payload, dict):
+ raise ValueError(f"Review edge '{edge_name}' must be an object.")
+ payload_dict = cast("dict[str, object]", payload)
+
+ raw_action = payload_dict.get("action")
+ if raw_action is None:
+ raw_action = payload_dict.get("decision")
+ if not isinstance(raw_action, str):
+ raise ValueError(f"Review edge '{edge_name}' must include string 'action'.")
+ action = raw_action.strip().lower()
+ if action not in {"expand", "shrink", "no_change"}:
+ raise ValueError(f"Review edge '{edge_name}' action must be one of 'expand', 'shrink', 'no_change'.")
+ action_literal = cast("EdgeDecisionAction", action)
+
+ try:
+ raw_amount = payload_dict.get("amount")
+ amount = 0 if raw_amount is None else int(round(float(cast("Any", raw_amount))))
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"Review edge '{edge_name}' amount must be numeric.") from exc
+ amount = max(0, min(1000, amount))
+ if action_literal == "no_change":
+ amount = 0
+ return _EdgeReviewDecision(action=action_literal, amount=amount)
+
+
+def _parse_single_edge_review_decision_json(
+ output: str,
+) -> tuple[int, str, _EdgeReviewDecision]:
+ try:
+ payload = json.loads(_strip_code_fence(output))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Failed to decode edge-review response as JSON: {exc}") from exc
+
+ if not isinstance(payload, dict):
+ raise ValueError("Edge-review response must be a JSON object.")
+ if "page_index" not in payload:
+ raise ValueError("Edge-review response must include 'page_index'.")
+
+ raw_edge = payload.get("edge")
+ if not isinstance(raw_edge, str):
+ raise ValueError("Edge-review response must include string 'edge'.")
+ edge_name = raw_edge.strip().lower()
+ if edge_name not in _EDGE_NAMES:
+ raise ValueError("Edge-review response 'edge' must be left/top/right/bottom.")
+
+ decision_payload = payload.get("decision")
+ if not isinstance(decision_payload, dict):
+ decision_payload = {
+ "action": payload.get("action"),
+ "amount": payload.get("amount", 0),
+ }
+
+ return (
+ int(payload["page_index"]),
+ edge_name,
+ _parse_edge_review_decision(
+ decision_payload,
+ edge_name=edge_name,
+ ),
+ )
+
+
+def _parse_text_block_edge_review_decision_json(
+ output: str,
+) -> tuple[str, _EdgeReviewDecision]:
+ try:
+ payload = json.loads(_strip_code_fence(output))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"Failed to decode text-block edge-review response as JSON: {exc}") from exc
+
+ if not isinstance(payload, dict):
+ raise ValueError("Text-block edge-review response must be a JSON object.")
+ payload_dict = cast("dict[str, object]", payload)
+
+ raw_edge = payload_dict.get("edge")
+ if not isinstance(raw_edge, str):
+ raise ValueError("Text-block edge-review response must include string 'edge'.")
+ edge_name = raw_edge.strip().lower()
+ if edge_name not in _EDGE_NAMES:
+ raise ValueError("Text-block edge-review response 'edge' must be left/top/right/bottom.")
+
+ decision_payload = payload_dict.get("decision")
+ if not isinstance(decision_payload, dict):
+ decision_payload = {
+ "action": payload_dict.get("action"),
+ "amount": payload_dict.get("amount", 0),
+ }
+ return edge_name, _parse_edge_review_decision(decision_payload, edge_name=edge_name)
+
+
+def _boxes_equal(left_boxes: Sequence[_PageBox], right_boxes: Sequence[_PageBox]) -> bool:
+ if len(left_boxes) != len(right_boxes):
+ return False
+ for left_box, right_box in zip(left_boxes, right_boxes, strict=False):
+ if (
+ left_box.page_index != right_box.page_index
+ or left_box.xmin != right_box.xmin
+ or left_box.ymin != right_box.ymin
+ or left_box.xmax != right_box.xmax
+ or left_box.ymax != right_box.ymax
+ ):
+ return False
+ return True
+
+
+def _bbox_to_polygon(
+ bbox: tuple[float, float, float, float],
+) -> tuple[tuple[float, float], ...]:
+ left, top, right, bottom = bbox
+ return ((left, top), (right, top), (right, bottom), (left, bottom))
+
+
+def _normalize_pixel_coord(value: int, size: int) -> int:
+ if size <= 0:
+ return 0
+ return max(0, min(1000, int(round(value * 1000 / size))))
+
+
+def _build_box_review_preview(
+ image: Image.Image,
+ box: _PageBox,
+ *,
+ margin_fraction: float = _REVIEW_CROP_MARGIN_FRACTION,
+ outline_width: int = _PAGE_DETECTION_BOX_WIDTH,
+) -> tuple[Image.Image, tuple[int, int, int, int]]:
+ width, height = image.size
+ left, top, right, bottom = box.denormalize(width, height)
+
+ box_width = max(1, right - left)
+ box_height = max(1, bottom - top)
+ margin_x = max(outline_width * 2, int(round(box_width * margin_fraction)))
+ margin_y = max(outline_width * 2, int(round(box_height * margin_fraction)))
+
+ crop_left = max(0, left - margin_x)
+ crop_top = max(0, top - margin_y)
+ crop_right = min(width, right + margin_x)
+ crop_bottom = min(height, bottom + margin_y)
+
+ crop = image.crop((crop_left, crop_top, crop_right, crop_bottom))
+ preview = crop.copy()
+ draw = ImageDraw.Draw(preview)
+ draw.rectangle(
+ [left - crop_left, top - crop_top, right - crop_left, bottom - crop_top],
+ outline=_GUIDELINE_COLOR,
+ width=outline_width,
+ )
+ return preview, (crop_left, crop_top, crop_right, crop_bottom)
+
+
+def _build_edge_strip_review_preview(
+ image: Image.Image,
+ box: _PageBox,
+ edge_name: str,
+ *,
+ outline_width: int = _PAGE_DETECTION_BOX_WIDTH,
+) -> tuple[Image.Image, tuple[int, int, int, int]]:
+ width, height = image.size
+ left, top, right, bottom = box.denormalize(width, height)
+ box_width = max(1, right - left)
+ box_height = max(1, bottom - top)
+
+ band_half_x = max(outline_width * 3, int(round(box_width * 0.18)))
+ band_half_y = max(outline_width * 3, int(round(box_height * 0.18)))
+ orthogonal_pad_x = max(outline_width * 2, int(round(box_width * 0.06)))
+ orthogonal_pad_y = max(outline_width * 2, int(round(box_height * 0.06)))
+
+ if edge_name == "left":
+ x0 = max(0, left - band_half_x)
+ x1 = min(width, left + band_half_x)
+ y0 = max(0, top - orthogonal_pad_y)
+ y1 = min(height, bottom + orthogonal_pad_y)
+ elif edge_name == "right":
+ x0 = max(0, right - band_half_x)
+ x1 = min(width, right + band_half_x)
+ y0 = max(0, top - orthogonal_pad_y)
+ y1 = min(height, bottom + orthogonal_pad_y)
+ elif edge_name == "top":
+ x0 = max(0, left - orthogonal_pad_x)
+ x1 = min(width, right + orthogonal_pad_x)
+ y0 = max(0, top - band_half_y)
+ y1 = min(height, top + band_half_y)
+ elif edge_name == "bottom":
+ x0 = max(0, left - orthogonal_pad_x)
+ x1 = min(width, right + orthogonal_pad_x)
+ y0 = max(0, bottom - band_half_y)
+ y1 = min(height, bottom + band_half_y)
+ else:
+ raise ValueError(f"Unsupported edge '{edge_name}'. Expected left/top/right/bottom.")
+
+ if x0 >= x1 or y0 >= y1:
+ raise ValueError(f"Invalid strip bounds for edge '{edge_name}'.")
+ return image.crop((x0, y0, x1, y1)), (x0, y0, x1, y1)
+
+
+def _convert_source_box_to_review_crop_box(
+ box: _PageBox,
+ crop_bounds: tuple[int, int, int, int],
+ source_size: tuple[int, int],
+) -> _PageBox:
+ source_width, source_height = source_size
+ crop_left, crop_top, crop_right, crop_bottom = crop_bounds
+ crop_width = max(1, crop_right - crop_left)
+ crop_height = max(1, crop_bottom - crop_top)
+ left, top, right, bottom = box.denormalize(source_width, source_height)
+ return _PageBox.from_json(
+ {
+ "page_index": box.page_index,
+ "left": _normalize_pixel_coord(max(0, min(crop_width, left - crop_left)), crop_width),
+ "top": _normalize_pixel_coord(max(0, min(crop_height, top - crop_top)), crop_height),
+ "right": _normalize_pixel_coord(
+ max(0, min(crop_width, right - crop_left)),
+ crop_width,
+ ),
+ "bottom": _normalize_pixel_coord(
+ max(0, min(crop_height, bottom - crop_top)),
+ crop_height,
+ ),
+ }
+ )
+
+
+def _map_review_crop_box_to_source_box(
+ reviewed_box: _PageBox,
+ crop_bounds: tuple[int, int, int, int],
+ source_size: tuple[int, int],
+ *,
+ page_index: int,
+) -> _PageBox:
+ source_width, source_height = source_size
+ crop_left, crop_top, crop_right, crop_bottom = crop_bounds
+ crop_width = max(1, crop_right - crop_left)
+ crop_height = max(1, crop_bottom - crop_top)
+ local_left, local_top, local_right, local_bottom = reviewed_box.denormalize(
+ crop_width,
+ crop_height,
+ )
+ return _PageBox.from_json(
+ {
+ "page_index": page_index,
+ "left": _normalize_pixel_coord(
+ max(0, min(source_width, crop_left + local_left)),
+ source_width,
+ ),
+ "top": _normalize_pixel_coord(
+ max(0, min(source_height, crop_top + local_top)),
+ source_height,
+ ),
+ "right": _normalize_pixel_coord(
+ max(0, min(source_width, crop_left + local_right)),
+ source_width,
+ ),
+ "bottom": _normalize_pixel_coord(
+ max(0, min(source_height, crop_top + local_bottom)),
+ source_height,
+ ),
+ }
+ )
+
+
+def _merge_instruction_prompts(*parts: str | None) -> str:
+ """Merge one or more instruction strings into a single non-empty user prompt."""
+ merged_parts = [part.strip() for part in parts if isinstance(part, str) and part.strip()]
+ if not merged_parts:
+ raise ValueError("Expected at least one non-empty instruction prompt.")
+ return "\n\n".join(merged_parts)
+
+
+async def _complete_page_boxes(
+ *,
+ model: str,
+ image: Image.Image,
+ system_prompt: str,
+ user_prompt: str | None,
+ transport: LiteLLMTransport,
+) -> list[_PageBox]:
+ messages = transport.prepare_messages(
+ system_prompt=None,
+ user_prompt=_merge_instruction_prompts(system_prompt, user_prompt),
+ images=[image],
+ )
+ output = await transport.complete_text(
+ model=model,
+ messages=messages,
+ output_json=True,
+ )
+ logger.info("Initial LLM page-detection response: %s", output)
+ return _parse_page_boxes_json(output)
+
+
+async def _complete_text_block_box(
+ *,
+ model: str,
+ image: Image.Image,
+ block_tag: str,
+ block_text: str,
+ transport: LiteLLMTransport,
+) -> _PageBox | None:
+ messages = transport.prepare_messages(
+ system_prompt=None,
+ user_prompt=build_text_block_localization_prompt(
+ block_tag=block_tag,
+ block_text=block_text,
+ ),
+ images=[image],
+ )
+ output = await transport.complete_text(
+ model=model,
+ messages=messages,
+ output_json=True,
+ )
+ logger.info("Initial LLM text-block-localization response: %s", output)
+ return _parse_text_block_box_json(output)
+
+
+async def _review_single_edge_from_strip(
+ *,
+ model: str,
+ review_image: Image.Image,
+ strip_image: Image.Image,
+ strip_bounds: tuple[int, int, int, int],
+ edge_name: str,
+ page_index: int,
+ history_steps: int,
+ round_index: int,
+ transport: LiteLLMTransport,
+) -> _EdgeReviewDecision:
+ strip_axis_pixels = _strip_axis_size_pixels(strip_bounds, edge_name=edge_name)
+ if strip_axis_pixels <= 0:
+ raise ValueError(f"Invalid strip axis size for edge '{edge_name}'.")
+
+ prompt = build_boundary_review_prompt(
+ edge_name=edge_name,
+ page_index=page_index,
+ strip_axis=(
+ "horizontal (x-axis / strip width)"
+ if edge_name in {"left", "right"}
+ else "vertical (y-axis / strip height)"
+ ),
+ )
+ messages = transport.prepare_messages(
+ system_prompt=None,
+ user_prompt=prompt,
+ images=[strip_image],
+ )
+ output = await transport.complete_text(
+ model=model,
+ messages=messages,
+ output_json=True,
+ )
+ logger.info(
+ "Review LLM single-edge response (round=%s, page=%s, edge=%s, history rounds=%s): %s",
+ round_index,
+ page_index,
+ edge_name,
+ history_steps,
+ output,
+ )
+ response_page_index, response_edge_name, strip_decision = _parse_single_edge_review_decision_json(output)
+ if response_page_index != page_index:
+ logger.info(
+ "Single-edge review page_index mismatch (expected=%s, got=%s) for edge=%s; using expected.",
+ page_index,
+ response_page_index,
+ edge_name,
+ )
+ if response_edge_name != edge_name:
+ logger.info(
+ "Single-edge review edge mismatch (expected=%s, got=%s); using expected edge.",
+ edge_name,
+ response_edge_name,
+ )
+
+ local_axis_pixels = review_image.width if edge_name in {"left", "right"} else review_image.height
+ local_amount = _convert_strip_delta_to_local_delta(
+ strip_decision.amount,
+ strip_axis_pixels=strip_axis_pixels,
+ local_axis_pixels=local_axis_pixels,
+ )
+ return _EdgeReviewDecision(action=strip_decision.action, amount=local_amount)
+
+
+async def _review_single_text_block_edge_from_strip(
+ *,
+ model: str,
+ review_image: Image.Image,
+ strip_image: Image.Image,
+ strip_bounds: tuple[int, int, int, int],
+ edge_name: str,
+ block_tag: str,
+ block_text: str,
+ history_steps: int,
+ round_index: int,
+ transport: LiteLLMTransport,
+) -> _EdgeReviewDecision:
+ strip_axis_pixels = _strip_axis_size_pixels(strip_bounds, edge_name=edge_name)
+ if strip_axis_pixels <= 0:
+ raise ValueError(f"Invalid strip axis size for edge '{edge_name}'.")
+
+ prompt = build_text_block_boundary_review_prompt(
+ edge_name=edge_name,
+ block_tag=block_tag,
+ block_text=block_text,
+ strip_axis=(
+ "horizontal (x-axis / strip width)"
+ if edge_name in {"left", "right"}
+ else "vertical (y-axis / strip height)"
+ ),
+ )
+ messages = transport.prepare_messages(
+ system_prompt=None,
+ user_prompt=prompt,
+ images=[strip_image],
+ )
+ output = await transport.complete_text(
+ model=model,
+ messages=messages,
+ output_json=True,
+ )
+ logger.info(
+ "Text-block review LLM single-edge response (round=%s, edge=%s, history rounds=%s): %s",
+ round_index,
+ edge_name,
+ history_steps,
+ output,
+ )
+ response_edge_name, strip_decision = _parse_text_block_edge_review_decision_json(output)
+ if response_edge_name != edge_name:
+ logger.info(
+ "Text-block edge-review mismatch (expected=%s, got=%s); using expected edge.",
+ edge_name,
+ response_edge_name,
+ )
+
+ local_axis_pixels = review_image.width if edge_name in {"left", "right"} else review_image.height
+ local_amount = _convert_strip_delta_to_local_delta(
+ strip_decision.amount,
+ strip_axis_pixels=strip_axis_pixels,
+ local_axis_pixels=local_axis_pixels,
+ )
+ return _EdgeReviewDecision(action=strip_decision.action, amount=local_amount)
+
+
+async def _review_page_box(
+ *,
+ image: Image.Image,
+ current_box: _PageBox,
+ history_steps: int,
+ round_index: int,
+ model: str,
+ transport: LiteLLMTransport,
+) -> _PageBox:
+ review_image, crop_bounds = _build_box_review_preview(image, current_box)
+ local_box = _convert_source_box_to_review_crop_box(current_box, crop_bounds, image.size)
+ edge_strip_inputs = [
+ (edge_name, *_build_edge_strip_review_preview(review_image, local_box, edge_name))
+ for edge_name in _EDGE_NAMES
+ ]
+ edge_results = await asyncio.gather(
+ *[
+ _review_single_edge_from_strip(
+ model=model,
+ review_image=review_image,
+ strip_image=strip_image,
+ strip_bounds=strip_bounds,
+ edge_name=edge_name,
+ page_index=current_box.page_index,
+ history_steps=history_steps,
+ round_index=round_index,
+ transport=transport,
+ )
+ for edge_name, strip_image, strip_bounds in edge_strip_inputs
+ ],
+ return_exceptions=True,
+ )
+
+ edge_decisions: dict[str, _EdgeReviewDecision] = {}
+ for edge_name, result in zip(_EDGE_NAMES, edge_results, strict=False):
+ if isinstance(result, Exception):
+ logger.info(
+ "Edge-strip review failed for round %s, page %s, edge %s; using no_change: %s",
+ round_index,
+ current_box.page_index,
+ edge_name,
+ result,
+ )
+ edge_decisions[edge_name] = _no_change_edge_review_decision()
+ continue
+ edge_decisions[edge_name] = result
+
+ reviewed_local_box = _apply_box_review_decision(
+ local_box,
+ _BoxReviewDecision(
+ page_index=current_box.page_index,
+ left=edge_decisions["left"],
+ top=edge_decisions["top"],
+ right=edge_decisions["right"],
+ bottom=edge_decisions["bottom"],
+ ),
+ expected_page_index=current_box.page_index,
+ )
+ return _map_review_crop_box_to_source_box(
+ reviewed_local_box,
+ crop_bounds,
+ image.size,
+ page_index=current_box.page_index,
+ )
+
+
+async def _review_text_block_box(
+ *,
+ image: Image.Image,
+ current_box: _PageBox,
+ block_tag: str,
+ block_text: str,
+ history_steps: int,
+ round_index: int,
+ model: str,
+ transport: LiteLLMTransport,
+) -> _PageBox:
+ review_image, crop_bounds = _build_box_review_preview(
+ image,
+ current_box,
+ margin_fraction=_TEXT_BLOCK_REVIEW_CROP_MARGIN_FRACTION,
+ outline_width=_TEXT_BLOCK_DETECTION_BOX_WIDTH,
+ )
+ local_box = _convert_source_box_to_review_crop_box(current_box, crop_bounds, image.size)
+ edge_strip_inputs = [
+ (
+ edge_name,
+ *_build_edge_strip_review_preview(
+ review_image,
+ local_box,
+ edge_name,
+ outline_width=_TEXT_BLOCK_DETECTION_BOX_WIDTH,
+ ),
+ )
+ for edge_name in _EDGE_NAMES
+ ]
+ edge_results = await asyncio.gather(
+ *[
+ _review_single_text_block_edge_from_strip(
+ model=model,
+ review_image=review_image,
+ strip_image=strip_image,
+ strip_bounds=strip_bounds,
+ edge_name=edge_name,
+ block_tag=block_tag,
+ block_text=block_text,
+ history_steps=history_steps,
+ round_index=round_index,
+ transport=transport,
+ )
+ for edge_name, strip_image, strip_bounds in edge_strip_inputs
+ ],
+ return_exceptions=True,
+ )
+
+ edge_decisions: dict[str, _EdgeReviewDecision] = {}
+ for edge_name, result in zip(_EDGE_NAMES, edge_results, strict=False):
+ if isinstance(result, Exception):
+ logger.info(
+ "Text-block edge-strip review failed for round %s, edge %s; using no_change: %s",
+ round_index,
+ edge_name,
+ result,
+ )
+ edge_decisions[edge_name] = _no_change_edge_review_decision()
+ continue
+ edge_decisions[edge_name] = result
+
+ reviewed_local_box = _apply_box_review_decision(
+ local_box,
+ _BoxReviewDecision(
+ page_index=current_box.page_index,
+ left=edge_decisions["left"],
+ top=edge_decisions["top"],
+ right=edge_decisions["right"],
+ bottom=edge_decisions["bottom"],
+ ),
+ expected_page_index=current_box.page_index,
+ )
+ return _map_review_crop_box_to_source_box(
+ reviewed_local_box,
+ crop_bounds,
+ image.size,
+ page_index=current_box.page_index,
+ )
+
+
+async def _run_review_pipeline(
+ *,
+ initial_boxes: list[_PageBox],
+ max_review_rounds: int,
+ review_box: Callable[[_PageBox, int, int], Awaitable[_PageBox]],
+ subject_name_singular: str,
+ subject_name_plural: str,
+) -> list[_PageBox]:
+ history_boxes: list[list[_PageBox]] = [initial_boxes]
+ page_review_states = {box.page_index: _new_page_review_stop_state() for box in initial_boxes}
+ final_boxes = initial_boxes
+
+ for round_index in range(max(0, max_review_rounds)):
+ previous_boxes = history_boxes[-1]
+ active_boxes = [
+ box
+ for box in previous_boxes
+ if not _page_review_is_fully_frozen(
+ page_review_states.setdefault(box.page_index, _new_page_review_stop_state())
+ )
+ ]
+ if not active_boxes:
+ logger.info(
+ "All %s frozen by review stop condition before round %s; stopping reviews.",
+ subject_name_plural,
+ round_index + 1,
+ )
+ final_boxes = previous_boxes
+ break
+
+ review_results = await asyncio.gather(
+ *(review_box(box, len(history_boxes), round_index + 1) for box in active_boxes),
+ return_exceptions=True,
+ )
+ results_by_page = {
+ box.page_index: result for box, result in zip(active_boxes, review_results, strict=False)
+ }
+
+ reviewed_boxes: list[_PageBox] = []
+ for prior_box in previous_boxes:
+ page_state = page_review_states.setdefault(
+ prior_box.page_index,
+ _new_page_review_stop_state(),
+ )
+ if _page_review_is_fully_frozen(page_state):
+ reviewed_boxes.append(prior_box)
+ continue
+
+ result = results_by_page.get(prior_box.page_index)
+ if isinstance(result, Exception):
+ logger.info(
+ "Review round %s %s %s failed, keeping prior box: %s",
+ round_index + 1,
+ subject_name_singular,
+ prior_box.page_index,
+ result,
+ )
+ reviewed_boxes.append(prior_box)
+ continue
+ if result is None:
+ reviewed_boxes.append(prior_box)
+ continue
+
+ reviewed_boxes.append(
+ _apply_page_review_stop_condition(
+ prior_box=prior_box,
+ reviewed_box=result,
+ page_state=page_state,
+ round_index=round_index + 1,
+ subject_name=subject_name_singular,
+ )
+ )
+
+ reviewed_boxes = sorted(reviewed_boxes, key=lambda item: item.page_index)
+ if not reviewed_boxes:
+ break
+ if _boxes_equal(reviewed_boxes, previous_boxes):
+ final_boxes = reviewed_boxes
+ break
+
+ history_boxes.append(reviewed_boxes)
+ final_boxes = reviewed_boxes
+
+ _log_box_history(history_boxes, subject_name=subject_name_singular.title())
+ return final_boxes
+
+
+@dataclass(slots=True)
+class LLMPageDetector(PageDetectionBackend):
+ """Detect one or more pages via a multimodal LLM prompt."""
+
+ model: str
+ system_prompt: str = DEFAULT_BOUNDARY_DETECTION_PROMPT
+ prompt_template: str | None = None
+ transport: LiteLLMTransportConfig | None = None
+ max_review_rounds: int = 0
+
+ async def detect(self, image: Image.Image) -> list[PageCandidate]:
+ processed_image, transform = _prepare_detection_image(image)
+ transport = LiteLLMTransport(self.transport)
+ boxes = await _complete_page_boxes(
+ model=self.model,
+ image=processed_image,
+ system_prompt=self.system_prompt,
+ user_prompt=self.prompt_template,
+ transport=transport,
+ )
+ if not boxes:
+ return [_full_image_candidate(image)]
+ if self.max_review_rounds > 0:
+
+ async def _review_page_candidate(
+ box: _PageBox,
+ history_steps: int,
+ round_index: int,
+ ) -> _PageBox:
+ return await _review_page_box(
+ image=processed_image,
+ current_box=box,
+ history_steps=history_steps,
+ round_index=round_index,
+ model=self.model,
+ transport=transport,
+ )
+
+ boxes = await _run_review_pipeline(
+ initial_boxes=boxes,
+ max_review_rounds=self.max_review_rounds,
+ review_box=_review_page_candidate,
+ subject_name_singular="page",
+ subject_name_plural="pages",
+ )
+
+ candidates: list[PageCandidate] = []
+ for page_index, box in enumerate(boxes):
+ original_bbox = transform.map_box_to_original(box)
+ candidates.append(
+ PageCandidate(
+ bbox=original_bbox,
+ polygon=_bbox_to_polygon(original_bbox),
+ metadata={
+ "page_index": page_index,
+ "detector": "llm",
+ "response_page_index": box.page_index,
+ },
+ )
+ )
+ return candidates or [_full_image_candidate(image)]
+
+
+async def locate_text_block_bbox_with_llm(
+ image: Image.Image,
+ block_text: str,
+ *,
+ block_tag: str,
+ model: str,
+ transport: LiteLLMTransportLike = None,
+ max_review_rounds: int = 0,
+) -> tuple[float, float, float, float] | None:
+ """Locate the tight bbox of a specific rendered text block via a multimodal LLM."""
+ normalized_block_text = block_text.strip()
+ if not normalized_block_text:
+ raise ValueError("block_text must not be blank.")
+
+ normalized_block_tag = block_tag.strip()
+ if not normalized_block_tag:
+ raise ValueError("block_tag must not be blank.")
+
+ processed_image, transform = _prepare_detection_image(image)
+ llm_transport = transport if isinstance(transport, LiteLLMTransport) else LiteLLMTransport(transport)
+ box = await _complete_text_block_box(
+ model=model,
+ image=processed_image,
+ block_tag=normalized_block_tag,
+ block_text=normalized_block_text,
+ transport=llm_transport,
+ )
+ if box is None:
+ logger.info(
+ "LLM text-block localization did not find a match for tag=%s.",
+ normalized_block_tag,
+ )
+ return None
+
+ if max_review_rounds > 0:
+
+ async def _review_text_block_candidate(
+ review_box: _PageBox,
+ history_steps: int,
+ round_index: int,
+ ) -> _PageBox:
+ return await _review_text_block_box(
+ image=processed_image,
+ current_box=review_box,
+ block_tag=normalized_block_tag,
+ block_text=normalized_block_text,
+ history_steps=history_steps,
+ round_index=round_index,
+ model=model,
+ transport=llm_transport,
+ )
+
+ reviewed_boxes = await _run_review_pipeline(
+ initial_boxes=[box],
+ max_review_rounds=max_review_rounds,
+ review_box=_review_text_block_candidate,
+ subject_name_singular="text block",
+ subject_name_plural="text blocks",
+ )
+ if not reviewed_boxes:
+ return None
+ box = reviewed_boxes[0]
+
+ return transform.map_box_to_original(box)
+
+
+def locate_text_block_bbox_with_llm_sync(
+ image: Image.Image,
+ block_text: str,
+ *,
+ block_tag: str,
+ model: str,
+ transport: LiteLLMTransportLike = None,
+ max_review_rounds: int = 0,
+) -> tuple[float, float, float, float] | None:
+ """Synchronously locate the tight bbox of a specific rendered text block via a multimodal LLM."""
+ return run_sync(
+ locate_text_block_bbox_with_llm(
+ image,
+ block_text,
+ block_tag=block_tag,
+ model=model,
+ transport=transport,
+ max_review_rounds=max_review_rounds,
+ )
+ )
+
+
+@dataclass(slots=True)
+class AzurePageDetector(PageDetectionBackend):
+ """Detect pages from Azure Document Intelligence page output."""
+
+ endpoint: str
+ api_key: str
+ model_id: str = "prebuilt-layout"
+
+ async def detect(self, image: Image.Image) -> list[PageCandidate]:
+ try:
+ from azure.ai.documentintelligence.aio import DocumentIntelligenceClient
+ from azure.core.credentials import AzureKeyCredential
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ "Azure page detection requires the 'azure' extra. "
+ 'Install with `pip install "churro-ocr[azure]"`.'
+ ) from exc
+
+ buffer = BytesIO()
+ image.convert("RGB").save(buffer, format="JPEG")
+ client = DocumentIntelligenceClient(
+ endpoint=self.endpoint,
+ credential=AzureKeyCredential(self.api_key),
+ )
+ try:
+ poller = await client.begin_analyze_document(
+ model_id=self.model_id,
+ body=BytesIO(buffer.getvalue()),
+ content_type="application/octet-stream",
+ )
+ result = await poller.result()
+ finally:
+ await client.close()
+
+ candidates: list[PageCandidate] = []
+ for page_index, page in enumerate(result.pages or []):
+ polygon = _normalize_azure_page_polygon(page, image=image)
+ bbox = _bbox_from_polygon(polygon) if polygon else None
+ metadata = {
+ "page_index": page_index,
+ "page_number": getattr(page, "page_number", page_index + 1),
+ "detector": "azure",
+ }
+ unit = getattr(page, "unit", None)
+ if unit is not None:
+ metadata["unit"] = str(unit)
+ angle = getattr(page, "angle", None)
+ if angle is not None:
+ metadata["angle"] = float(angle)
+ candidates.append(PageCandidate(bbox=bbox, polygon=polygon, metadata=metadata))
+ return candidates or [_full_image_candidate(image)]
+
+
+def _normalize_azure_page_polygon(page: Any, *, image: Image.Image) -> tuple[tuple[float, float], ...]:
+ raw_polygon = getattr(page, "polygon", None)
+ polygon = _normalize_polygon(raw_polygon)
+ if not polygon:
+ return ()
+
+ page_width = float(getattr(page, "width", 0.0) or image.width)
+ page_height = float(getattr(page, "height", 0.0) or image.height)
+ scale_x = image.width / page_width if page_width else 1.0
+ scale_y = image.height / page_height if page_height else 1.0
+ return tuple((x * scale_x, y * scale_y) for x, y in polygon)
+
+
+def _apply_box_review_decision(
+ current_box: _PageBox,
+ decision: _BoxReviewDecision,
+ *,
+ expected_page_index: int,
+) -> _PageBox:
+ page_index = expected_page_index
+ if decision.page_index != expected_page_index:
+ logger.info(
+ "Review decision page_index mismatch (expected=%s, got=%s); using expected.",
+ expected_page_index,
+ decision.page_index,
+ )
+
+ left = _apply_edge_decision_to_coordinate(current_box.xmin, decision.left, is_min_edge=True)
+ top = _apply_edge_decision_to_coordinate(current_box.ymin, decision.top, is_min_edge=True)
+ right = _apply_edge_decision_to_coordinate(current_box.xmax, decision.right, is_min_edge=False)
+ bottom = _apply_edge_decision_to_coordinate(
+ current_box.ymax,
+ decision.bottom,
+ is_min_edge=False,
+ )
+
+ min_span = 1
+ if left >= right:
+ center = (left + right) // 2
+ left = max(0, center - min_span)
+ right = min(1000, center + min_span)
+ if top >= bottom:
+ center = (top + bottom) // 2
+ top = max(0, center - min_span)
+ bottom = min(1000, center + min_span)
+
+ return _PageBox.from_json(
+ {
+ "page_index": page_index,
+ "left": left,
+ "top": top,
+ "right": right,
+ "bottom": bottom,
+ }
+ )
+
+
+def _no_change_edge_review_decision() -> _EdgeReviewDecision:
+ return _EdgeReviewDecision(action="no_change", amount=0)
+
+
+def _new_page_review_stop_state() -> dict[str, dict[str, int | bool | None]]:
+ return {
+ edge_name: {
+ "frozen": False,
+ "stable_rounds": 0,
+ "last_sign": None,
+ "last_mag": None,
+ }
+ for edge_name in _EDGE_NAMES
+ }
+
+
+def _page_review_is_fully_frozen(page_state: dict[str, dict[str, int | bool | None]]) -> bool:
+ return all(bool(page_state[edge_name]["frozen"]) for edge_name in _EDGE_NAMES)
+
+
+def _apply_page_review_stop_condition(
+ *,
+ prior_box: _PageBox,
+ reviewed_box: _PageBox,
+ page_state: dict[str, dict[str, int | bool | None]],
+ round_index: int,
+ subject_name: str = "page",
+) -> _PageBox:
+ prior_coords = _box_to_edge_coords(prior_box)
+ reviewed_coords = _box_to_edge_coords(reviewed_box)
+ final_coords = dict(reviewed_coords)
+
+ for edge_name in _EDGE_NAMES:
+ edge_state = page_state[edge_name]
+ prior_value = prior_coords[edge_name]
+ candidate_value = reviewed_coords[edge_name]
+ delta = candidate_value - prior_value
+ magnitude = abs(delta)
+
+ if bool(edge_state["frozen"]):
+ final_coords[edge_name] = prior_value
+ continue
+
+ if magnitude <= _REVIEW_EDGE_STOP_DEADBAND:
+ final_coords[edge_name] = prior_value
+ edge_state["stable_rounds"] = int(edge_state["stable_rounds"] or 0) + 1
+ if int(edge_state["stable_rounds"]) >= _REVIEW_EDGE_STOP_STABLE_ROUNDS:
+ edge_state["frozen"] = True
+ logger.info(
+ "Freezing %s %s edge %s after %s stable round(s) (deadband <= %s).",
+ subject_name,
+ prior_box.page_index,
+ edge_name,
+ edge_state["stable_rounds"],
+ _REVIEW_EDGE_STOP_DEADBAND,
+ )
+ continue
+
+ edge_state["stable_rounds"] = 0
+ sign = 1 if delta > 0 else -1
+ previous_sign = edge_state["last_sign"]
+ previous_magnitude = edge_state["last_mag"]
+ if (
+ isinstance(previous_sign, int)
+ and previous_sign != 0
+ and previous_sign != sign
+ and isinstance(previous_magnitude, int)
+ and previous_magnitude > _REVIEW_EDGE_STOP_DEADBAND
+ and _is_oscillating_magnitude(magnitude, previous_magnitude)
+ ):
+ final_coords[edge_name] = _select_more_expansive_oscillation_coordinate(
+ edge_name=edge_name,
+ prior_value=prior_value,
+ candidate_value=candidate_value,
+ )
+ edge_state["frozen"] = True
+ logger.info(
+ "Freezing %s %s edge %s on round %s due to oscillation (prev=%s, current=%s, final=%s).",
+ subject_name,
+ prior_box.page_index,
+ edge_name,
+ round_index,
+ previous_magnitude,
+ magnitude,
+ final_coords[edge_name],
+ )
+ continue
+
+ edge_state["last_sign"] = sign
+ edge_state["last_mag"] = magnitude
+
+ return _build_page_box_from_edge_coords(prior_box.page_index, final_coords)
+
+
+def _is_oscillating_magnitude(current_magnitude: int, previous_magnitude: int) -> bool:
+ if current_magnitude <= 0 or previous_magnitude <= 0:
+ return False
+ ratio = current_magnitude / previous_magnitude if previous_magnitude else 0.0
+ return (
+ _REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MIN
+ <= ratio
+ <= _REVIEW_EDGE_STOP_OSCILLATION_MAGNITUDE_RATIO_MAX
+ )
+
+
+def _select_more_expansive_oscillation_coordinate(
+ *,
+ edge_name: str,
+ prior_value: int,
+ candidate_value: int,
+) -> int:
+ if edge_name in {"left", "top"}:
+ return min(prior_value, candidate_value)
+ return max(prior_value, candidate_value)
+
+
+def _box_to_edge_coords(box: _PageBox) -> dict[str, int]:
+ return {
+ "left": box.xmin,
+ "top": box.ymin,
+ "right": box.xmax,
+ "bottom": box.ymax,
+ }
+
+
+def _build_page_box_from_edge_coords(page_index: int, coords: dict[str, int]) -> _PageBox:
+ left = int(coords["left"])
+ top = int(coords["top"])
+ right = int(coords["right"])
+ bottom = int(coords["bottom"])
+
+ min_span = 1
+ if left >= right:
+ center = (left + right) // 2
+ left = max(0, center - min_span)
+ right = min(1000, center + min_span)
+ if top >= bottom:
+ center = (top + bottom) // 2
+ top = max(0, center - min_span)
+ bottom = min(1000, center + min_span)
+
+ return _PageBox.from_json(
+ {
+ "page_index": page_index,
+ "left": left,
+ "top": top,
+ "right": right,
+ "bottom": bottom,
+ }
+ )
+
+
+def _strip_axis_size_pixels(
+ strip_bounds: tuple[int, int, int, int],
+ *,
+ edge_name: str,
+) -> int:
+ x0, y0, x1, y1 = strip_bounds
+ return (x1 - x0) if edge_name in {"left", "right"} else (y1 - y0)
+
+
+def _convert_strip_delta_to_local_delta(
+ strip_delta_normalized: int,
+ *,
+ strip_axis_pixels: int,
+ local_axis_pixels: int,
+) -> int:
+ if strip_delta_normalized <= 0 or strip_axis_pixels <= 0 or local_axis_pixels <= 0:
+ return 0
+ delta_pixels = strip_delta_normalized * strip_axis_pixels / 1000
+ local_delta = int(round(delta_pixels * 1000 / local_axis_pixels))
+ return max(0, min(1000, local_delta))
+
+
+def _apply_edge_decision_to_coordinate(
+ current_value: int,
+ decision: _EdgeReviewDecision,
+ *,
+ is_min_edge: bool,
+) -> int:
+ if decision.action == "no_change" or decision.amount <= 0:
+ return current_value
+ if decision.action == "expand":
+ return current_value - decision.amount if is_min_edge else current_value + decision.amount
+ if decision.action == "shrink":
+ return current_value + decision.amount if is_min_edge else current_value - decision.amount
+ return current_value
+
+
+def _log_box_history(
+ history_boxes: Sequence[Sequence[_PageBox]],
+ *,
+ subject_name: str = "Page",
+) -> None:
+ per_page_history: dict[int, dict[str, list[int]]] = {}
+ for boxes in history_boxes:
+ for box in boxes:
+ page_history = per_page_history.setdefault(
+ box.page_index,
+ {"left": [], "top": [], "right": [], "bottom": []},
+ )
+ page_history["left"].append(box.xmin)
+ page_history["top"].append(box.ymin)
+ page_history["right"].append(box.xmax)
+ page_history["bottom"].append(box.ymax)
+ if not per_page_history:
+ return
+ label_width = max(len(key) for key in ("left", "top", "right", "bottom"))
+ for page_index in sorted(per_page_history):
+ logger.info("%s %s coordinate history:", subject_name, page_index)
+ page_history = per_page_history[page_index]
+ for key in ("left", "top", "right", "bottom"):
+ logger.info("%s: %s", key.ljust(label_width), " -> ".join(map(str, page_history[key])))
+
+
+__all__ = [
+ "AzurePageDetector",
+ "LLMPageDetector",
+ "locate_text_block_bbox_with_llm",
+ "locate_text_block_bbox_with_llm_sync",
+]
diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py
new file mode 100644
index 0000000..9f9bb0d
--- /dev/null
+++ b/src/churro_ocr/providers/specs.py
@@ -0,0 +1,224 @@
+"""Public OCR provider specs, options, and model profile resolution."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import TYPE_CHECKING, Literal
+
+from PIL import Image
+
+from churro_ocr._internal.image import prepare_ocr_image
+from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG, strip_ocr_output_tag
+from churro_ocr.templates import (
+ CHURRO_3B_MODEL_ID,
+ CHURRO_3B_XML_TEMPLATE,
+ DEFAULT_OCR_TEMPLATE,
+ DOTS_OCR_1_5_MODEL_ID,
+ DOTS_OCR_1_5_OCR_TEMPLATE,
+ OCRConversation,
+ OCRPromptTemplateLike,
+)
+
+if TYPE_CHECKING:
+ pass
+
+
+OCRProvider = Literal["litellm", "openai-compatible", "azure", "mistral", "hf", "vllm"]
+ImagePreprocessor = Callable[[Image.Image], Image.Image]
+TextPostprocessor = Callable[[str], str]
+VisionInputBuilder = Callable[[OCRConversation], object]
+DEFAULT_OCR_MAX_TOKENS = 20_000
+
+
+def identity_text_postprocessor(text: str) -> str:
+ """Return OCR text unchanged."""
+ return text
+
+
+def default_ocr_image_preprocessor(image: Image.Image) -> Image.Image:
+ """Apply the default OCR image preprocessing."""
+ return prepare_ocr_image(image)
+
+
+def default_ocr_text_postprocessor(text: str) -> str:
+ """Strip the default OCR output tag wrapper."""
+ return strip_ocr_output_tag(text, output_tag=DEFAULT_OCR_OUTPUT_TAG)
+
+
+@dataclass(slots=True, frozen=True)
+class LiteLLMTransportConfig:
+ """Shared transport config for LiteLLM-based multimodal requests."""
+
+ api_base: str | None = None
+ api_key: str | None = None
+ api_version: str | None = None
+ image_detail: str | None = None
+ completion_kwargs: dict[str, object] = field(default_factory=dict)
+ cache_dir: str | Path | None = None
+
+
+@dataclass(slots=True, frozen=True)
+class OpenAICompatibleOptions:
+ """Provider options for OpenAI-compatible OCR servers."""
+
+ model_prefix: str | None = None
+
+
+@dataclass(slots=True, frozen=True)
+class HuggingFaceOptions:
+ """Provider options for local Hugging Face OCR backends."""
+
+ trust_remote_code: bool | None = None
+ processor_kwargs: dict[str, object] = field(default_factory=dict)
+ model_kwargs: dict[str, object] = field(default_factory=dict)
+ generation_kwargs: dict[str, object] = field(default_factory=dict)
+ vision_input_builder: VisionInputBuilder | None = None
+ backend_variant: str | None = None
+
+
+@dataclass(slots=True, frozen=True)
+class VLLMOptions:
+ """Provider options for local vLLM OCR backends."""
+
+ trust_remote_code: bool | None = None
+ processor_kwargs: dict[str, object] = field(default_factory=dict)
+ llm_kwargs: dict[str, object] = field(default_factory=dict)
+ sampling_kwargs: dict[str, object] = field(default_factory=dict)
+ limit_mm_per_prompt: dict[str, int] = field(default_factory=dict)
+
+
+@dataclass(slots=True, frozen=True)
+class AzureDocumentIntelligenceOptions:
+ """Provider options for Azure Document Intelligence OCR."""
+
+ endpoint: str | None = None
+ api_key: str | None = None
+
+
+@dataclass(slots=True, frozen=True)
+class MistralOptions:
+ """Provider options for Mistral OCR."""
+
+ api_key: str | None = None
+
+
+OCRProviderOptions = (
+ OpenAICompatibleOptions
+ | HuggingFaceOptions
+ | VLLMOptions
+ | AzureDocumentIntelligenceOptions
+ | MistralOptions
+)
+
+
+@dataclass(slots=True, frozen=True)
+class OCRModelProfile:
+ """Model-level OCR behavior shared across provider adapters."""
+
+ profile_name: str
+ template: OCRPromptTemplateLike = DEFAULT_OCR_TEMPLATE
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor
+ text_postprocessor: TextPostprocessor = default_ocr_text_postprocessor
+ display_name: str | None = None
+ transport: LiteLLMTransportConfig = field(default_factory=LiteLLMTransportConfig)
+ huggingface: HuggingFaceOptions = field(default_factory=HuggingFaceOptions)
+ vllm: VLLMOptions = field(default_factory=VLLMOptions)
+
+
+@dataclass(slots=True, frozen=True)
+class OCRBackendSpec:
+ """Declarative builder input for OCR backends."""
+
+ provider: OCRProvider
+ model: str | None = None
+ profile: str | OCRModelProfile | None = None
+ transport: LiteLLMTransportConfig | None = None
+ options: OCRProviderOptions | None = None
+
+
+def default_ocr_profile() -> OCRModelProfile:
+ """Return the generic OCR model profile."""
+ return OCRModelProfile(profile_name="default")
+
+
+def churro_3b_profile() -> OCRModelProfile:
+ """Return the built-in `stanford-oval/churro-3B` OCR profile."""
+ return OCRModelProfile(
+ profile_name=CHURRO_3B_MODEL_ID,
+ template=CHURRO_3B_XML_TEMPLATE,
+ text_postprocessor=identity_text_postprocessor,
+ display_name="churro-3B",
+ )
+
+
+def dots_ocr_1_5_profile() -> OCRModelProfile:
+ """Return the built-in `kristaller486/dots.ocr-1.5` OCR profile."""
+ return OCRModelProfile(
+ profile_name=DOTS_OCR_1_5_MODEL_ID,
+ template=DOTS_OCR_1_5_OCR_TEMPLATE,
+ text_postprocessor=identity_text_postprocessor,
+ display_name="dots.ocr-1.5",
+ huggingface=HuggingFaceOptions(
+ trust_remote_code=True,
+ backend_variant="dots-ocr-1.5",
+ ),
+ vllm=VLLMOptions(
+ trust_remote_code=True,
+ ),
+ )
+
+
+def _profile_registry() -> dict[str, OCRModelProfile]:
+ default_profile = default_ocr_profile()
+ churro_profile = churro_3b_profile()
+ dots_profile = dots_ocr_1_5_profile()
+ return {
+ default_profile.profile_name: default_profile,
+ churro_profile.profile_name: churro_profile,
+ dots_profile.profile_name: dots_profile,
+ }
+
+
+def resolve_ocr_profile(
+ *,
+ model_id: str | None,
+ profile: str | OCRModelProfile | None = None,
+) -> OCRModelProfile:
+ """Resolve the OCR model profile for a model or explicit profile."""
+ if isinstance(profile, OCRModelProfile):
+ return profile
+
+ registry = _profile_registry()
+ if isinstance(profile, str):
+ try:
+ return registry[profile]
+ except KeyError as exc:
+ raise ValueError(f"Unknown OCR profile '{profile}'.") from exc
+
+ if model_id is not None and model_id in registry:
+ return registry[model_id]
+ return registry["default"]
+
+
+__all__ = [
+ "AzureDocumentIntelligenceOptions",
+ "DEFAULT_OCR_MAX_TOKENS",
+ "default_ocr_image_preprocessor",
+ "default_ocr_profile",
+ "default_ocr_text_postprocessor",
+ "HuggingFaceOptions",
+ "identity_text_postprocessor",
+ "ImagePreprocessor",
+ "LiteLLMTransportConfig",
+ "MistralOptions",
+ "OCRBackendSpec",
+ "OCRModelProfile",
+ "OCRProvider",
+ "OpenAICompatibleOptions",
+ "resolve_ocr_profile",
+ "TextPostprocessor",
+ "VisionInputBuilder",
+ "VLLMOptions",
+]
diff --git a/src/churro_ocr/providers/vllm.py b/src/churro_ocr/providers/vllm.py
new file mode 100644
index 0000000..d58df63
--- /dev/null
+++ b/src/churro_ocr/providers/vllm.py
@@ -0,0 +1,183 @@
+"""vLLM OCR backends."""
+
+from __future__ import annotations
+
+import asyncio
+import threading
+from dataclasses import dataclass, field
+from typing import Any
+
+from churro_ocr._internal.prompt_logging import log_prompt_payload_once
+from churro_ocr.errors import ConfigurationError, ProviderError
+from churro_ocr.ocr import OCRBackend, OCRResult
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.providers._shared import build_ocr_result, preprocess_backend_page, render_ocr_prompt
+from churro_ocr.providers.specs import (
+ DEFAULT_OCR_MAX_TOKENS,
+ ImagePreprocessor,
+ TextPostprocessor,
+ default_ocr_image_preprocessor,
+ identity_text_postprocessor,
+)
+from churro_ocr.templates import (
+ DOTS_OCR_1_5_MODEL_ID,
+ DOTS_OCR_1_5_OCR_TEMPLATE,
+ OCRPromptTemplateLike,
+)
+
+
+def _load_vllm_processor_cls() -> Any:
+ try:
+ from transformers import AutoProcessor
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ 'vLLM OCR requires transformers. Install with `pip install "churro-ocr[vllm]"`.'
+ ) from exc
+
+ return AutoProcessor
+
+
+def _load_vllm_runtime() -> tuple[Any, Any]:
+ try:
+ from vllm import LLM, SamplingParams
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ 'vLLM OCR requires the "vllm" extra. Install with `pip install "churro-ocr[vllm]"`.'
+ ) from exc
+
+ return LLM, SamplingParams
+
+
+@dataclass(slots=True)
+class VLLMVisionOCRBackend(OCRBackend):
+ """OCR backend for local multimodal models served by vLLM."""
+
+ model_id: str
+ template: OCRPromptTemplateLike
+ model_name: str | None = None
+ trust_remote_code: bool = False
+ processor_kwargs: dict[str, object] = field(default_factory=dict)
+ llm_kwargs: dict[str, object] = field(default_factory=dict)
+ sampling_kwargs: dict[str, object] = field(default_factory=dict)
+ limit_mm_per_prompt: dict[str, int] = field(default_factory=lambda: {"image": 1})
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor
+ text_postprocessor: TextPostprocessor = identity_text_postprocessor
+ provider_name: str = "vllm"
+ _processor: object | None = field(default=None, init=False, repr=False)
+ _llm: object | None = field(default=None, init=False, repr=False)
+ _init_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
+ _has_logged_prompt: bool = field(default=False, init=False, repr=False)
+ _prompt_log_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ self.sampling_kwargs = {
+ "max_tokens": DEFAULT_OCR_MAX_TOKENS,
+ **self.sampling_kwargs,
+ }
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ return (await self.ocr_batch([page]))[0]
+
+ async def ocr_batch(self, pages: list[DocumentPage]) -> list[OCRResult]:
+ return await asyncio.to_thread(self._ocr_batch_sync, pages)
+
+ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]:
+ if not pages:
+ return []
+
+ processor = self._get_processor()
+ llm = self._get_llm()
+ _, sampling_params_cls = _load_vllm_runtime()
+ prompts: list[dict[str, object]] = []
+
+ for page in pages:
+ prepared_page = preprocess_backend_page(
+ page,
+ image_preprocessor=self.image_preprocessor,
+ )
+ rendered, _ = render_ocr_prompt(
+ processor,
+ self.template,
+ prepared_page,
+ add_generation_prompt=True,
+ )
+ prompt_payload: dict[str, object] = {
+ "prompt": rendered,
+ "multi_modal_data": {"image": prepared_page.image},
+ }
+ prompts.append(prompt_payload)
+ if not self._has_logged_prompt:
+ log_prompt_payload_once(
+ payload={
+ "batch_size": len(pages),
+ "prompt": prompt_payload,
+ },
+ provider_name=self.provider_name,
+ has_logged=lambda: self._has_logged_prompt,
+ lock=self._prompt_log_lock,
+ set_logged=lambda: setattr(self, "_has_logged_prompt", True),
+ )
+
+ request_outputs = llm.generate(
+ prompts,
+ sampling_params_cls(**self.sampling_kwargs),
+ use_tqdm=False,
+ )
+ results: list[OCRResult] = []
+ for request_output in request_outputs:
+ outputs = getattr(request_output, "outputs", None)
+ if not outputs:
+ raise ProviderError("vLLM OCR returned no outputs.")
+ text = getattr(outputs[0], "text", None)
+ if not isinstance(text, str):
+ raise ProviderError("vLLM OCR returned a non-text response.")
+ results.append(
+ build_ocr_result(
+ text,
+ provider_name=self.provider_name,
+ model_name=self.model_name or self.model_id,
+ text_postprocessor=self.text_postprocessor,
+ )
+ )
+ return results
+
+ def _get_processor(self) -> Any:
+ if self._processor is None:
+ with self._init_lock:
+ if self._processor is None:
+ processor_cls = _load_vllm_processor_cls()
+ self._processor = processor_cls.from_pretrained(
+ self.model_id,
+ trust_remote_code=self.trust_remote_code,
+ **self.processor_kwargs,
+ )
+ return self._processor
+
+ def _get_llm(self) -> Any:
+ if self._llm is None:
+ with self._init_lock:
+ if self._llm is None:
+ llm_cls, _ = _load_vllm_runtime()
+ self._llm = llm_cls(
+ model=self.model_id,
+ trust_remote_code=self.trust_remote_code,
+ limit_mm_per_prompt=self.limit_mm_per_prompt,
+ **self.llm_kwargs,
+ )
+ return self._llm
+
+
+@dataclass(slots=True)
+class DotsOCR15VLLMOCRBackend(VLLMVisionOCRBackend):
+ """Preset vLLM OCR backend for `kristaller486/dots.ocr-1.5`."""
+
+ model_id: str = DOTS_OCR_1_5_MODEL_ID
+ template: OCRPromptTemplateLike = DOTS_OCR_1_5_OCR_TEMPLATE
+ model_name: str | None = "dots.ocr-1.5"
+ trust_remote_code: bool = True
+
+
+__all__ = [
+ "DotsOCR15VLLMOCRBackend",
+ "VLLMVisionOCRBackend",
+]
diff --git a/src/churro_ocr/py.typed b/src/churro_ocr/py.typed
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/churro_ocr/py.typed
@@ -0,0 +1 @@
+
diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py
new file mode 100644
index 0000000..16f76b0
--- /dev/null
+++ b/src/churro_ocr/templates/__init__.py
@@ -0,0 +1,33 @@
+"""Template helpers for model-specific OCR input rendering."""
+
+from churro_ocr.templates.base import (
+ OCRConversation,
+ OCRPromptTemplate,
+ OCRPromptTemplateCallable,
+ OCRPromptTemplateLike,
+ build_ocr_conversation,
+)
+from churro_ocr.templates.hf import HFChatTemplate
+from churro_ocr.templates.presets import (
+ CHURRO_3B_MODEL_ID,
+ CHURRO_3B_XML_TEMPLATE,
+ DEFAULT_OCR_TEMPLATE,
+ DOTS_OCR_1_5_MODEL_ID,
+ DOTS_OCR_1_5_OCR_PROMPT,
+ DOTS_OCR_1_5_OCR_TEMPLATE,
+)
+
+__all__ = [
+ "build_ocr_conversation",
+ "CHURRO_3B_MODEL_ID",
+ "CHURRO_3B_XML_TEMPLATE",
+ "DEFAULT_OCR_TEMPLATE",
+ "DOTS_OCR_1_5_MODEL_ID",
+ "DOTS_OCR_1_5_OCR_PROMPT",
+ "DOTS_OCR_1_5_OCR_TEMPLATE",
+ "HFChatTemplate",
+ "OCRConversation",
+ "OCRPromptTemplate",
+ "OCRPromptTemplateCallable",
+ "OCRPromptTemplateLike",
+]
diff --git a/src/churro_ocr/templates/base.py b/src/churro_ocr/templates/base.py
new file mode 100644
index 0000000..6fced04
--- /dev/null
+++ b/src/churro_ocr/templates/base.py
@@ -0,0 +1,45 @@
+"""Provider-neutral template protocols for OCR backends."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any, Protocol, runtime_checkable
+
+from churro_ocr.page_detection import DocumentPage
+
+OCRConversation = list[dict[str, Any]]
+
+
+@runtime_checkable
+class OCRPromptTemplate(Protocol):
+ """Protocol for OCR templates that build model conversations."""
+
+ def build_conversation(self, page: DocumentPage) -> OCRConversation: ...
+
+
+OCRPromptTemplateCallable = Callable[[DocumentPage], OCRConversation]
+OCRPromptTemplateLike = OCRPromptTemplate | OCRPromptTemplateCallable
+
+
+def build_ocr_conversation(template: OCRPromptTemplateLike, page: DocumentPage) -> OCRConversation:
+ """Build an OCR conversation from a template or template callable."""
+ if callable(template) and not isinstance(template, OCRPromptTemplate):
+ return template(page)
+ return template.build_conversation(page)
+
+
+# Internal aliases kept to make the refactor incremental while the
+# provider implementations migrate off the old HF-prefixed names.
+HFConversation = OCRConversation
+HFOCRTemplate = OCRPromptTemplate
+HFOCRTemplateCallable = OCRPromptTemplateCallable
+HFOCRTemplateLike = OCRPromptTemplateLike
+
+
+__all__ = [
+ "build_ocr_conversation",
+ "OCRConversation",
+ "OCRPromptTemplate",
+ "OCRPromptTemplateCallable",
+ "OCRPromptTemplateLike",
+]
diff --git a/src/churro_ocr/templates/hf.py b/src/churro_ocr/templates/hf.py
new file mode 100644
index 0000000..3aa53ce
--- /dev/null
+++ b/src/churro_ocr/templates/hf.py
@@ -0,0 +1,40 @@
+"""Built-in OCR templates."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.templates.base import OCRConversation
+
+
+@dataclass(slots=True, frozen=True)
+class HFChatTemplate:
+ """Template for processor/tokenizer chat-template OCR models."""
+
+ system_message: str | None = None
+ user_prompt: str | None = None
+ include_image: bool = True
+
+ def build_conversation(self, page: DocumentPage) -> OCRConversation:
+ """Build a structured multimodal conversation for one OCR page."""
+ conversation: OCRConversation = []
+ if self.system_message:
+ conversation.append(
+ {
+ "role": "system",
+ "content": [{"type": "text", "text": self.system_message}],
+ }
+ )
+
+ user_content: list[dict[str, object]] = []
+ if self.include_image:
+ user_content.append({"type": "image", "image": page.image.copy()})
+ if self.user_prompt:
+ user_content.append({"type": "text", "text": self.user_prompt})
+
+ conversation.append({"role": "user", "content": user_content})
+ return conversation
+
+
+__all__ = ["HFChatTemplate"]
diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py
new file mode 100644
index 0000000..9909db8
--- /dev/null
+++ b/src/churro_ocr/templates/presets.py
@@ -0,0 +1,33 @@
+"""Built-in OCR template presets."""
+
+from __future__ import annotations
+
+from churro_ocr.prompts import DEFAULT_OCR_SYSTEM_PROMPT, DEFAULT_OCR_USER_PROMPT
+from churro_ocr.templates.hf import HFChatTemplate
+
+CHURRO_3B_MODEL_ID = "stanford-oval/churro-3B"
+DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5"
+DEFAULT_OCR_TEMPLATE = HFChatTemplate(
+ system_message=DEFAULT_OCR_SYSTEM_PROMPT,
+ user_prompt=DEFAULT_OCR_USER_PROMPT,
+)
+
+CHURRO_3B_XML_TEMPLATE = HFChatTemplate(
+ system_message="Transcribe the entirety of this historical document to XML format.",
+ user_prompt=None,
+)
+DOTS_OCR_1_5_OCR_PROMPT = "Extract the text content from this image."
+DOTS_OCR_1_5_OCR_TEMPLATE = HFChatTemplate(
+ system_message=None,
+ user_prompt=DOTS_OCR_1_5_OCR_PROMPT,
+)
+
+
+__all__ = [
+ "CHURRO_3B_MODEL_ID",
+ "CHURRO_3B_XML_TEMPLATE",
+ "DEFAULT_OCR_TEMPLATE",
+ "DOTS_OCR_1_5_MODEL_ID",
+ "DOTS_OCR_1_5_OCR_PROMPT",
+ "DOTS_OCR_1_5_OCR_TEMPLATE",
+]
diff --git a/systems/__init__.py b/systems/__init__.py
deleted file mode 100644
index 448a6ed..0000000
--- a/systems/__init__.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""OCR systems package."""
-
-from importlib import import_module
-from typing import TYPE_CHECKING
-
-
-__all__ = [
- "BaseOCR",
- "ZeroShotLLMOCR",
- "AzureOCR",
- "MistralOCR",
- "FineTunedOCR",
- "OCRFactory",
- "LLMImprover",
-]
-
-_LAZY_MAP = {
- "BaseOCR": "churro.systems.base_ocr:BaseOCR",
- "ZeroShotLLMOCR": "churro.systems.llm_ocr:ZeroShotLLMOCR",
- "AzureOCR": "churro.systems.azure_ocr:AzureOCR",
- "MistralOCR": "churro.systems.mistral_ocr:MistralOCR",
- "FineTunedOCR": "churro.systems.finetuned_ocr:FineTunedOCR",
- "OCRFactory": "churro.systems.ocr_factory:OCRFactory",
- "LLMImprover": "churro.systems.llm_improver:LLMImprover",
-}
-
-_CACHE: dict[str, object] = {}
-
-if TYPE_CHECKING:
- from .azure_ocr import AzureOCR
- from .base_ocr import BaseOCR
- from .finetuned_ocr import FineTunedOCR
- from .llm_improver import LLMImprover
- from .llm_ocr import ZeroShotLLMOCR
- from .mistral_ocr import MistralOCR
- from .ocr_factory import OCRFactory
-
-
-def __getattr__(name: str) -> object:
- if name not in _LAZY_MAP:
- raise AttributeError(f"module 'churro.systems' has no attribute '{name}'")
- if name in _CACHE:
- return _CACHE[name]
- module_path, attr = _LAZY_MAP[name].split(":", 1)
- module = import_module(module_path)
- resolved = getattr(module, attr)
- _CACHE[name] = resolved
- return resolved
-
-
-def __dir__() -> list[str]:
- return sorted(__all__)
diff --git a/systems/azure_ocr.py b/systems/azure_ocr.py
deleted file mode 100644
index dc18c50..0000000
--- a/systems/azure_ocr.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Azure OCR implementations."""
-
-from typing import override
-
-from PIL import Image
-
-from .base_ocr import BaseOCR
-from .detect_layout import (
- run_azure_document_analysis_on_image,
-)
-
-
-class AzureOCR(BaseOCR):
- """Azure Document Intelligence OCR."""
-
- def __init__(self, **kwargs: object) -> None:
- super().__init__(**kwargs)
- self._initialized = False
-
- @override
- async def process_image(self, image: Image.Image) -> str:
- """Process a single image using Azure OCR."""
- azure_output = await run_azure_document_analysis_on_image(
- image, skip_paragraphs=False, output_ocr_text=True
- )
- assert isinstance(azure_output, str), (
- "Azure OCR output must be a string when output_ocr_text=True"
- )
- return azure_output
-
- def get_system_name(self) -> str:
- """Return human-readable system name."""
- return "Azure Document Intelligence OCR"
diff --git a/systems/base_ocr.py b/systems/base_ocr.py
deleted file mode 100644
index 21e8c3e..0000000
--- a/systems/base_ocr.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""Base OCR class and interfaces for all OCR systems."""
-
-from abc import ABC, abstractmethod
-from typing import cast
-
-from PIL import Image
-
-from churro.utils.concurrency import run_async_in_parallel
-from churro.utils.image.io import load_image_async
-from churro.utils.log_utils import logger
-
-
-class BaseOCR(ABC):
- """Base class for all OCR systems."""
-
- def __init__(self, **kwargs: object) -> None:
- """Initialize the OCR system with configuration parameters."""
- self.config: dict[str, object] = kwargs
-
- @abstractmethod
- async def process_image(self, image: Image.Image) -> str:
- """Process a single image and return the OCR result."""
- pass
-
- @abstractmethod
- def get_system_name(self) -> str:
- """Return the name of the OCR system."""
- pass
-
- async def process_images(self, images: list[Image.Image], *, max_concurrency: int) -> list[str]:
- """Process a list of images and return the OCR results."""
- raw_results = await run_async_in_parallel(
- self.process_image,
- images,
- max_concurrency=max_concurrency,
- desc=self.get_system_name(),
- )
- processed: list[str] = []
- for index, result in enumerate(raw_results):
- if result is None:
- message = (
- f"{self.get_system_name()} failed to process in-memory image at index {index}."
- )
- logger.error(message)
- result = ""
- processed.append(cast(str, result))
- return processed
-
- async def process_image_from_file(self, image_path: str) -> str:
- """Process a single image from a file path and return the OCR result."""
- image = await load_image_async(image_path)
- return await self.process_image(image)
-
- async def process_images_from_files(
- self, image_paths: list[str], max_concurrency: int
- ) -> list[str]:
- """Process a list of images from file paths and return the OCR results."""
- raw_results = await run_async_in_parallel(
- self.process_image_from_file,
- image_paths,
- max_concurrency=max_concurrency,
- desc=self.get_system_name(),
- )
- processed: list[str] = []
- for path, result in zip(image_paths, raw_results, strict=False):
- if result is None:
- message = f"{self.get_system_name()} failed to process file {path}."
- logger.error(message)
- result = ""
- processed.append(cast(str, result))
- return processed
diff --git a/systems/detect_layout.py b/systems/detect_layout.py
deleted file mode 100644
index f15bd57..0000000
--- a/systems/detect_layout.py
+++ /dev/null
@@ -1,541 +0,0 @@
-"""Azure Document Intelligence-powered layout detection utilities.
-
-This module provides helpers to detect page layout using Azure Document
-Intelligence (prebuilt-layout) and apply basic geometric transformations
-to align and then restore coordinates on the original image. It exposes a
-single high-level coroutine, `detect_layout`, that returns:
-
-- the processed `Page` (post-crop/rotation),
-- the transformed `Image` (post-crop/rotation), and
-- a second `Page` whose coordinates refer back to the original image
- (all transformations reversed).
-
-"""
-
-from __future__ import annotations
-
-import asyncio
-import hashlib
-from io import BytesIO
-import math
-from pathlib import Path
-import struct
-from typing import Any, Literal, cast
-
-from azure.ai.documentintelligence.aio import DocumentIntelligenceClient
-from azure.ai.documentintelligence.models import AnalyzeResult, DocumentAnalysisFeature
-from azure.core.credentials import AzureKeyCredential
-from azure.core.exceptions import HttpResponseError
-from diskcache import Cache
-from google.api_core.client_options import ClientOptions
-from google.api_core.exceptions import GoogleAPICallError
-from google.cloud import documentai
-from PIL.Image import Image
-from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
-
-from churro.config.settings import get_settings
-from churro.page.page import Page
-from churro.page.page_object import PageObject
-from churro.page.visualization import crop_image_to_objects
-from churro.utils.image.transform import adjust_image, rotate_image_and_page
-from churro.utils.log_utils import logger
-
-
-# -----------------------------
-# Constants and type aliases
-# -----------------------------
-_SETTINGS = get_settings()
-
-# Azure Document Intelligence model and request settings
-AZURE_DI_ENDPOINT: str | None = _SETTINGS.azure_document_intelligence.endpoint
-AZURE_DI_API_KEY: str | None = _SETTINGS.azure_document_intelligence.api_key
-AZURE_LAYOUT_MODEL_ID: str = "prebuilt-layout"
-AZURE_CONTENT_TYPE: str = "application/octet-stream"
-
-# Pricing ($10 layout + $6 high-res) per 1000 pages
-AZURE_DI_COST_PER_PAGE_USD: float = 16 / 1000
-
-# Transformation tracking: either ("rotate", angle) or ("shift", dx, dy)
-Transformation = tuple[Literal["rotate"], float] | tuple[Literal["shift"], float, float]
-
-# Google Document AI OCR settings
-GOOGLE_PROJECT_ID: str | None = _SETTINGS.vertex_ai.project_id
-GOOGLE_LOCATION: str = _SETTINGS.vertex_ai.document_ai_location
-GOOGLE_OCR_PROCESSOR_ID: str | None = _SETTINGS.vertex_ai.ocr_processor_id
-GOOGLE_OCR_PROCESSOR_VERSION: str | None = _SETTINGS.vertex_ai.ocr_processor_version
-
-GOOGLE_DOCUMENT_AI_COST_PER_PAGE_USD: float = (
- 1.5 / 1000
-) # https://cloud.google.com/document-ai/pricing
-
-
-# -----------------------------
-# Module state
-# -----------------------------
-
-azure_document_intelligence_client: DocumentIntelligenceClient | None = None
-total_azure_cost: float = 0.0
-
-google_document_ai_client: documentai.DocumentProcessorServiceClient | None = None
-google_document_ai_endpoint: str | None = None
-total_google_document_ai_cost: float = 0.0
-
-AZURE_DI_CACHE_PATH = (
- Path(__file__).resolve().parents[1] / ".diskcache" / "azure_document_intelligence"
-)
-azure_di_cache = Cache(str(AZURE_DI_CACHE_PATH))
-
-
-def log_total_azure_cost() -> None:
- """Log the running total Azure Document Intelligence cost for this process."""
- global total_azure_cost
- logger.info(f"Total Azure Document Intelligence cost: ${total_azure_cost:.2f}")
-
-
-def get_total_azure_cost() -> float:
- """Return the accumulated Azure API cost."""
- global total_azure_cost
- return total_azure_cost
-
-
-def _build_azure_cache_key(
- image_bytes: bytes,
- skip_paragraphs: bool,
- output_ocr_text: bool,
-) -> str:
- digest = hashlib.sha256(image_bytes).hexdigest()
- return f"azure_di:{digest}:{int(skip_paragraphs)}:{int(output_ocr_text)}"
-
-
-def log_total_google_document_ai_cost() -> None:
- """Log the running total Google Document AI cost for this process."""
- global total_google_document_ai_cost
- logger.info(f"Total Google Document AI cost: ${total_google_document_ai_cost:.2f}")
-
-
-def get_total_google_document_ai_cost() -> float:
- """Return the accumulated Google Document AI cost."""
- global total_google_document_ai_cost
- return total_google_document_ai_cost
-
-
-def _determine_google_endpoint(location: str) -> str:
- if location and location.lower() != "global":
- return f"{location}-documentai.googleapis.com"
- return "documentai.googleapis.com"
-
-
-def _ensure_google_document_ai_client(
- location: str,
-) -> documentai.DocumentProcessorServiceClient:
- """Initialize or return a cached Google Document AI client for the given location."""
- global google_document_ai_client, google_document_ai_endpoint
- endpoint = _determine_google_endpoint(location)
- if google_document_ai_client is not None and google_document_ai_endpoint == endpoint:
- return google_document_ai_client
-
- client_options = ClientOptions(api_endpoint=endpoint)
- google_document_ai_client = documentai.DocumentProcessorServiceClient(
- client_options=client_options
- )
- google_document_ai_endpoint = endpoint
- return google_document_ai_client
-
-
-def _resolve_google_processor_name(
- client: documentai.DocumentProcessorServiceClient,
-) -> str:
- if GOOGLE_PROJECT_ID is None:
- raise RuntimeError(
- "Google Document AI project is not configured. Set VERTEX_AI_PROJECT_ID or GOOGLE_CLOUD_PROJECT."
- )
- if GOOGLE_OCR_PROCESSOR_ID is None:
- raise RuntimeError(
- "Google Document AI processor is not configured. Set VERTEX_AI_OCR_PROCESSOR_ID."
- )
-
- processor_name = client.processor_path(
- GOOGLE_PROJECT_ID, GOOGLE_LOCATION, GOOGLE_OCR_PROCESSOR_ID
- )
- if GOOGLE_OCR_PROCESSOR_VERSION:
- processor_name = client.processor_version_path(
- GOOGLE_PROJECT_ID,
- GOOGLE_LOCATION,
- GOOGLE_OCR_PROCESSOR_ID,
- GOOGLE_OCR_PROCESSOR_VERSION,
- )
- return processor_name
-
-
-def _layout_to_coordinates(
- layout: documentai.Document.Page.Layout | None, width: float, height: float
-) -> list[float] | None:
- if layout is None or layout.bounding_poly is None:
- return None
-
- coords: list[float] = []
- vertices = layout.bounding_poly.vertices or []
- if vertices:
- for vertex in vertices:
- if vertex.x is None or vertex.y is None:
- continue
- coords.extend([float(vertex.x), float(vertex.y)])
- else:
- normalized_vertices = layout.bounding_poly.normalized_vertices or []
- for vertex in normalized_vertices:
- if vertex.x is None or vertex.y is None:
- continue
- coords.extend([float(vertex.x) * width, float(vertex.y) * height])
-
- if len(coords) < 6 or len(coords) % 2 != 0:
- logger.debug(f"Skipping polygon creation due to invalid coordinates: {coords}")
- return None
- return coords
-
-
-def _collect_text_spans(
- layout: documentai.Document.Page.Layout | None,
-) -> list[tuple[int, int]]:
- if layout is None or layout.text_anchor is None:
- return []
- spans: list[tuple[int, int]] = []
- for segment in layout.text_anchor.text_segments:
- start_index = int(segment.start_index) if segment.start_index is not None else 0
- end_index = int(segment.end_index) if segment.end_index is not None else start_index
- spans.append((start_index, end_index))
- return spans
-
-
-def _is_span_within(span: tuple[int, int], containers: list[tuple[int, int]]) -> bool:
- start, end = span
- for container_start, container_end in containers:
- if start >= container_start and end <= container_end:
- return True
- return False
-
-
-def _build_page_from_google_document(
- document: documentai.Document,
- skip_paragraphs: bool,
- image_width: int,
- image_height: int,
-) -> Page:
- if not document.pages:
- raise RuntimeError("Google Document AI response did not contain any pages.")
-
- page_proto = document.pages[0]
- width = float(page_proto.dimension.width or image_width)
- height = float(page_proto.dimension.height or image_height)
-
- page_objects: list[PageObject] = []
- object_id = 1
- paragraph_spans: list[tuple[int, int]] = []
-
- if not skip_paragraphs:
- for paragraph in getattr(page_proto, "paragraphs", []):
- coords = _layout_to_coordinates(paragraph.layout, width, height)
- if coords is None:
- continue
- paragraph_spans.extend(_collect_text_spans(paragraph.layout))
- page_objects.append(
- PageObject(
- object_id=str(object_id),
- coordinates=coords,
- )
- )
- object_id += 1
-
- lines_added = 0
- for line in getattr(page_proto, "lines", []):
- spans = _collect_text_spans(line.layout)
- if spans and all(_is_span_within(span, paragraph_spans) for span in spans):
- continue
- coords = _layout_to_coordinates(line.layout, width, height)
- if coords is None:
- continue
- lines_added += 1
- page_objects.append(
- PageObject(
- object_id=str(object_id),
- coordinates=coords,
- )
- )
- object_id += 1
-
- if lines_added > 0:
- logger.info(f"Added {lines_added} Google OCR lines that were not part of any paragraph")
-
- page = Page(
- page_objects=page_objects,
- )
-
- return page
-
-
-def _google_skew_from_transforms(page_proto: documentai.Document.Page) -> float | None:
- transforms = getattr(page_proto, "transforms", [])
- if not transforms:
- return None
- matrix = transforms[0]
- if matrix.rows != 2 or matrix.cols != 3:
- logger.debug(f"Unexpected transform matrix dimensions: {matrix.rows}x{matrix.cols}")
- return None
- try:
- a, b, _tx, c, d, _ty = struct.unpack("<6d", matrix.data)
- except struct.error as exc:
- logger.debug(f"Failed to unpack Google transform matrix: {exc}")
- return None
- try:
- rotation_radians = math.atan2(b, a)
- except (TypeError, ValueError) as exc:
- logger.debug(f"Invalid rotation components in transform matrix: {exc}")
- return None
- return -math.degrees(rotation_radians)
-
-
-def _google_orientation_to_angle(page_proto: documentai.Document.Page) -> float:
- skew_angle = _google_skew_from_transforms(page_proto)
- if skew_angle is not None:
- return skew_angle
-
- if page_proto.layout and page_proto.layout.orientation is not None:
- orientation_map = {
- documentai.Document.Page.Layout.Orientation.PAGE_UP: 0.0,
- documentai.Document.Page.Layout.Orientation.PAGE_RIGHT: -90.0,
- documentai.Document.Page.Layout.Orientation.PAGE_DOWN: 180.0,
- documentai.Document.Page.Layout.Orientation.PAGE_LEFT: 90.0,
- }
- try:
- return orientation_map.get(page_proto.layout.orientation, 0.0)
- except AttributeError:
- orientation_name = str(page_proto.layout.orientation)
- fallback_map = {
- "PAGE_UP": 0.0,
- "PAGE_RIGHT": -90.0,
- "PAGE_DOWN": 180.0,
- "PAGE_LEFT": 90.0,
- }
- return fallback_map.get(orientation_name.upper(), 0.0)
-
- return 0.0
-
-
-@retry(
- stop=stop_after_attempt(3),
- wait=wait_fixed(3),
- retry=retry_if_exception_type(HttpResponseError),
-)
-async def run_azure_document_analysis_on_image(
- image: Image, skip_paragraphs: bool, output_ocr_text: bool = False
-) -> tuple[Page, float] | str:
- """Analyze an image with Azure Document Intelligence.
-
- Args:
- image: PIL image to analyze. The image is serialized to JPEG in memory.
- skip_paragraphs: Passed to Page conversion logic to optionally skip paragraphs.
- output_ocr_text: If True, returns the raw OCR text content instead of a Page.
-
- Returns:
- Either a tuple of (Page, angle_degrees) where angle is the page rotation angle
- reported by Azure, or a string containing the OCR text when output_ocr_text is True.
- """
- logger.info("Running Azure Document Analysis on image")
- _ensure_azure_di_initialized()
-
- # Preprocess to improve OCR/layout analysis
- image = adjust_image(image, thresholding=True)
-
- # Save the processed image to an in-memory stream
- image_stream = BytesIO()
- image.save(image_stream, format="jpeg")
- image_bytes = image_stream.getvalue()
-
- cache_key = _build_azure_cache_key(image_bytes, skip_paragraphs, output_ocr_text)
- cached_payload_raw = await asyncio.to_thread(azure_di_cache.get, cache_key)
- if cached_payload_raw is not None:
- cached_payload = cast(dict[str, Any], cached_payload_raw)
- logger.info("Azure Document Intelligence cache hit; returning cached result")
- if cached_payload["kind"] == "text":
- return cached_payload["content"]
- cached_page = Page.model_validate(cached_payload["page"])
- cached_angle = float(cached_payload.get("angle", 0.0))
- return cached_page, cached_angle
-
- image_stream = BytesIO(image_bytes)
-
- assert isinstance(azure_document_intelligence_client, DocumentIntelligenceClient), (
- "Azure Document Intelligence client is not initialized"
- )
- poller = await azure_document_intelligence_client.begin_analyze_document(
- model_id=AZURE_LAYOUT_MODEL_ID,
- body=image_stream,
- content_type=AZURE_CONTENT_TYPE,
- features=[
- DocumentAnalysisFeature.OCR_HIGH_RESOLUTION,
- ],
- )
- analysis_result: AnalyzeResult = await poller.result()
- global total_azure_cost
- total_azure_cost += AZURE_DI_COST_PER_PAGE_USD
-
- if output_ocr_text:
- cache_value: dict[str, Any] = {"kind": "text", "content": analysis_result.content}
- await asyncio.to_thread(azure_di_cache.set, cache_key, cache_value)
- return analysis_result.content
-
- page = Page.from_azure_analysis_result(analysis_result, skip_paragraphs=skip_paragraphs)
- # Robustly handle missing/None angles
- try:
- angle_value = analysis_result.pages[0].angle # type: ignore[assignment]
- except (AttributeError, IndexError):
- angle_value = 0.0
- angle: float = float(angle_value or 0.0)
-
- cache_value: dict[str, Any] = {
- "kind": "page",
- "page": page.model_dump(mode="python"),
- "angle": angle,
- }
- await asyncio.to_thread(azure_di_cache.set, cache_key, cache_value)
-
- return page, angle
-
-
-async def shutdown_layout_clients() -> None:
- """Close any cached async clients used for layout detection."""
- global azure_document_intelligence_client, google_document_ai_client
-
- if azure_document_intelligence_client is not None:
- try:
- await azure_document_intelligence_client.close()
- finally:
- azure_document_intelligence_client = None
-
- if google_document_ai_client is not None:
- close_fn = getattr(google_document_ai_client, "close", None)
- transport = getattr(google_document_ai_client, "transport", None)
- try:
- if callable(close_fn):
- close_fn()
- elif transport is not None:
- transport_close = getattr(transport, "close", None)
- if callable(transport_close):
- transport_close()
- finally:
- google_document_ai_client = None
-
-
-@retry(
- stop=stop_after_attempt(3),
- wait=wait_fixed(3),
- retry=retry_if_exception_type(GoogleAPICallError),
-)
-async def run_google_document_analysis_on_image(
- image: Image, skip_paragraphs: bool, output_ocr_text: bool = False
-) -> tuple[Page, float] | str:
- """Analyze an image with Google Document AI OCR processor."""
- logger.info("Running Google Document AI OCR on image")
-
- client = _ensure_google_document_ai_client(GOOGLE_LOCATION)
- processor_name = _resolve_google_processor_name(client)
-
- image_stream = BytesIO()
- image.save(image_stream, format="jpeg")
- document_bytes = image_stream.getvalue()
-
- request = documentai.ProcessRequest(
- name=processor_name,
- raw_document=documentai.RawDocument(content=document_bytes, mime_type="image/jpeg"),
- process_options=documentai.ProcessOptions(
- ocr_config=documentai.OcrConfig(
- enable_image_quality_scores=True,
- )
- ),
- )
-
- response = await asyncio.to_thread(client.process_document, request=request)
-
- global total_google_document_ai_cost
- total_google_document_ai_cost += GOOGLE_DOCUMENT_AI_COST_PER_PAGE_USD
-
- document = response.document
- if output_ocr_text:
- return document.text or ""
-
- page = _build_page_from_google_document(
- document,
- skip_paragraphs=skip_paragraphs,
- image_width=image.width,
- image_height=image.height,
- )
- page_proto = document.pages[0]
- angle = _google_orientation_to_angle(page_proto)
-
- return page, angle
-
-
-async def tidy_image_via_layout_detection(
- image: Image,
- margin: int = 30,
-) -> Image:
- """Detect layout and return processed and original-coordinate pages.
-
- This performs light preprocessing, calls Azure DI layout analysis, applies
- rotation and content-cropping, and then returns a deep-copied `Page` with
- all transformations reversed so the coordinates map to the original image.
-
- Args:
- image: Input page image.
- margin: Margin in pixels to add around the detected content when cropping.
-
- Returns:
- processed_image.
- """
- result = await run_azure_document_analysis_on_image(image, skip_paragraphs=False)
- # We always call with output_ocr_text=False, so result must be (Page, float)
- if isinstance(result, str): # defensive runtime check
- raise RuntimeError("Unexpected OCR text output when a Page was expected.")
- page, page_angle = result
-
- page.remove_subsumed_page_objects()
- page.remove_small_page_objects_in_margins()
-
- # Apply rotation if needed
- image, page = rotate_image_and_page(image, page_angle, page)
-
- # Crop to content and shift page objects accordingly
- image = crop_image_to_objects(image, page.page_objects, margin=margin)
-
- return image
-
-
-def _ensure_azure_di_initialized() -> DocumentIntelligenceClient:
- """Initialize or return a cached Azure DI client.
-
- Reads the API key from the AZURE_DOC_KEY environment variable and the endpoint
- from AZURE_DI_ENDPOINT (optional). A singleton client is cached in the module
- state for reuse.
-
- Returns:
- A configured `DocumentIntelligenceClient` instance.
-
- Raises:
- ValueError: If the AZURE_DOC_KEY environment variable is not set.
- """
- global azure_document_intelligence_client
- if azure_document_intelligence_client is not None:
- return azure_document_intelligence_client
-
- api_key = AZURE_DI_API_KEY
- if not api_key:
- raise ValueError("No Azure Document Key found (AZURE_DOC_KEY not set)")
-
- if AZURE_DI_ENDPOINT is None:
- raise RuntimeError(
- "AZURE_DI_ENDPOINT environment variable must be set before using Azure layout detection."
- )
- azure_document_intelligence_client = DocumentIntelligenceClient(
- endpoint=AZURE_DI_ENDPOINT,
- credential=AzureKeyCredential(api_key),
- )
- return azure_document_intelligence_client
diff --git a/systems/finetuned_ocr.py b/systems/finetuned_ocr.py
deleted file mode 100644
index e4fa0ba..0000000
--- a/systems/finetuned_ocr.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""Finetuned OCR implementation using a vLLM OpenAI-compatible server."""
-
-from __future__ import annotations
-
-from typing import override
-
-from PIL import Image
-
-from churro.evaluation.xml_utils import extract_actual_text_from_xml
-from churro.utils.llm.core import run_llm_async
-from churro.utils.llm.models import COMPLETION_TOKENS_FOR_STANDARD_MODELS
-from churro.utils.log_utils import logger
-
-from .base_ocr import BaseOCR
-
-
-SYSTEM_MESSAGE = """Transcribe the entiretly of this historical documents to XML format."""
-
-
-class FineTunedOCR(BaseOCR):
- """Finetuned model OCR using a locally hosted vLLM HTTP server."""
-
- def __init__(
- self,
- engine: str,
- max_new_tokens: int = COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- strip_xml: bool = False,
- **_: object,
- ) -> None:
- self.engine = engine
- self.max_new_tokens = max_new_tokens
- self._system_message: str = SYSTEM_MESSAGE
- self._strip_xml = strip_xml
-
- @override
- async def process_image(self, image: Image.Image) -> str:
- """Process a single image using the finetuned OCR model."""
- llm_output = await run_llm_async(
- model=self.engine,
- system_prompt_text=self._system_message,
- user_message_text=None,
- user_message_image=image,
- )
- if not isinstance(llm_output, str):
- llm_output = ""
- if self._strip_xml and llm_output:
- try:
- llm_output = extract_actual_text_from_xml(llm_output)
- except Exception:
- logger.exception(
- "Failed to extract text from finetuned XML output; returning raw XML."
- )
- return llm_output
-
- def get_system_name(self) -> str:
- """Return human-readable system name."""
- return "Fine-tuned OCR Model"
diff --git a/systems/llm_improver.py b/systems/llm_improver.py
deleted file mode 100644
index fefa7c0..0000000
--- a/systems/llm_improver.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""LLM-based OCR improvement implementation."""
-
-from functools import partial
-import textwrap
-from typing import Literal, cast
-
-from churro.utils.concurrency import run_async_in_parallel
-from churro.utils.image.io import load_image_async
-from churro.utils.image.transform import resize_image_to_fit
-from churro.utils.llm import LLMInferenceError, extract_tag_from_llm_output, run_llm_async
-from churro.utils.log_utils import logger
-
-
-class LLMImprover:
- """LLM-based OCR improvement using vision models."""
-
- def __init__(
- self,
- engine: str,
- resize: int | None = None,
- image_fidelity: Literal["high", "low"] = "high",
- backup_engine: str | None = None,
- **kwargs: object,
- ) -> None:
- super().__init__(**kwargs)
- self.engine = engine
- self.resize = resize
- self.image_fidelity = image_fidelity
-
- if not backup_engine:
- self.backup_engine = engine
- else:
- self.backup_engine = backup_engine
-
- self.system_prompt = (
- "You are a meticulous historical document editor. Improve OCR text by fixing "
- "recognition errors and formatting while preserving every original word and "
- "punctuation mark. Return the final document inside and "
- " tags."
- )
-
- self.instruction_template = """You receive a document image and its OCR text.
-
- Follow these steps:
-
- 1. Compare the image and OCR text, correcting transcription and formatting mistakes.
- 2. Merge split words, handle punctuation precisely, and preserve any non-English text.
- 3. Apply Markdown only when it is clearly supported by the image (headings, bold, italics).
- 4. Clean Markdown layout without changing words or punctuation; adjust only structure, whitespace, and line breaks. Rejoin hyphenated line breaks such as 'co-\\noperate' -> 'cooperate'.
- 5. Return the complete improved document exactly once between the tags, with no commentary, metadata, or code fences.
-
- Original OCR text:
- {ocr_text}
-
- Output format:
-
-
- [Improved document here]
- """
-
- async def process_batch_inputs(
- self, image_paths: list[str], texts: list[str], max_concurrency: int
- ) -> list[str]:
- """Process a batch of image-text pairs using LLM OCR improvement."""
- raw_results = await run_async_in_parallel(
- partial(self._process_single_input),
- image_paths,
- texts,
- max_concurrency=max_concurrency,
- desc="LLM OCR Improvement",
- )
- improved_texts: list[str] = []
- for image_path, original_text, result in zip(image_paths, texts, raw_results, strict=False):
- if result is None:
- logger.warning(f"LLM improvement failed for {image_path}; returning original text.")
- improved_texts.append(original_text)
- else:
- improved_texts.append(result)
- return improved_texts
-
- async def _process_single_input(self, image_path: str, ocr_text: str) -> str:
- """Process a single image-text pair using LLM OCR improvement."""
- image = await load_image_async(image_path)
-
- if self.resize:
- image = resize_image_to_fit(image, self.resize, self.resize)
-
- instruction = textwrap.dedent(self.instruction_template.format(ocr_text=ocr_text))
-
- kwargs_for_llm = {
- "model": self.engine,
- "system_prompt_text": self.system_prompt,
- "user_message_text": instruction,
- "user_message_image": image,
- "image_detail": self.image_fidelity,
- "timeout": 60 * 5,
- }
-
- try:
- llm_output = await run_llm_async(**kwargs_for_llm)
- except LLMInferenceError as exc:
- logger.warning(
- f"LLM improvement primary engine '{self.engine}' failed for {image_path}: {exc}"
- )
- if self.backup_engine and self.backup_engine != self.engine:
- kwargs_for_llm["model"] = self.backup_engine
- try:
- llm_output = await run_llm_async(**kwargs_for_llm)
- except LLMInferenceError as backup_exc:
- logger.error(
- f"LLM improvement backup engine '{self.backup_engine}' failed for {image_path}: {backup_exc}"
- )
- raise
- else:
- raise
-
- if "" not in llm_output:
- logger.warning(
- f"LLM output does not contain tag. Output: {llm_output}."
- )
- improved_output = ""
- else:
- improved_output = cast(
- str, extract_tag_from_llm_output(llm_output, tags="improved_text")
- )
-
- if not improved_output:
- if self.backup_engine and self.backup_engine != self.engine:
- logger.warning(f"LLM output is empty. Retrying with {self.backup_engine}.")
- kwargs_for_llm["model"] = self.backup_engine
- try:
- llm_output = await run_llm_async(**kwargs_for_llm)
- except LLMInferenceError as backup_exc:
- logger.error(
- f"LLM improvement backup engine '{self.backup_engine}' failed for {image_path}: {backup_exc}"
- )
- raise
- improved_output = cast(
- str, extract_tag_from_llm_output(llm_output, tags="improved_text")
- )
-
- return improved_output.strip() if improved_output else ocr_text
diff --git a/systems/llm_ocr.py b/systems/llm_ocr.py
deleted file mode 100644
index fc55ccc..0000000
--- a/systems/llm_ocr.py
+++ /dev/null
@@ -1,150 +0,0 @@
-"""LLM-based OCR implementation."""
-
-from typing import Literal, override
-
-from PIL import Image
-
-from churro.utils.image.transform import resize_image_to_fit
-from churro.utils.llm import LLMInferenceError, extract_tag_from_llm_output, run_llm_async
-from churro.utils.log_utils import logger
-
-from .base_ocr import BaseOCR
-
-
-class ZeroShotLLMOCR(BaseOCR):
- """LLM-based OCR using vision models."""
-
- OUTPUT_TAG = "answer"
-
- def __init__(
- self,
- engine: str,
- reasoning_effort: Literal["low", "medium", "high"] | None = None,
- resize: int | None = None,
- output_markdown: bool = False,
- backup_engine: str | None = None,
- **kwargs: object,
- ) -> None:
- super().__init__(**kwargs)
- self.engine = engine
- self.resize = resize
- self.reasoning_effort = reasoning_effort
- self.backup_engine = backup_engine
-
- if output_markdown:
- self.system_prompt = (
- "You are an expert in transcription of historical documents "
- "from various languages. Your task is to extract the full text from a "
- f"given page in Markdown format. Only output the markdown text between <{self.OUTPUT_TAG}> and {self.OUTPUT_TAG}> tags."
- )
- else:
- self.system_prompt = (
- "You are an expert in diplomatic transcription of historical documents "
- "from various languages. Your task is to extract the full text from a "
- f"given page. Only output the transcribed text between <{self.OUTPUT_TAG}> and {self.OUTPUT_TAG}> tags."
- )
-
- self.instruction = f"""Follow these instructions:
-
- 1. You will be provided with a scanned document page.
-
- 2. Perform transcription on the entirety of the page, converting all visible text into the following format. Include handwritten and print text, if any. Include tables, captions, headers, main text and all other visible text.
-
- 3. If you encounter any non-text elements, simply skip them without attempting to describe them.
-
- 4. Do not modernize or standardize the text. For example, if the transcription is using "ſ" instead of "s" or "а" instead of "a", keep it that way.
-
- 5. When you come across text in languages other than English, transcribe it as accurately as possible without translation.
-
- 6. Output the OCR result in the following format:
-
- <{self.OUTPUT_TAG}>
- extracted text here
- {self.OUTPUT_TAG}>
-
- Remember, your goal is to accurately transcribe the text from the scanned page as much as possible. Process the entire page, even if it contains a large amount of text, and provide clear, well-formatted output. Pay attention to the appropriate reading order and layout of the text."""
-
- @override
- async def process_image(self, image: Image.Image) -> str:
- """Process a single image using LLM OCR."""
- if self.resize:
- image = resize_image_to_fit(image, self.resize, self.resize)
-
- kwargs_for_llm = {
- "model": self.engine,
- "system_prompt_text": self.system_prompt,
- "user_message_text": self.instruction,
- "user_message_image": image,
- "image_detail": "high",
- "timeout": 60 * 10, # 10 minutes
- }
- if self.reasoning_effort:
- kwargs_for_llm["reasoning_effort"] = self.reasoning_effort
-
- engines_to_try: list[str] = [self.engine]
- if self.backup_engine and self.backup_engine != self.engine:
- engines_to_try.append(self.backup_engine)
-
- last_error: Exception | None = None
- extracted_output: str | None = None
- for current_engine in engines_to_try:
- kwargs_for_llm["model"] = current_engine
- try:
- llm_output = await run_llm_async(**kwargs_for_llm)
- except LLMInferenceError as exc:
- last_error = exc
- logger.warning(
- f"LLM engine '{current_engine}' failed for ZeroShotLLMOCR: {exc}. "
- "Trying next candidate if available."
- )
- continue
-
- if f"<{self.OUTPUT_TAG}>" not in llm_output:
- logger.warning(
- f"LLM output does not contain <{self.OUTPUT_TAG}> tag. Output: {llm_output}"
- )
- if current_engine in {"nanonets-ocr-s", "rolmocr"}:
- extracted_output = llm_output
- else:
- extracted_output = ""
- else:
- extracted_output = extract_tag_from_llm_output(llm_output, tags=self.OUTPUT_TAG)
-
- if extracted_output:
- break
-
- if current_engine == engines_to_try[-1]:
- break
-
- logger.warning(
- f"LLM output from '{current_engine}' is empty. Retrying with backup engine."
- )
- else:
- if last_error:
- raise LLMInferenceError(
- f"All OCR engines failed for {self.get_system_name()}"
- ) from last_error
- raise LLMInferenceError(f"No OCR output received for {self.get_system_name()}.")
-
- if not extracted_output:
- if last_error:
- raise LLMInferenceError(
- f"Failed to obtain OCR output after exhausting engines: {last_error}"
- ) from last_error
- raise LLMInferenceError(
- f"Failed to obtain OCR output for {self.get_system_name()} (empty response)."
- )
- assert isinstance(extracted_output, str)
-
- while f"<{self.OUTPUT_TAG}>" in extracted_output:
- extracted_output = extracted_output[
- extracted_output.index(f"<{self.OUTPUT_TAG}>") + len(f"<{self.OUTPUT_TAG}>") :
- ]
- if f"{self.OUTPUT_TAG}>" in extracted_output:
- extracted_output = extracted_output[: extracted_output.index(f"{self.OUTPUT_TAG}>")]
-
- return extracted_output
-
- @override
- def get_system_name(self) -> str:
- return "Zero-Shot LLM"
diff --git a/systems/mistral_ocr.py b/systems/mistral_ocr.py
deleted file mode 100644
index de88ed8..0000000
--- a/systems/mistral_ocr.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""Mistral OCR implementations."""
-
-from typing import override
-
-import mistralai
-from PIL import Image
-from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
-
-from churro.config.settings import get_settings
-from churro.utils.llm import encode_image
-
-from .base_ocr import BaseOCR
-
-
-class MistralOCR(BaseOCR):
- """Mistral OCR implementation."""
-
- def __init__(self, **kwargs: object) -> None:
- super().__init__(**kwargs)
- self._client = None
-
- def _get_client(self) -> mistralai.Mistral:
- """Get or create Mistral client."""
- if self._client is None:
- api_key = get_settings().tokens.mistral
- if not api_key:
- raise ValueError("MISTRAL_API_KEY is required to use Mistral OCR.")
- self._client = mistralai.Mistral(api_key=api_key)
- return self._client
-
- @retry(
- stop=stop_after_attempt(3),
- wait=wait_fixed(30),
- retry=retry_if_exception_type(mistralai.models.sdkerror.SDKError),
- )
- @override
- async def process_image(self, image: Image.Image) -> str:
- """Process a single image using Mistral OCR."""
- client = self._get_client()
-
- base64_image = encode_image(image)
- if base64_image is None:
- return ""
-
- image_url = f"data:image/jpeg;base64,{base64_image}"
- response = await client.ocr.process_async(
- model="mistral-ocr-latest",
- document={
- "type": "image_url",
- "image_url": image_url,
- },
- )
-
- return response.pages[0].markdown
-
- def get_system_name(self) -> str:
- """Return human-readable system name."""
- return "Mistral OCR"
diff --git a/systems/ocr_factory.py b/systems/ocr_factory.py
deleted file mode 100644
index 619f077..0000000
--- a/systems/ocr_factory.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""OCR Factory for creating OCR system instances."""
-
-from argparse import Namespace
-from importlib import import_module
-from typing import TYPE_CHECKING, Any
-
-
-if TYPE_CHECKING:
- from .base_ocr import BaseOCR
-
-
-class OCRFactory:
- """Factory class for creating OCR system instances."""
-
- _REGISTRY: dict[str, str] = {
- "azure": "churro.systems.azure_ocr:AzureOCR",
- "mistral_ocr": "churro.systems.mistral_ocr:MistralOCR",
- "llm": "churro.systems.llm_ocr:ZeroShotLLMOCR",
- "finetuned": "churro.systems.finetuned_ocr:FineTunedOCR",
- }
-
- _CACHE: dict[str, type["BaseOCR"]] = {}
-
- @classmethod
- def get_available_systems(cls) -> list[str]:
- """Get list of available OCR system names."""
- return list(cls._REGISTRY.keys())
-
- @classmethod
- def create_ocr_system(cls, args: Namespace) -> "BaseOCR":
- """Create an OCR system instance by name."""
- system_name = args.system
- if system_name not in cls._REGISTRY:
- available_systems = ", ".join(cls._REGISTRY.keys())
- raise ValueError(
- f"Invalid system: {system_name}. Available systems: {available_systems}"
- )
-
- ocr_class = cls._load_class(system_name)
- return ocr_class(**_extract_system_config(args))
-
- @classmethod
- def _load_class(cls, system_name: str) -> type["BaseOCR"]:
- if system_name in cls._CACHE:
- return cls._CACHE[system_name]
- module_path, class_name = cls._REGISTRY[system_name].split(":", 1)
- module = import_module(module_path)
- ocr_class = getattr(module, class_name)
- cls._CACHE[system_name] = ocr_class
- return ocr_class
-
-
-def _extract_system_config(args: Namespace) -> dict[str, Any]:
- """Extract configuration parameters for the OCR system from args."""
- config = {}
-
- # Common parameters
- if hasattr(args, "engine"):
- config["engine"] = args.engine
- if hasattr(args, "backup_engine"):
- config["backup_engine"] = args.backup_engine
- if hasattr(args, "max_tokens"):
- config["max_tokens"] = args.max_tokens
- if getattr(args, "system", None) == "finetuned" and hasattr(args, "strip_xml"):
- config["strip_xml"] = args.strip_xml
-
- # LLM-specific parameters
- if hasattr(args, "resize"):
- config["resize"] = args.resize
- if hasattr(args, "reasoning_effort"):
- config["reasoning_effort"] = args.reasoning_effort
- if hasattr(args, "output_markdown"):
- config["output_markdown"] = args.output_markdown
-
- # Pipeline-specific parameters
- if hasattr(args, "layout_detection_models"):
- config["layout_detection_models"] = args.layout_detection_models
- if hasattr(args, "layout_detection_num_splits"):
- config["layout_detection_num_splits"] = args.layout_detection_num_splits
- if hasattr(args, "layout_detection_num_iterations"):
- config["layout_detection_num_iterations"] = args.layout_detection_num_iterations
-
- return config
diff --git a/tests/minimal-document.pdf b/tests/assets/minimal-document.pdf
similarity index 100%
rename from tests/minimal-document.pdf
rename to tests/assets/minimal-document.pdf
diff --git a/tests/churro_dataset_sample_1.jpeg b/tests/churro_dataset_sample_1.jpeg
deleted file mode 100644
index bb3db9235b67aee62f6525e110cb2158919b672e..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 701122
zcmeFZ1yq|+*Cv|a?h>3ra4GHuiUcq2(Be`ETC6~E_W;3ZvEmf>;;zLZxCAK0X(<#6
zmw)E2neU%}=H9t?jr@1r?_}+)tT!ilv(NixpXWUL+2?WjaSK4C3Q>UofIt8M`1Alg
zt^(u%SQr?X80c7-n3&kuSU7lO_;|RucvK{$gk*Ho4D@u=w6u(1UN%N1Zf06qc2N#)
zK7Jt~AqF<_7h(cZyn;dk|MDe3Y;0^iTs#myK1kph?K6S@$L+BPK#YYwfKiPKWCEZN
z15t^Aj{^XPr{9SV{0|NAe>NZrDjGTlCKfgh?o)>*A^-{y6%_>y6&)Q7?WuR*)B6B4
zVsw&c{4X&`wJkB3+{pw&QVOt`4FLb!N`Nf>s`(*fqPwT-Qvy@TUh&v#zlKE8hM!@?sXqoQMCVX0~9@Qlo??82hr
zlFy}O>qu;rFA9%d4Niu7BU$
z{`nWbfB@A0=+@Koe{}4>@Qe7#FBCL1R5Z+g@e7FJ^JJ*RXz0)QF-Tr&V_LeCG6{rW
zk;$bL)b(OB3+kMcTY1djP=JNDSuXy?wg2GRf1hKa|F1mzPmcX(zYqXCRN&LaLnQ{t
z0Dk}Bntout*LVa(c>c|J1dt@15VqWQUtS@dR~r6CPWjxutC8-%4$FT89Ir~TA^%pK
z@H|`h<^kUT^WRg_!=1ixA&JU1P6Nzo|5Y@_3P-&nGLQ4-UK!_gRu#IVp^D
zC*iQY+{3F7C#XPFE9tWf(UNj
z+_@a6<|~@E{rt`ZVWxD4gA8ne+pOX;zTzNMJ0EnwPaLkv`{x&L9!S2BiX>2kNay!Y
z>IXtx**jBI3zgHob4Tj}yIBOfd&xod`GFVk>N_;qe9Y(GxF;+
zS?!vbYM0$6tALx!!L_ThRHbbS2~_b8y*_la=(lQk>F6OWvKS@g(`&vRuOi-M?Yc@V
z>BWjeS5Pr+VMa_496kKDRyhxOg0Zz-xlNP$c4O-G(sb*LQ9Ct9tPK_iWlUGGcNXry
zW!Kz_Nq_~4N@xkfojRQYvZo>095`?}rFBUaDmpRndr}=Opd#5n?SJVQkkdw5oJSWW
zBzLiJBUNkW#^ULQy=TG_vBh`MP*m9qgK_?ieUZ7zxe$FIpT2%`uPW0hy}ADg7@tmW
zu$#W75#KzS4HlC@nD`ZF5`c9^Vg$^=6;-lc>!_ArAxPb@HB?0BT;Ev`LG@NQY;fts
zL&K_`JIhf$MAil`XZ1br(ujeWQmV79_0J%L(}S(+o&O^MA>8NSHdsZ7i7<7{cFA)~
z@&hl4R$@i&pe3kDQYK8v_=rw2wO5`ZWX)58-#;YTO4scc`Ijj9IHbH-9hGWV*H&Vd
zs`{+F$6uT1rJ_gwg`?WXDeg>|Wen`8F+`qv3cOilaz1)iMaZ8RND
z+l=E&AV&X3{uCeAgN1vp>o^9SX=u&&)at?c2GgK{Deb)tHSin^te}(Z2NQf}pi{3<
zfD}U=kHiN})H9y#T8{d;C02@Y^p)qx6egqIa|02v%*Bbj<
zKi*u^lDrgQ9)NWCY^d{TE?ZP{$!c6V++NAKw&`7lbYnq_x
zK!ccHsUbfO3f=~6^MRit2FVXkkEnu={NSjux
zBKrXHH(~(4gMKGTUnxiFyuvtw>`pF&C`@4sIMWutf+5#i_bFI3?$_{)=SinSVo@
z>v1ck#w@rc-n4tAFqfyDQJvdiOkgGw#OTZ-`z*8;5s=jLC!wbGdpXa*#UTFr$$-f3
z6V$bhVV1$lB6c6WgnT4Uxxz95a;@G<{wRYqE-p~wRq$_S?Sy)v%H{r#^Uolj_0T(oWT*FWPEdtL3g
zpp@BS$JecSc&AWJi{epu+?#ZZoT2A`61us61hCFym&Qvu*UT!5>qf;#r$>lahB>&DO;v1c(yY|&O!y$JCMF+zNiVBu=v_ac(S~2b~x#v_u}yxT64x2
z9S!%yMDf9Ui4n0iJpd(^KD=@RWHtNl`>Z%GkNm0vd})eQaz7fJXh(BDf7e3-YC)sm
zRPbq6_-$fWSqi$p{u)#0$ZMZ5!aojf)9SK>jwE^}gmfw51p`4CdnmRoF)rXll|CTz
zPy~4nZE`UT!{26iem+fG7M<*$_U~d00v-W(8bK4)j{w)8J4jdX7s9^PFvQDv^P9|2jW4;znw>jXb!{CkDnJ_9`FRJHt_
zHv&}H9j$ljDgCtZ-+B&z5PNA;pKizr2CU$|j{1i)wc@@bdMiz4(2!4c&|$8omH17=
z={hrnr1;2anD%Y-6(c*j?wI#xQwV_m5wK+}JxS3~*_g3pwX2oFJF_{JzoAT?K9N#X
zA1O&JrvS<=2SJQ>w(kA6JCs^43}alGlaJ)C@x?kRpJUNX$f5^0$578I8v6Ngq%7lq
z0lL(2t7wIj%?PapdFE
zlCNLo8v-H`4NEL_@;5xk*;B*XI8jf_QAZ3
zKub0_VnipH+eKMRDf36$#0TmOZK$DfHa-LwqqecNzSc%<6zWV4wd5bVoQMSLueNKA
z(}pTXdVordZc44a$^&SdiC6v>e*g7`k8^n%H^a{c2xE-Z&g|l6lnF?f6*siN(~R&&
zKx@jFjCLvE@IT?n(~fT&))ZYHl#LDEu=DgLEbh>a(ZZcM8*Nff-m7
z@Te$lr&8F|U&lyfo*R%e2w;I6Y1pJvT7o!DAINRhX_Qpsc}
z!)aHV?Ow~ZwS-0dtCLdY@))TmM?!7jkCeF2XGY}DUjZfHI&&F}MSN>C@eP7$-d#S72c
zh)qqV$zGbL_p4kR%goZ+By2GscvKw7>%ljc`b_=~?rO5;r{`l1o9O4QFs9KxI2IUoJ!w;TBQ&CVD*99NEWzA
z8tb|VNyeF7pKW{mU<$LxQFWrM6_EMGx!@tED*Gpt*#8~I
zDYg|!nY9xur83-!%&YQWVKp8wCl
z9+_=(<@?U~Ek%3Ud&RwGL#>fVfE|xpHS#v47SHHYx=jp)-bzSEJcs=+nSc1$J{;ks
zQSSR%Md(DhFn>d+zNJ1(M?+3i)OC8lDp)4_7&H^)#
zG@$8uIf@7B8q5~X1;|$!Bb2JXKLXBE8ZQ-*O5Bt#DK>*d`~qdfx3EzecyR=RUj6nH
zs=UV6ud_`ZxQBN0pH*fy{2qEa!LD^!&DqBA?UpPiqv(VUh&^#$w|xxt!C;R14W?4?
z(MM+ouNLo4r=TXlNCvlh`4viqUJQUnk86hy5N~_qao4{{D%^cNSd@8ks*YQ-(YK_N
zBBIgMp@fGYWuNognNLmOoVMJi?ShFiXFD;~Ht@x-h^_*kxA}o5cS0+~UFdxh5qLW6
zqvj)_b}U(P85Y=5n4f)flv=-SwJ#MXnl{d-IbuNCTk46^63ECm+n8+uk=X*c=$MlI
ze@YVSO$KSBDaXxdojR56etW(9P?edY*@>h8lnaP*JLN%My3F8?;|^vvhom+e?BxbzYL&`-z0F8UjjL^KP1@;d
zxmNzv@n(J4H#vkXxge2{yO|6dM9R#-pETl*&0?Dv%o5RJ{NLZFw
z7@e;ma7Ev|9NXc3(3nb(*DOgy^pMExrG15P8omC&2Zz{2v6sF
zQ#2PeLE4VN<_x`g7fQXPSFB^FTxH-6cjILf&Bv4u3IXgY?vec;)7KJKWk2C&G;|~2!uh_|n%tvE6Tt?Gbh+Rg
zt`?-*E?Pyc^Cv*|`-uD$yFW(lGkaa0?s&vzG}iCV1-LScQ>G4pDwg}STca?DN_9+u
z@i*oy0Ku0_fvjKz4q_-zYn>VxsomxOT9`1!;Uu?>t>~<
z1$_{|*moSKFp4`ldPi-w_8k+QC5=hdSb6HcuKt+%HlN}v`$
zi$uFVOR2Qz=6wGA*fj4KLp(yiz3*=9+kTUhTm{SU3v&w@73NaBgaA}`)f~qnJ&4}L;
z-reU{ul%z8qwUg9J`Bt@Ui8!rSQ?e!pq%i1@+LrO7;Oa@19pC$FsRLUvhBp}?zFLf
zofDSKY!bJb`#cBBb+7#z_kA)+)IiF&(=^ltIl8PD@kym2aCTUmQ!GSTAwY1Q!@GkJ*8*^Ti|
z!q0!*il>W@tb=!v#>!a+@V&wKheX_@Jxe30W27>b)>$Nl!(JXwLWkA6WHDP`W#Z8e
zVx@g_=ybHf^NO42Dk-}Q`jIWyW$NnR)I;JoOa(O7t`{?B(0Yj&Ypb#sp==s&-UT_=Wx=0$+50%?
z7e}k@S14EX2H%Pi!f4NKWw&m+`|rq`>K-
z99)LHA9lDb%lUc^>DxvMDr=|cDaf7ZV2=|BrX~}pFtGQ~?x9GTA~e&&Y%)BM2zHf?
zZRx9Iv0E+P{h#_%Zd|D?aPiK-@=3M~>hHgaAxj?9V%qzCvG7BxaQ~HMN%ULIQ-P|3
zcY$aI-@dntW(lQyA0!b!V>1r_c(fo-30E9XC?o3ttU^3W(d-AWJHCK66x~|9`_{>;
zYAC>7aEeRyxX%7?nxfT)3EtJbT0ndmuu|6RIgz|8t)Y8leP=!F8Xd*~?d(OxOq35F
z2H3bjx4q-JJ6vAD|LnCazPd9!PD16d?!=~=iF?U(%$3=tWYOCdfZw+E)`NZBrLGEgYyY#=T@qLOZW7V>$&Hj2&(*sG<8(K
z$ygh+h@I;Hz2o|{?^kVe_Bw6{Xh2nCYWlam3)$MU9#X=YE+=H5ot)-T-d^)mS?<@9E
zy%nF%;Qjw^VYBXRV#lbWF!cH;Gw}!r|8Q~g
zM3`0%XEn1;2iSx9y{734UI$%>D$V|SbKHIM9RCsEeEZrp<8FNZvY|od;sh_xP5N5G
zJ>L!3`!M@WL%`R4Css*W=R49?M~?T9lIKuVAU=PO;8%}>+iUrqY223;jwX%`PV8sO
z8q(_;3-#PnCYmxZD6(|YBIUp&+&TAYh-V{KCW;79pHlga+!*+L
zDugF5ANpEX%1VWP%e2qRD}G>3*_hI95DhQ+fPc6x)Guh@g^ZdxrEY!OTxomP$3a`Q
zRm6@1-Z@e#`4e-U(GKg22{R(Ip;M_^{G_N5G$O}#p239s7~*wuL|H^c!;pTMB25yX
z@<9kAaTZzu8_$78`2~YQ_SN=OL=g?>)a79ntEC0LR;HLJPFq>s5lwJLXMD0->u>7UH;DE&
z-=j7pb97%cb85RFxxQSZOV&^`k71GL_}F#!V6vsLBP`(h2lM!K=*Y3u;d1S*QfAd>
zK4l=8dqU-Y5RMR+q$Cu@R9Sk`OC;uC(w$6p%*U)Ug<0sW+z7_P8Utqnvi|t_^D;$x
zSMo)fMh&!jMV7%aKUZ28v>fYa0I%t^PTGLZIvd7d6=)f5hreS%D1@XEOA(YoKZ7mmPQPKzXB2;*|W}n^GY6C{ewCp@1*$RZ3{)q+Zi>MUL)0)
zWHxf0fIGIohhjjzyk2E3tP>LOycdWm?61%2`lq_4Kxvg#zR;I`8I!5Gen%oO2)%UD
zw-*a}33-c!%@1aFI!ihNSej!BP_B&Cbba;H(`b=X8c$8%jz!yq!uiI#QJ#ZHD(X{#
z&q6kTFXi_IVCrFbGkezf!FnQLqfjS*?kjm(^jT2CZkjd+?^uexWDMRK3V#-44JHd<
zBpR-G4c0Nie3$5GnE=ReU(2`7y~G)+XhZ#Ly!X>gl1?>{sc-g&UM#vfHkcnwvB`^NWQHl&xsD>)TwYkdnxt)_FclnAR#ULOp$r`INUX~-CL&PzL$
zLkE)Op+r|&{Or{=bsN&BpwpO1oQ
z0*2B@K$4@DcP-`{u2rkPu129hUd;>Lo=k4WxD=!wH_t@n?*^0u32Y2GcTa%EzZo+{
zREHVhvJ!;At_;r|nhwdk$v>@ggEbbC1~S5XFr!&7Y|%_r7Y;GfB5*?6<(UZs=~91#
zFO6c;!cJZ_uNST|)V3X_a0v>#8y&CjnxX&O8*L2}NVuw&G(KVKfU-^@gloH6qmMZ+
zM|MvL$RK4Gne@WD+~r`V$-FZZd%k;T?1)>0%MFgLQ%QXh
z8d43tzSk8Uzo=<%)Dd;KM%T
z=g6sD?3U@e5_5(pa+-{7T%5P}6;*7y9-slLV`L3SNS$rRZ-1Jp`9&w^_bYClBjCCf
z?wW<$AzL(QVIG}`ylQ#`BsmrPlYb>z&l*#7wn>C|OMY%jby?oSWjt^|3-yNi-StR>
z$&v)Nuk{`%vE?!3?4_b)x!v8@6RhpxCvUFzP65RBep+#Ns+B+GfT>zxzqXymGZOF|
z2IU}?L^e9|ChS*qwrL&^X2qI52ZSC@stPtibsGvy)BO-Xb+$A9o)
z2`0JB_a`!-7{kcQHN8|{(t6z0QP<KmY*N!OX|b
zESsBzEMiKT6w$te`myaJto%c_BvrL#5fc-uA$pQ60iP@~PkGO*=SySMot(?xT3b4C
zR~aL{lIdE_pxB|&QQho@U;+&^YWHea=u
zfA_gwx~8aq>B(~W8Z3%+al}41a(D=4+*$zwl%c8phM{S@B@)jE`ftI$lpg9S&w6ySeVmDFsY
zL{Nd{B@o<0r}$68`(G2@|69ky@Z-J4jqM`ImJ8w
z{vk^r^bfhxHVvw*PFMSDqoRk|Cmio?;3UpyTIU_6DHjLiUhYAR3cPcd^As=70G|
zj>V1I_Sj@Nwv)@j?$V|?R-`q5AGv|m+!w4WJvF8e6HA8U<#;oVhS7{{G05Mr&YJQN
zYQ||3neimfp|yL#0@F7FtdwIR#aohehh-u5wpK{G;miP)hR8tL1e5W%d9GT%uQJff
zU^d*y(lJ4yd{xC5vgj5H*$F)Vq|$rI_UId4);XyC726du-u#I09ZMFLitz!?NXO?5
z$V4Pss~KGXN|TH=tIGES=;tz&m)j*kJ1|H{?nou5(KbF;jy1s@ufBjutS@i8F>1X(
z_}wD_@8>v;CjQ&@^sX#WlJ?9=v4qXys#+oTOu(Q)O0fVsa*>4uPoThROjs&VWSya3
zl;V%`)v$HNpeLHooS&U-?5v}_;3!kQwd=%ea!(OSjo8}{=nIYWu%lhgIo_Y;`)X9ZWI(
zF?={@G~Tz-cS-3H0OSLO!u1CA>@L@J7W3Rha{}5i2DlZXkUB;JdIWr^k900`Snw&z
zT(`VN*|coPHZIBcN(v~B{@t|(M^wbYdv0X`bv;LZ(mv-;i4zB33APBA$vu&ashLUt
zca_2unOS+o;0tC>evK^iDOulrgCNn=lJ2DS6-8HXS!j?46StNNpam3j_Ak^
zq?Hk_!x%Gs#zvjDZLA$y1Z`{6$fY|4K7U4-j?d2(k1mTp2;)jH*|1H*t22X8tFaOAGhqxw
z0rk?S);?U}jJdTjaZP&(S1`*_j*
z;Ko>xo*|1v?@3XsDln8`^#Rm?Txg8a`_IQTyG>2{PEU&h2a_eEqNpasRmWOZ>z<
z!v5%e!a`K~HI({JXESfp#+Pz@i2tLA!jJrwL(fD3;PxY+ap`_+^ZNJG3#YX6gm@nY
zl1}azSI2UBA6q;gckJBEVk7ds{>C2c9l0X*0f=xtEBf#;?ZF={~+0&nX=~>ZR
ziL|a4=RXbJ7yogo5h{aP!~C86(n*gi&Z|(A5Y?=57WMa2rd<0%4o?gTKJQk?w|C#A
zmIuykX~Sv0n;4fk?1&PA2>`mSL#vS|8{YRxLK)llTJ1&OCF0+MiGPR?JGDTrG{a5|
z1ys5$Rz)%=RDW=LD8JOHK=Zpot3L4gNa#fqb)#=xk#=P#lE7(NRj8ozDWM$sjI+4h
zm75Fnn+z4FOse1f&YAD6{j2%`3X`?!k~m-Hl%KcOp0wJTpIXD(VIOW;oB1Y^uvIa5i>$
zC>84iMFxMC#u~Z28|Yy-D!$ql{{#2`iostjf}&c^5nqTb0?0u`I5V
zIb<&N6&czXUmb1-pn3%0{7iw`7pMh(-ZY(bEs^6c4fV^Oed%VGHT6vyPgY}JK4BXr
z0!3is({p0(?bdb%>njnI)b`NCJ#@L3oTl!$z2a=4P7@CxM~N3T2~JhAm#_UOo(-y;f3p+x7Ayh2xFr$byks-rQiOEvTWf1Q7_z3
z2s?^0+PEvpnhI@dIEqpN4ws1dGZir2_2_VU&71ANebWW6cGQ~vzPOy4h*u+oL-O~v
zW(qhO?0TYkqwdEmB2y%)EuM62l?pik9p)sFRGD<1f-^<^8o-
z!huTH63pIVp|e0l8C^eeHVcQpwFbz!V)>emcjLQ@4fEN0WfjcyqbRXjCo@$Gg*IEJ
zUNA~!LS~N@kiLL%wqW5`vQ1O$3jRRZj&SUO4IA%7^rbbrUakwh+_rn(wNNk*>G2YY
z65ZM~_oh8It?YI%OnF8{l4^D0HY<~=%L==vv?5$XNgM9K=>O*UGwI(7^4OIgo``&J
zwDjHp&QKyM+_10cMo^j2&N-h(Tv{I=QclxlHyih7c&7W84+g28_a*C5oS>p7ExzKv
zsP_M9{~pG`;6rJV$y=#fF;fS!)X1
zqaVW^MiWV?0GN0p&t#DfsE0vl#;cnFQnPTH=XGy&9Fj{BuTf|SNrq@uXiz+!#c!L5
z=_g4Ntoc2(IJuWO!VpO<8dhD53-l)x+IBHCDx3}Axsgg{Zl=BC=XOShN?FLK
z>`)`WL`n>a9FfQ(n(EN>t!WcH-;@ipn2es|F$wHN8m``h}OE6vs?4SL`~U^a61
z(P8aV>h>c1tf;Zi|HqFTWSvt^Nvp#y1kVUDNFu?uvJBy&Jnfo-9qd*iBdN}SG!i?KPK3~w%DG^otI4_CkhZlws{`t#_F^|MgWvc1;i4^?xM^+;dw>D3<9c_fnGo9
zpU}R3jrg^ZMdDW{Po68Xlv3{18r?S)A1+ANqD4h2!;5?Zh0I-&XqR6Ko7(B$mkrEB
zZW1Gv`wMyxhmn@Oy*-Q;fadOo&edzvRSHa&pDOM-IT
zb*j_S(5W{9?0rfH>aef<;VCb#M9K{0uPDqcjw7_wi(_|?JP$J-*7Y5fb=4C*-X|`|
zz9Dijj6bC*B{)Hk3S(`rP3UKt&{q+`(oM&wOPj2!4%z3qh=!j$jK%S3gL=LA2lGa#
zWT~hs39E4L%C{oe9Cq&TLPcVo{D3gCQ?b9>S}1#DdSmSk7RtpgJci44jI-N`>oXO%wokdrR*(zZ?Rw+9*xqD;6$R3K4dz4YKB#YdM)ohkjcaMQ!1LX@U{gqIRJ$9#YDW{UJlX=x&6W|k1
zT*L&$g`^*W1?y8)+?r}lZ$SvTJ2k@#CPm52(&`)4S%hoU23bB-LUPo4jyqi-+89X8
zZ($UVZlfe7{&$&?1n`BZs}<_0(pcLh>V2JU7>#7opg6G)UEsA#dkIz{^UnlFugXKM
zKDCE*&m)tO`)
zRP^7(VOj578l{RsK5M-8J=yVPiP%dEs8<>s;rYEHX9JP@YWS^t$9zjMRpAbQVH>_~
z)7A8MO^`3GW?A-5O7X)tur^71x#5P7P>D^_uI-+LODJnsW8$u6drLlw%|HZiXR~Qw
zbWem#XZp&LwH5zWF4h|Ar*k30W`tK-`JX;F{m-cO=6c*=jM!^b(DV5Wc!&Zv{3s
zq{cQ^8Q=1EBpa)w9>EA8bvz;85h!jJVQl(nX=dUwsNdb}M2W=G1uQN^6(41u(I$$c3Z
zlLrn0oP`WUQ}AE%>?WMjuR0`(s@xanHrv=atkTf13P{V@psD2*H_V+(b{u|}N_}Z@
z-D*wOyqwJ!&xBPOA*SG&4!U|N$0wrV>RP1)1wY^83j8vu!rbP~L)B)-88G1d;fF(y
z*r@FrZuGY`E!iH3m0|i4Yp2gNtxOGi1}#rzW`?osW@5$y%Z#7(%J@&jtYy(BbH4t%
zG})L&+3>4XVOY+5p8svIUQ>iGp-D%CrsZjO}CmsaB8
z7_tIsdpo0rs43h}l4QZwrQSkDyu4>M*|R&dNH&!#8yGZr4sQ8
zC0q8!uGt)O_7dgzbrw^iAAt_IPiTbFgMwujmBk2LJ-4p|to!d(to^g!{}OfMe-T^b
zZ@9`Q3vTL^Lbs&Lp)03PM=s-E$MC|B-3-i|SuFIca*K
zxVKO`cS@t#s@&XT7x@xdXDNWSw+ZUuF5z$SbU_DL5SHB2Mow>cg;l@qqMue(kt>o9
z0kmi%W9SUuHV-5tBWW$#6u$^v7>0YPpu!Ykr@x`l+=I`vCN_TVS1df!9ZaD&CAd
z!93@;OC?EqG-p-Z(Fd&g*RD9e-fqbzh;S^`Kuq^6XNeq
zL@?A;f+>P%A+mZB>GL(Lksl+>^VE9}w4>x7`>JuQ>6w!>jj1f^eMfiMh%cvxQ(IB@
zNn7#w$bgt%9V`iy-w&&YB?H8YOtjWKgG!3T<7rUE6PN?@x2gc}B(nrki
zzyt%i!OU*>apNkd&o@8h(H)a?n6$4K8ySI>YTpJ@k?b&Q(KSGKx|e-~c`Tx76bN
z@2LOz|He59KmIG$7q}f_xx)}mN$EVdrXquIv3c4+BymXDjNRQE#uXe7UPGdE!-Ka}
zAxi(4AMBj#*EPkwATyc2=)6X}nAdJuC61VjNMtW$`DhbEFExNYrA5BpqzDL9+JdZX
zZQ^sly|3P>>D1l&C5S-UBty@K-=CW%w`uoN-(G1gxEyHWN{JxSF01=r)YO%!!qkN4
z6BshMt%N<6=eX-4mj|!TPrfcz`h1@LY|Q;T!H2G`eRjYQ2;9s(B^Mb3D@U`j3Txbs
z0p4%GMy(5`x#3GoqT^+*t-7v0d{>l?S_3qLDPf30N&{9#Wg1$jvkU^qE8anc<=8vs
z+W$BH6*sIK9?fiMv=+Q!xmNnN7Gy_DT(Es9^akB%AVWa7QzElPZJf*Z^mOOej5|_^
z#?6{P)J_KoT}dSks4V(T&)iA0vW?EcZbmcfdS!t+TI
ztt}*GWeGK4;xiUsea}V;Nem2-dt=D>ja(vpkC1rjuIzJ
zC%Wz{xfChx4!sZe8Os%812Ed-@1t_SjN-qERL(+okAN2l#GGapy}D@}B&CLej~@wy
zb-h}M07dG%^+s#Nx!z<9a$k2dzs(N!ludmERHv9*3!v2dIfIy#y~@Cr*V+XOY5
z_A%Ql!(=Z`l4F3%^32?@G_Xwyl|Eo?-Xivo-rVPo%iU}?Pdq^>!LCKkEU=P+zS(x&
z()&wHe5-Yzah7pKDFM;bSQS>tyd9!fo}8v3_k4gHf23#sTEJ;&afhZU|NC=Mmanrz
zF4m4b^O|XT`(i7U?zSl?wc5P$OUkJ(^LAk;d?F)7LC=)VNLhyV#qX+HP(#K<***dO
zN5Ec8hU$BrbJr;0n4fv3#&<7%(gcijjSHTv6-04{(_=|dKP{6ZR?n{4b-YZ4k>lQt28uN7L$#>qFS9xR_
z-%g}7)=M4xt9=a5g
z#pfK>&d3mTlu|Y^MtipxSF)5sP*1fBg4~QhiuaVw)gfJ!Q?s&?MqUQJRJx&pBh@$;
zPjxOwqgzRd)WmZq5vcdZer3$27?PblX|khL?1s0+~o8j7TZ|sQ&tq+4@Mc%
z$b;j6%(`_zQo3c@bj-!#n+3m}JZAYg(>bY^80k*dJ`5R98e*PRS`VZ1W%0jLIOzs<
zY!O<7MuqkF;;c=gQOC#&GLhuBQxP>xjo_j1)CelJ~TUyAqy*QF71_QsC~_G8*e#Y6Eo$q)XpHhB@$ZRc4J7@8oDKd{5anOap2
z8tUheTz4H_A)wowOl!|=W4C^{4F^=je<}y7A>7%F5e~xjmEiizq{7JKPi82py^4)M
zkmgO1N1N?&-mlRukAR8i9q5^7rF2VbN(wihc6c%S{0m*R_K3;|rL>?IHIz_}9aZQ5
z7Pwq|RlANoM}CZ^n4Qz?=$`FEWw}?fcIg#U&P9Q^VYB$3y9v(AUwHQYX~=nwzot_4
zgM^vJ6fI^;kWXj%ibY=$fTF_oNepd^h~P4I`r9v=lRScPZN6I`cajbWjjzEec`a&<
zXA}b$9^VM#Vg!Ip&vT-A@p5YCX}>LR;-RSlU_SXv?!aL1b&YHP2ceb=?;_?yx4-d5Jv@lK37qN5o
zWW-H|-ou2b+v<_QZv$%iwP5uteobCV^8~Vp7N+(tBFyzYoJTu`8=D_}Q0!jiIo*#Hf;-ZaC&OqQNV-2V
zof^3JyJon*Jx|b8jvFu$ji}a`T;F?I_BFORMMEup^u0JML83STB|ecT#`mKW>m9FM+`A&8~={*5vinMhjB`FG)wpP|FFtI1$B{pBI
zS}$)2sCf=Y_8WpCSg2tLhM6<|cg3L>M0kZ)zD&S+D8<~O_3bBAVDU&&Z^jjxJ$7yA
z_^_DdbO0*a80)>y%EGqkF{>|htMdqI6e)v`yOs`OuAK5#3kt8U*6&1DcT0+OR
zlp%Xs5~tjs0PopCoCoq5n!ZNt-dPpO&8d_QQ`e((WXtdn5nXVr;s_2&m0dT6;L1E@
z*3SeQ1t7%NmHcMPB4KTADnlx?TS!e%ueAM^Di1;mzQO78KO*j%7yJjib#<6
zt5NC`OH{Cbi{LRRMlMN~NGjV#+KhDQjPup`wD;IWDx)|0e6#n}C5qOrog6hFabK*_
z72llOQw5Kr`LYp86e)-TP_1x5VZq{~^Z~H1X|lF;M~N&R^hK6pwm4`)`ho*R#(Y3)K@uCGxB3&MYkHT@344O*gT7&$%Lo|EB{X2kP_L@!#d$kLN=|{
zDjU#*n~#ZL*djcF!eNzgrA`y5`B*cgh0ogHriRdZkw(X`&MQjR@9LI7{I9R|K
z)lg_HqcChpeTBj1Ia7;WTk2Q3bud+0<#cW!-x4Y?`#+an(I{6KqN8udVO
z_`c-Z;`0o3Xu~VleFNp_kulD)+Z;|Yv}Q7bg*C%e#7&&+#fLiS(j6HxM}t2?B#qwG
zT$??X9Z{~NYegi+F^pBtWJ6l+xiH}Lq4(vz^-Z4bF2A*OUV%f0RjfRxm?d1S^QJ$#c8!>R3qbo5
za6rECKbc4**VJF&5i;l9)#>&lnOzYb`%XHd@1IwO-E;IxjEXJT0d@Rd%<>TsnRW5L
z(SvF#i&%*OC1S<3m3&Br&4mEM=PSM|zdN;gqwkYbZJqhIkgi^UpO0?$K!+^YJ$;mu
zZ_gggWqU8V)JeDVCb~T%2I{48nLZwhe`ff#
zDR`LcY-z=&tBM0FI74ppj4v_W#Dy99*Pjm|(xmYggu`bFzy18deC@ylw-h&s8c#IP
zQg^<4Z*yMX4QzJ_fIb3X@->fu)$WHhCDVM2eDd?>5!DEzfwQ93c8Jl_&IUzzvPZJq
zpYm6ussZlqH^W0BAL*f;JQaTrGkU}Q
zhCpz48GLYe4H{%11W0gq2@I~mA-H>R5AHI!yE_CJToPai799}ECSP<5&&~RHWQl}Q4N|c
zal0qAx>tD5d3awCv=9=OmoQK5-+oULzcwO=q%FHb55|{{LYh
z8mK2DZ@kA$s2XyN7RnY17aVEGlEA&Hv2pDmN)h%t)sO*L__%p4qT(9&!gOUC`7DH6
z@(ngt*6%60`&Msgj!xr(y1-RikyYBeC8d39$X$`!|8oXRiduMArC#E*YVaZSj6iosoVSZ2SlKQE7+V-o3g%?@!9zOSfgO(UMcxNjiQl
z%e^n7p!m9gZQ1PBa$YyZ!GJ7A3b=0UFEekEC{HFrfQWr?<5Oi({4OzE*3D+(P_)^Z
z>F|;2>hB^N+q)hdfb&Yd5^8d6JkXE)*rZvq5#q&Uw$dqFSV^aZ;T+v=LR
zx+#4^D5kF|wA%ov^5(Q*VaW;I8ff~p53^$`mn#X44#3P4Eg$nGI(1l1r42>2TR#el
zo2n&%2BTln7#f9uVf96ILi*xqM%9QAKTQD&5f(T!+{$${O1lx^Gcr$*_E8p@xC_~BF6G&wk{ehzaV5g&>F4ckd-
zbB&g1NC?!&sp69;72B87pOrGM@|s7L>N-#-LPA%ruMY?dT-)ja9WkT>(bN9NqHvXwT)5d(qePa=O0
zLQ%(J>Iq&%9$t;(Z7YyG*LYX%?a}!cUeLN+%b+!ag!x{0{sAWBA1wa?%s>7Elt6wU
zuW~ZCw=dOx>u!BwT25x1aX!Y7ET%iOWX
zDKy1p1g7aJKXu1?$o5Xx=xUqKiAE1|pi{6w%PiPG1YBk&JS$cE%Pw~d?bG5am3*zq
zxQ4ymwtPKVIhB|pqw1nd<&`cbHX0ro>*-ezbDvMzxmeP?w7-ONNZ1vb;jdEJcVt0=
zYlx}%D~%`)PT_U;3lC1=%{bL#b2*<(#&wPNMis
zCy^R{C9#HfrT$`7|3rBnbocod6TmLEi-67;`bCu>pfjb(<3$MQ48H7csOSh-0q`*g
zyyWE3ST3lMOK$eagZ-0BqV{CW9D}|qKty`taaI&XkYYW!zQ-I5N5co
z($@Ba2mGGSInxEHyH_BRA1N&6x61WDK>Rjw`)W52Xo`-nMO-G)C}Au@8RM80}RTv
z5q|xRk@Azk++$-7v(6jLJg77&r9^fdS|1$;P
z?u)|Ks371u@qUmt?YZ#E814KCf=!G4B^gX5dEYd_svnkSM&dGLx9(MyR|-WD4s^sS
zK(qF}{Y9$E;hg-e>NCA9FQ4+(s(@?4>L0)}K}miF;N&lTb(EOcE&aF1sc{oFdcoB#j|(FE4uAL~PE50k
ze}G#m1HgqO?SX0#v5yJF;uP<^l8Y&W>d7yX@jQ`Y3e86QN0;8!sEaRZM?lbi?QvF}
zkCUG%2kq$Je$#_gfxMHr*q;h7rej2cH(gL~R()2{A-%wdJh~Xsq@S4y+Qh7!
z3tfB%9Twm>6U^;>uYXQl3GC@4TG!hLSuZ+1N5QAPIT*Lmk>aB#ZJ3ypAN9l@BV?p`
z(P|K~OQeBkljYW0vVM{5y7-DQo4b0fj%!urvDrB8)vZ?vU11!kXh2=?9=MgN0K;B{
zvUQqFKQ8OMc6c(kK91W6;~Pcwf0^H{xmerEgdUDR4Xl!a3>wsh_Pf?W2lVYX0ZFuT!^G}
z%(Gh2`a{ckNL`omYM;oVLukK(lpCi)ra(H9r
zd&0P^t6L_)Ow`7TWpY2!tM_9BUu;mWa&Jcfu7S)wSNB7Cz=xQ#VHcn5Q3}h1(^-Th
z$2hqesmGW~qPbNGTgpGlRY!{dp=(46d#PncRl$M0CUCWJ*825fR+J3^oFhCD-E9C$
zs3^Rf+EM-Eqra?r)9Hfo`UAv-O=ta>Dt1kW<^HQ>+p97uMeS6ViFGa8)W0QthxrKc
z+#N|G;sREcq3Hl*LG}%!8B<(>m9L5vY{o!rppg9UqQ8a4eq8Q+k|vQpIn&)8gU2y5MooR9!}&Gp>(T7>$M%bPE~ChL-Z%aff$+(Wce$=NmdPn%W3GEMDiFh=
zJ8y4QN)uu}*cdszWxL!5D+-qIWMlUfTz(ATio`EM-=)*+_hGb~o?W!uViwk$)Xgg4
z#lET}Fj@dKL%(s}rbSRq;E7f#bmH61vGJn16s*@{cH}VC={;=
z_#g4{p@p+h&baQe=G*Nm`IXsQVQVAtB3L;r$n}5%8HX83+Shs8jr)c<9xDc;I8qlj
z%}ZN`j~QKh_!l>XF{Q&^FHfQaI0*qfcd7XfhKU$PhEpJc;d^ypa-)tSDj_;!lI)4l
zF{S+yyiQWq+kv`lHplTsk%Y!Be3+l;$TztmxjT5FI6-?AT+FN?*AyI}w5RI-pbCyT
zlh22lP9a{{JSdiRv`qM<^10!um!IDA&wwjQwj>-@F=&&TO6_iiZJ(g7HL`-n0p
z!Op#G_wA8H?{BH(LJ6z=x9AlFKTzO@U_KT6HA7>DgoCQ8PqukkLp~!#A{UB@4ik#v
zt7
zE(uS!sbj@a@(t5*bJ?ju*)p`Tuv3luZMQPz+>C@ad8gE)?H2+%y=|M(731`&mxYD_
zO8a|F@@-#yS$B#QqY7*T4B^Uk^nU=bhG`1*U06%nxg<|H*LJeIb%489XV2dO)u-x?
z@2mFH=(Bycsh>jUCyyAfziTtbjd!I^0ny;}B-4NHl8-iF-T3;F^>p#mD%c@lSbLqD
z29Q0ugMX$&F8Fg$2v_|FY9|a(%F3@goc(B<^j};IDdZVA`+J7&aHTC(ezdrXS#lO%
z6X#u4Cj`fK?L%7#T)
zKU$S=iHy&*ijg~scsQpUChQ_hz3@Qo*F-i9WqCGQ?zJ*t?x1HP46x4IN5vC5Clgcp
zr4;)I18Z?%L?c(ZmRbJJuC~MmYL)zUT&saQD)#G|cLqN?df{k^{BY9@TDYfg?Q@@$
z1Y5Q=zdPmyy7F{LTH)N4lac56nSLN?2mZrnvE8KxsH@QL79H=8Mm0Y)as4M2c~it3
z;f_ugvtXuiK)F+9+3gPo5gX5TFhfVjl63|zPFSJWjWrMXc5S|
z44iXz8ssK9>_*){;l``Kp*q*R;Zq{t8ul)jB5;jogE4LNiV#_PJjT1a`6w1YB$QC~
z>j0p!oWa;IplV}wba9`f>uVUDe#Bf$fyit0A#k%R#iP;lKunU=LLKF$s+(u5nk?qq
zLjB?zW&c*U9p3~~AM=dSAsRFtU(Us#hK7is0=OID(hv{q=4(?hyzAmfJ7jYq-OKz_
zZ&E0DLplGk)*_k^ueD|Jlmb7U^%TpDo%+J~Ub0GMHwLi&-vOtAj5pFt(s)%6d9=_<
zQMV6GUYRnOYHTWJd?Sx%gW~Z=l@v5{Glk~VcU;>y^|^{4#@kmYiJ`3FG6=cCo@
ztc7?y^DnP`oQF1!GV0B%&Z0f-*(bBw<50)69ThPUG?Y2IWRs%*=WQ`aQ)hj6&(ig0
z*@^WUer-l?E-}7)vbrf$cu_--nifxZI0Ro!SWyc`e?;-WW}K1toaL
z=%RE9vVb@OUDiU(&pPRrML)g%*knPdTk|>{V?aMj9#h_fSyI3(pp`e$gPo-5b*5)J
z;#T;d3?ihK#E~;ET*SH6S;)5(->VBXDgbQHJ~QX-zo7|W(SbfG#69_sLz|p71|r#I
zfx$jPrb9I3i_eTjL#6ikzB*Ue(_7tjpS$wUU8Ly{b6$j53vqdLAGG&yoQK=8;j5YC
z?cv{#V(6+#mWa9pxTW04Q}ab^aoSyVh3X^oNv~cXr@9A~_Ro~sVdYgE@>DzgMQ|
zPP}HS3JGyB4i7?t-}_CnA{X>t#^)CusPT*tnV1Gtt3~fa+XxfsoUv<0R8CxRCQxiY
z{m}_Z=mpyS1K>*y{H=8lz+3m9<2g@=&3kgso7~x==G*SVWPQ~W9^ac)Zm6d}cC@iE
zoHUpdy%7V@Or7XQuGK@ZEXS55wTtWxjv`_MDhE)@Ds0c^#l!-KSc(rX1>%Rztgeit
za#tZw;M2hk;~SpHWn-QUGOK*6x7guSy>gMD#uJKuO!0X4{x-_B7(qn$xfcFAqTZZH
zo@As%(JhYLtS0Jg^!{Ax>MSH42XCmsF7=em^i%%blSaCAM^}gPm&8^Qk|3`r;R+K}
z@n)J^B*edNFm0)CPYhxGPkkZea({X;-N>1R`@A0sJLJ5~{p)uL~mG;-8<
zGaMpImA+h$86Z20&PccPkh$yI9aQ+;p3hXtDtWthxLMG8ToV`@ZWNa{-J^WK(uQZR
z0B{S!f5yX0d=~6{2VK;c^u}-{o+5W4M>XO5b#CHzNFUdf1>M=;utiycw|j&9ZCq_EbWW&g(8R*W}y6&r#D
zxq#lBL!xrND72Z_WT4=#W@E*^`t=8ARfng$q|kjTiOpUNQ^wA
z(ul+4AXb_~r*+e%>#cXgYyAU2YKs^e$CQ65!K`a8fqFlJKp;p9*fKG)cQc-Qv&b%(
zX-$zV%Yvy*91KQDc+y=AW_-}xb4bMW;W=Mx3o`HR?8-jfU$7)8DEtWZ1u9&O6i;Y#RUS<;Ho#HS%2{`Dt9(9c1>r{4I=m0#8;$YFBNLoDW
zhoEvJL`v$c9)!Kv(yRV`k#z_^%==2}oLTB+i?%EOlQiG@w$VjEBPxQw$%_dyLL}$oS<{Qcw6EE0wBl&OB;59B#oDM
zl{GthYF?-Vnp`o9&I6_p)tz4K-Bb2pc;f522s?sf$=?9o7$to{;)^Tu5Qen-O
zh6{&9)1QTfOKoep97+<+SHY(wLGsU?^Jf+=g>hr!9J_y$T!jM1Sw2MW^}1r
zns}ngJG5V&=Sd}<@)0;vdJyZF)Um#m6)`@FrsG4}R?h#mQZtUauKmmZh(3eFGmu+lIyq0a4TQgp`6eYokomOFw2DYXhWrgSZ5kn}@~sLh^4P6I
zP;4*NWvBgySLB{Li`qyFKB_CzWGGS^{+^VDD)oq7-f*2VTEo0x0pY+d?1RJ9SGp^m
z>SLG~)jQ#(77NidbM?uXWaPX@`VPmNbxj!>-Al`%7r}WV19j5BHJ~}7jhg#v(Jcd=
zv^WU}(^o>D39;iIgb3w-1O*KlaqifQGIzFLruTGzP?N0pK)m!Bwif09fEXyB&nWT>
z#>?sz_8$~ChgVd7n|e&PDGm{>*Zy4rcf%G|ob(#P>9SqZ8j?+UJ7b&b(1*zWj6kJ=
zyV#E6Yu5q+_kQ8Ow`aKli1)(xa{}^S-Z^D{Pu-6umK+B^dp|)gVgkeQa|@5r5xlq(
zVNy_X(jndYVkF=X?xLN?T%<7Vr^Q3IHz~2P4*L{wO^RUs7vtGq^y{0JwzjDk!0aom
zvmSvbs=YyyhQJvU&t;=Di*F
zufXr$3u
zwdvZ7WdT)DdL0H2yGw_?Nwxd=Rgiwr;PnPbC@df8vn)IJuDS5Mt(v}sDPf}T4NC=0
zGQ`WegW|gY7hdcL0p(rT%UJuBNF?X4z6C3vud0DLCxC6
z?Uzg~WZQ%ZVi)Cho;B93v!5~xFcfs7cd2`b{MW!SX_dV96zosLwg^<0k6`o
zv!p*i;76KK^&FAHNpum|oPSaTHPw8E3JBZpsYTLCh?1oWUbs6$3M@
zR}U$AGJV#oVyJ?GE*nt4&-$%>-WjrgJ*ei)={7!3G5yKXIm>)w+ZER0YfBaK<}>;8
z8$?;DWL+I(Y7_VNs;9fyJC%Jv<5$)P`N7;@?;6eALo(|34IL!ZF?>Rge+SOjmTv_V
zD@Bp{$c+yQG!$8DoCCJX3wlxvN*8T~v-5br6A4im-FY9i
zGs9g3f(wb^efw%<6%EjQSR5F;ajN{{I*%(BL)dUE+7OG_iUZ~G6+QN=mTa$7i?vR{
z`$W5qj2vfAXM1Ad0&4}e9|;SCM_F=T-eT8=0uLgIji%S!irSZc^N3SLUF<_sUUT9l
z4TC-^4&Yzt>Tn+dn`l$CSY(~{I#xY?a$Q(vV4Q!;5C9g|#C8ICkuHaD?%RY#0uE<^
zuZ9IS-*u3cy
z`o*mE1R#Eh7noD)GdzWVRng?xHj2Lgzv21($V=68CbFT1vW%CedZV)CvR|qs>
z6w5l4!y`TQQ`giXy+nzY+E;Z}oXPl6$rgohbwfldV!v6n1=l_~=&rxtC9qKP}
zK-+`})DV*)9G|%V0a)%Am7rcC0izVND`!8AUlad_4}&0v_{;AqKlcd)Y)v)W{Qd4D
zy4iShyZ-oc<{{-;Z2Z>KnOJS_r~~Xycbc;sxGs>$equ
z{{i4+?6MUP!up0*_FO{(zF5Kl-V}c*z3Jao(KmP`gFd__bv^rBHfjv;y`|b?V>ym!
zrWAcF<=GwkzlY|N#mf46oNm5`=y(jR0KbC-;2&D9fd2jv!p){Ux5|
zVDo+=MBfd`v{If-s3LJ8ia>K3K1%_na%Lx)aG;lF?+dAp(OM-aXH)Q6!Wyb`Garo`
z!|AXIu2X!aarHMgOnkzbGkkT`yS9^+>_xLzl);#sMO!n+==){D)la%$``Im7_7!ku=w+T*q}~N0AwFE6Vr3YWa{2
z!#`VvGdFeFzxyEgr15r=f+Qx`in?}1&WZ2>yd_&LQ}bm1QV;rPq_$H
zyV65_8C%~~LP8l-CCl$!gXJ#1cM5M-I+J8%R@?tEZhG`>X-_Afqvu3PcD5mBKi41N
zhWBy0=3U63WP0l^7;eeh-DMhlvAh1d7V*}cCB?t#z?HMe*JsFl_Gpy-sMCn^-o_h7
zrGky@BJt58l$Y4@7~4hFbJj(!#&uKvE`-Wpd>>5&f9u`gJLRJx6i!t(q^KVNUCeGP
z>>l*ZJd3CEY7`BovQigAu1aVMd1!~Wl@$*3*bxXv;eV~8#EmqM=;!_aIGO+NtO3$q
zPzz`ZQuDyzO&ZSFp0{qIIERE(DBZ1%()2kPgHf~|ba{~&6jW^@5}EJI#VLf&f-*%H
zW|y2HY)>|oG?9Tsg0H-}W2ZxoqH*$_EF~)|!5Q$QgiM
z>%->Q)rrQ-4l(QrAMH{=sVo=U_#nuGxAX5_{T=e}yEJrqIYyW8TG(tlky@GF&(82t
zZ#<-TPV#rrfWZlh1YyX*Ap>8N%$EZ1#nPqLxv;Ux)VYNn6_#0V2QP2qmrsJ`3zl*G
z2cIU1>{&4jQ65+I)2E`s9M4
z!3azN*@ne~+bpx*c~Q#!d%VN%U{~V!L%9ZsjlE>U4DhXB2(`wyBPGwIOWGER)O`sb
zi>8)b#1RBaRbfb)b1L@IcH8mP$h1Q4aBsJItK_{GZ>zah+UeJ&dNu`a)q;LG*Ues0
zD^kpy1ru0og;?*PbHv6mN`S9jXlZNVoBK3*l1&4g(Pf;m_VnycDCz5w78Bvt+o+@5
z`d-0UY=wBX^G*yci>(q^4nLTB5q4n55u(|~mirGa)@f!Fd8b5X$w}Vl!sq9V_QGZJdW3EXT>Zv-r3(ncUX>s<-Hz`1|$JSwQ8zA%J
z-jU{L0aB0{eyS%RtDVaErWyrLKR}E-hvqDzZ>FM@IS(Eq3K_ryggUoq;
zmE=%a!6u{9`S5JjuMNTzQM$*EGFr&)#Cq4>goVgCZOien(Rz2x3D09t?YsS{?m4*Ae2@Dy>rLu*!bwK|okWw-v>goC
z`tQ)S`EN@ICHX!O$WeQHhNxLI)&wYyL{nx0ln+48UH*?dCn({C-hLbTW&Fv_w_tgO&;#<=TE0N{D(A0f38`q$Z@vFZ)k)`68F^%H5Xcb
zTzJ3;11xOP-kODKD;oEQTNplgpJNAw>p>M5f`wV@co0+K2wQfDA2LpcuI$BU3n~l^
zbD}MmA*&44Y2>5(vJ%U23F#aEyGMRpZWR@*_RQaxfGWBK8*He$d>@dk`813xen9<3
z!S-h__rh;u>lQy>?j;RFcMr%#jV;uf!^QYuT48+4_3;no|H1CG|FzvG=}oWbDT7A(
zE7h=Dy_rof&nnVIiBNk4G$n_{?H&>($((ZX)c$@WzVR}vj&M|fvdK0S56v)hX%uCt
z?Ou)i4`l`azc*WihW6@?!buX5me935X)PL0N26-^)|ddu_)*`h)ADl6(Is$93!aIs
zo{f-www{gH$crz^Dr=6>Rn8Fen&4_6AkoL-bZX*YFP>BhKbib3ozNAu$Nmu1yDHK0
zzWpM_#*DK_Vb=v=(rtge+US0z{m$j576JwZ8)QE?%3EZ-oCLkJhO4Eytez@-2|uIO_D8(D2z@Ni69O_nPr+j
z<#a2J^XdBg-zIWIKg-oyFx8MsE7k+vsG9kOyVo3t+2O9kf9ntYr;Ebsxdwlk*N(
zR&xORH3veaEJ`WKnUF7rIcBu{K%#yjHK~h(A-DrX(%p18>Qc>@l7B+bN}K{<1khJh
zsbB=vEh3?0$9y&Ky(x9ze{sY!i+AI;xK_0eEuyn^rBa{P23VMy1ExAVz~@4bOpVtq
z6L3nU%=0O%#%m&X)w|#-y%P4|$?;xQH1m71QU>Q1M$Y7@SG*pqs-)u^^0ok^E=Dvl
zV4SC;pA0c)o*ImF`BInCHJ~1DQ#Oa;+
z6T1&b!5;u7U5O|M0D|pmC1*deuC92T4uCA-_yRt8X1o`ltr#sEHtx#cwVVANR(W*s
z7sZUDwebicYduZNx9CU~fJg9PIPi8>zj8;_*S*m!#<#{c^SpVCSdpT{benPFSg=Cw
z#2h=g4T+=6$68P*1LHRqZOa^@EXFytpcJ7tNy%31|pesnD72Yes{%n66NTzzPrMsQ8qk&jvT60kSZa|
zBn!e$P5GP~tmIkuAoZjf_P%??Ln8JE_u52T_C8sPM7V_Rr_^LRP)LGoU1)c~zWSGE
z5rkeGJ#qb6y3FwLg{c|gK$sM~VT3#-H;8=ihS
zX0$ruiWn_g{frP_px@sq-BBk#e-6lm_gO{4rQMyg`fUHqJ~ggoUninIe7M=)39~>f
zoSUCB{?F{-Ht32MSu*4xUjHLfCReZAw)xbdry$bpn~zJL5D|T-Lp+a0OK*r#3D|*u
zvZw(U{xrf$W=mn=gCM9azBq?q-yrIucv73MYaH+oz*7MIejpaHv|?zh@J5I
z<3l>)O9e$r(z{ptI6UFKzrqRMh!ykr2VfS+75RoYhRZy5OmO6>44
zrepcuzb58&?Xp+U_`*T|`YW%P3gCW{W9A}oY|k_{pJK5^nqhHGr=?SMh96rA)6w{+
zXf!WMoYuE%Ty0))-O~ROU}*34HMe(nG)G)uAW*k|i;0uSVqGRLG$#+;1XfFyTk;TV
zVi@pI{Xc6WKIVyuSQAAVGesBWxsH0;2id(YlC@rjpD90Gr;6*owKXcZlI0Mgm*!yH
z9P(*s+@$cAw-@3$R^6}NeYVcc%)ECoFc^!!QKyS110iKdVyB=Tn3&c-d>r~?Iw}pA
zFfU%;c>D)|@R#zkGm;nYP&pzy9oc|{1(xzr-!SUT-}ZcFWmu`9>xsbd|FNGZ^6hU;
z2fL3Gb!XVwGO`RQm-G3r?tg$r*nsPy%Dmd)IPFw&;I*(g@A$Z6OrqLK3(N*qC)gvm
z_~*4-JX6gSdqlq|6PRKEjb^{Qes4CB>+eadV5+SpZA5Lis+Vkhoa&RkgGr5cGvcF99awulKt?Rz7%uT+A(s@axk3Ab*<=ux$5Y(NbAu!i0+cfS4$@Gp}N6Q14HWk`@6mxIc(r~Pk)
zTLs^6bIXV!S~BHwyy_4yFY>FbQNMYD2CxxgVlt6zZg`sluT{Hxo0ip+H#+5sMN=_r3O?^2l2oL@kx$_Ld)
zCi~Zm$`|9i?YN>S=8*uNN&xcme<`sexDCUw|EBp<7?@N#kN^&xLm;1X@_mo7S+jm_!c1LUJ|0D%lm
zqk`p`6tH#mz6IJ@TQkZ?IAK|-?%Z5Q&dw{>pI=j6h(>48-M1%_TCaclSvu->yqh|a
zJ=9If9rBC37{FZ&$IP?-UQzV}^xdLI0r6S3_U$(0@Ac+s
zGd@I~J+H#(+~T5$_f;@x7mj5TVrflUU`t)YBQcsaknf!}3zqCk8LM|6d!L46@gIXJ
zVu@lt&m4zb&KWK{(oS{iWq^_hF?|ynGd`fx2fN9h%hU~a^X%7$v~o!gO0lig`&-s*
z4=@d?LcTrLv|Vv*@r+OEhlOJcA2?y&o&zLe)m}LhKNBo^C<{>t-ghsY&uv~TLjG~oUKcrCoc#-mPTBId(;L|^4T@%n#
z@M~k@Ex~_7qO2GVm-~M?>WW$26?}ME$0V5mM4wFqYsfeeMJFygTWFp9Q~t3
z`+boG#4SCT2L`MD#&>yq-Lcx{H21bi6#+-r$jtAuUhDQQZ+ynWlBWGZOX_jh-nHBc
zbSSmHyq8Hf&R2Yfyv87Vt1KfwBHW|z;9s?xG}!L$^a
zY74C8nk;oRo~{a1u(4V%BntK34d(c1!f-|9ubVpfrvf1Xp_DKItzJ2jy<04f+|ZwV
z{kY_Gp=={bf{Ell+PPg~S>vM)Ubql8{bW$}xZwElSbwyv{3rqy>Ei&lpRT!y^nOM6
zN9U`}!Fwr4{83z=!^>oI%16BPSZgB0i@YXNF;F8k^&T)IItVtM_NT}
z-WA8N6z^q3qI>;Mo}#&LhVUD@F6h^HIa&DT8EW+z7QR>^i9H51v}>(>WIm?KD;`-M
z)jk)sq~HtY2z^&kCaY-kNppGRNPe2H8?rbu)ZqvOFxhDQ0ak#fQ_I6GO#WSupF}LZ
zM{^Si@-YZ$0J7^Pfs0Ys6pYC(x*I{3wI3tsTi{<)h2*tC#&zmd6_?+SyTbGzZbeqc
zwhlBwj0OFjJ!6`VqYD;5nc`PiqP&qw*umzE`aXC0?&e=gEY)f(S+|>k&$fzl8FImv
z@{5{t&;uFj5t5S^@vD<4NgNwbif*>ocUi|@Ult&h!j=?+6vRW<7k8XU2a<7jlrY3L
zmdv@A{tVj4oPbU>KgkUoTL()^xbR{~{vMT>M>UP&zOcPQjotfEYvrdV%gxf$9`kcI
z1-%F(KAggk8O(r&_DvBT6c-Sr0>K(ZoHHJ0cISe6gn!P4i45RzDw4&PzM3ve_?zQ<
zQK09&wd1HNx(-`@UGH$BN&_ar-0G
zey1jw2~+Ajl)WW1wZKV0mT-ew)gd3i4T-8rW(44w9hi(-zH45$W4CjeW@>t|!o}%4
z{UM+LF*Xdc@9U$y3_-cyP&ScUEgz~B^1jSQbo}fV?eXrS4K`MeaX-K|r{i4!>jb7#
z=rVMQqSUC;LvYIxoKhd>_XUH=-xB|3Iic2MXY+0zzAB$&rv>dPpMwuWQW+l3hTC2Q
z7rVu(e59~=;A}R^+@m#3C8hH2o#BbzumPmd6FQ)KFrG|t`(LHuZMMkwy~{1X&;qI!
z@v_ms_42~Dz9DfpMCCYt{9=Q5eO(k~@Kdl>XUu33{`(F8u|$rV8t_nQotmb$!d%cQ23BUX3(wg)F^q8fjfH*g4u*?vOp3Sz%z<
zv+idsXHFG0pAy0K1TYh?Koga`#yts9?F;so=4_smT(ZAZ12s=Cc6AlnFnvw&W$B|v
z1-vSG2tyH(#`RZD9RXGdPDNs#lB4QPv7NM=p}&bj?LdT~4`I5QgEXWqmoMQoWlo(|
zd|NwnpSgA;6v6{#!@ZME*i|Yqci6r4mo5nL8WvZQWWiQlM8(g*5M+YFbw8+G-P)F_?rK1A*HP)^&QI6bw;efzsUtA!lLH-RjO
z^pjYZr{Xl9FIKNtO=L(%gc4GtAAPdiUCCoqAsUBbmZR)Rf0d#Y3J@x=JTU$^B1Nj3*W~MvB&2a{9Dp~mF4?z-jbl9W4Ik;@jEszp
zk(rdO0j@7|oJ7feK99-oZ0{ZWsfU10vjX!ziKdO0(8ymxwct|Hj0%e9)~#tUW))hc
z1)3=ETIM)dh_%jf`=RI7YMD%V$)fwMZ2$5-+SpqUqYIBZB54%~;w0m`2?{-)(Mx
zfQKVhyXQ*1SsgVk;&`_Q8f<3dRXpVI)acI_*bWv~;h4TPja3tgbM_b6H^zN~da=4Q
zGh|vjqa?uO(w&PzJKD8*sP{5nucIH
z8IQH^*&NaRkT~T)WCkjcr%J|h3hx16etf$qNEc~A&dF`nFMI{n-009<}cRAm(9z&
ztZH8=_03v_j%%pX_a0=(EUIg+?W+#ym|prdL20y4TqX8Rgpl#n4}KLz3EvIbRgQ+T
zK0^SW)&n32--D{@3j7&)ZyoMWTLp7mg5xW5a}iJr)7cu$bX^<1tkdkevi$)0c0!>
z=+;e$su;hY{GNiE995Af4(X8Qi;myJ(p$r+YQ6kF7pmZQL^)i-$487w02Q(XVdEi2
zMlotUL8N)zf}MvYy`>l4ANjaejTxgai>g`7_?_eJH>5*Q~
zb44EcLTNjQ-K1?b`MRV_HIJkOn!5wYeojF{M
zPo@<6xA-1E$E3<$y^r5FZNp){nN68AWj-d0-cKRBwQ?c%U@X^A(r`hLpIz^Fh`Xu2
zIi%P@|IB^oIY^VcnsX2|*E>LIR4aNf3(IR4n-#0hUQ2vt+k=0g7SLYf;`+tplCQ}W
zX9XY2Y9Sz}51HDv%nzPN11l(@T*3@TT%9@HW!cdZl-Kz?lapkD_)21x|FcN5^0c!y
zA+l`i)91WtuT`&~z51rbiN|1suNq8;ooWP&9N|GdH5fq@o@(H|o!<{f-qg(h+Ad0c
zJ&*SlU$-EKkz%|McQA&dU_sJ-&zay6tMqJDgh;(>3VI{=ZqvO0CJc1JIP^Fv19%{?
zX5StVBHiqgd;<2p*PW0JX=phDF}SZWwU(NBaBvD<&F
zidr^4I(u9Qa>qs7PYUynbi_!a|ApH@q>j^sman%jcN&rx8ah3o<-XMjz +A=Fe*
z!oJ)p@?lCqz}EH2d9iD>BdLd8p`XhVBQX){n3U}sqbar{${>bIjtP5Y>la)1Nz(l4KCb&CItAQxjOdIyVLW+xXNqZoZHd@#F-D`TtXy
zEHa`yWM?c4G1a>CDsWn;Y3|vmbVF3TIQ;;svd=rTpmBld{;r~(Si7a5gG}R;f{`&vbTK+1XCh0k~r7|1DuOfo|N+9j5X7Pbc{3>
z;;0s0)%@u?bh${$huO^4oHXQDjS0t#zckjVe}QhY!3Qa_*eP|0uApdA{d6odw^0N+
zyNp!qDqXXBgg6YAIw0us^L>(XTQS+DGsPzHBmm7ZRBa(mn<@*_(nU(PQY7rR>MtC7
z{+|M;WUlsiil>IQ5i{KM5u`kfQ+&8&Q$P-MRaR`H`ZV3;XGo>-&NJ)ECOozR
zi`yDE6#wL|+C!XD-qDAY#Zm!lY6)3`OB5%Z3L8=0j+b5*(A;iT{~|Vkab^yw=@o^Z
ziaMS+X{fQKgSBJSlDi6iXVFT_kO$+LB0`iFKVtzc0{i*by{L{W!rDN2zZZgMtOTc;Gt@lmrFR3YqrK1Z^1BD>`Zp}TTP
zMpM*V8Q!V=)jhVl8T#)M^hhW>PFFVUsWL&0#Wl`duQbnE_bqx~l
z;@pNetO~cd2F&>@nAyUrhbRflvSQAUG6?~}D~Y6F!OwJh^;Ru6b@Xla3~|jbzlT+j
zaLW|$)uz!QMQ!O8HBeGwurp&@XGp6}EVF)zj)&YLc~}*MS%_&cE^e=5Rt=!!>W=fR
z!sJ5qD9vz&6AzodEP>>@dl-{^{IC)2(8#4Q4i0nUX%nnkd?Xl^>|gk11f7;_!x@#*
zaO+a%3SKvAKx7;~bs5qO6k?={!q&7N=6Qi8@darjbja;QA&5)2Yow>6DdO6Pu^aj%
z`5ykXX>UHM%$z}XVdBxHoeZx^0C*IZL6ul`Ky4L6B<@Q
znz>8+2HW}QXEUuW*QcV_OLIcKdi>%3qQ-ef^Sp64rje>V8#Lv!0;R4#E?()%#{uo4AO`~MEi
zUw1@!{E96OE$&+U+p40pa!@f$aDZ4v#p`JV=KKG!L#EWhNx2VfZ;Dj8SXtOlb>(v=
z8}q~TXm?~caP5WA^5m7o%tx$_vfCQz?)#e6l0muD;~z9M#>66_IaCcuMNBx`ft7V6
z4`Y5Jl$FfF?pJhvnLS6|pJE!{2HPN!VX#UcrkyqpCI-sTGJNL;G1P?Wtp)Sk*M7>Y
zbP4rwGfyIRyA^vz@&FP=1;&D*i92a|l(O8SA0|VPhNINgYl7H$-RL^*&WD4btg|cNO?0JR;U
ze--ss@-3W=gl9|6BquX{#>jg!5$6CfJmTa4R#Hin{6mE4F`!`uegU{)Xw2KYq?k~8
zzAm~FCy`AD)1i+YP`6%wim0?BOe?oZSp_;hG|X}*kj%^7KuO^K_`;yOP!;rtT|buZ
z(sGb~wMHh(77}jV>Up{UNJ~qb>GM~ySA-}W<+*b)S?z%>mX^j=&CBuMLB_iOfPxNE
zJpkOK0v%@uk&$}+vS=q<