Files
Stirling-PDF/scripts/download_pdf_samples.py
T
05eb74022a chore(ci): migrate Python tooling to uv and standardize workflow execution (#7386)
# Description of Changes

This PR modernizes the project's Python tooling across GitHub Actions by
migrating CI workflows from pip-based dependency management to `uv` and
aligning Python execution with the engine project's managed environment.

### What was changed

- Replaced `actions/setup-python` and ad-hoc `pip install` steps with
`astral-sh/setup-uv` across CI workflows.
- Configured shared `uv` dependency caching using
`engine/pyproject.toml` and `engine/uv.lock`.
- Updated Python script execution to use `uv run --project engine
--locked` for a consistent runtime environment.
- Replaced package installation steps with `uv sync` for the required
dependency groups (e.g. `tools` and `cucumber`).
- Added Docker image build validation for both production and
development AI engine images.
- Updated workflow cache configuration and Docker build context where
required.
- Removed obsolete Python requirements files that are no longer needed
after the migration.
- Applied minor Python code modernizations, including import cleanup,
modern built-in generic type annotations (`list[...]`, `tuple[...]`,
`float | None`), and small style improvements.
- Removed unnecessary Python formatter/linter extensions from the
development container configuration.

### Why the change was made

- Standardize Python dependency management across the repository.
- Reduce duplicated dependency installation logic in CI.
- Improve workflow performance through shared dependency caching.
- Ensure all Python utilities execute against the same locked dependency
set managed by the engine project.
- Simplify long-term maintenance by eliminating legacy requirements
files and pip-specific workflow steps.


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Signed-off-by: Carsten Drewes <c.drewes@stud.uni-hannover.de>
Co-authored-by: albanobattistella <34811668+albanobattistella@users.noreply.github.com>
Co-authored-by: kastenherri <116314318+kastenherri@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-08-11 08:09:21 +00:00

196 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""
Download large batches of PDF URLs into a local directory so they can be fed to
scripts/harvest_type3_fonts.py (or any other processing pipeline).
Usage examples:
# Download every URL listed in pdf_urls.txt into tmp/type3-pdfs
python scripts/download_pdf_samples.py \
--urls-file pdf_urls.txt \
--output-dir tmp/type3-pdfs
# Mix inline URLs with a file and use 16 concurrent downloads
python scripts/download_pdf_samples.py \
--urls https://example.com/a.pdf https://example.com/b.pdf \
--urls-file more_urls.txt \
--output-dir tmp/type3-pdfs \
--workers 16
"""
from __future__ import annotations
import argparse
import concurrent.futures
import hashlib
import os
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse
import requests
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Bulk download PDF URLs.")
parser.add_argument(
"--urls",
nargs="*",
default=[],
help="Inline list of PDF URLs (can be combined with --urls-file).",
)
parser.add_argument(
"--urls-file",
action="append",
help="Text file containing one URL per line (can be repeated).",
)
parser.add_argument(
"--output-dir",
default="tmp/harvest-pdfs",
help="Directory to store downloaded PDFs (default: %(default)s).",
)
parser.add_argument(
"--workers",
type=int,
default=min(8, (os.cpu_count() or 4) * 2),
help="Number of concurrent downloads (default: %(default)s).",
)
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Per-request timeout in seconds (default: %(default)s).",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing files (default: skip already downloaded PDFs).",
)
return parser.parse_args()
def load_urls(args: argparse.Namespace) -> list[str]:
urls: list[str] = []
seen: set[str] = set()
def add(url: str) -> None:
clean = url.strip()
if not clean or clean.startswith("#"):
return
if clean not in seen:
seen.add(clean)
urls.append(clean)
for url in args.urls:
add(url)
if args.urls_file:
for file in args.urls_file:
path = Path(file)
if not path.exists():
print(f"[WARN] URL file not found: {file}", file=sys.stderr)
continue
with path.open("r", encoding="utf-8") as handle:
for line in handle:
add(line)
if not urls:
raise SystemExit("No URLs supplied. Use --urls and/or --urls-file.")
return urls
def sanitize_filename(name: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("_") or "download"
def build_filename(url: str, output_dir: Path) -> Path:
parsed = urlparse(url)
candidate = Path(unquote(parsed.path)).name
if not candidate:
candidate = "download.pdf"
candidate = sanitize_filename(candidate)
if not candidate.lower().endswith(".pdf"):
candidate += ".pdf"
target = output_dir / candidate
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
digest = hashlib.sha1(url.encode("utf-8")).hexdigest()[:8]
return output_dir / f"{stem}-{digest}{suffix}"
def download_pdf(
url: str,
output_dir: Path,
timeout: int,
overwrite: bool,
) -> tuple[str, Path | None, str | None]:
try:
dest = build_filename(url, output_dir)
if dest.exists() and not overwrite:
return url, dest, "exists"
response = requests.get(url, stream=True, timeout=timeout)
response.raise_for_status()
content_type = response.headers.get("Content-Type", "").lower()
if "pdf" not in content_type and not url.lower().endswith(".pdf"):
# Peek into the first bytes to be safe
peek = response.raw.read(5, decode_content=True)
if not peek.startswith(b"%PDF"):
return (
url,
None,
f"Skipping non-PDF content-type ({content_type or 'unknown'})",
)
content = peek + response.content[len(peek) :]
else:
content = response.content
output_dir.mkdir(parents=True, exist_ok=True)
dest.write_bytes(content)
return url, dest, None
except Exception as exc: # pylint: disable=broad-except
return url, None, str(exc)
def main() -> None:
args = parse_args()
urls = load_urls(args)
output_dir = Path(args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Downloading {len(urls)} PDFs to {output_dir} using {args.workers} workers...")
successes = 0
skipped = 0
failures: list[tuple[str, str]] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
future_to_url = {
executor.submit(download_pdf, url, output_dir, args.timeout, args.overwrite): url for url in urls
}
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
result_url, path, error = future.result()
if error == "exists":
skipped += 1
print(f"[SKIP] {url} (already downloaded)")
elif error:
failures.append((result_url, error))
print(f"[FAIL] {url} -> {error}", file=sys.stderr)
else:
successes += 1
print(f"[OK] {url} -> {path}")
print()
print(f"Completed. Success: {successes}, Skipped: {skipped}, Failures: {len(failures)}")
if failures:
print("Failures:")
for url, error in failures:
print(f" {url} -> {error}")
if __name__ == "__main__":
main()