From c69efca189f68aa8e92246e8145716d55bde7663 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 08:47:45 +0900 Subject: [PATCH 01/15] docs: Add Korean Web Manual and GitHub Pages deployment workflow --- .github/workflows/gh-pages.yml | 38 +++++++++++++++++++++++++++++ manual/development.md | 44 ++++++++++++++++++++++++++++++++++ manual/index.md | 26 ++++++++++++++++++++ manual/installation.md | 42 ++++++++++++++++++++++++++++++++ manual/usage.md | 39 ++++++++++++++++++++++++++++++ mkdocs.yml | 36 ++++++++++++++++++++++++++++ 6 files changed, 225 insertions(+) create mode 100644 .github/workflows/gh-pages.yml create mode 100644 manual/development.md create mode 100644 manual/index.md create mode 100644 manual/installation.md create mode 100644 manual/usage.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml new file mode 100644 index 0000000..93f24de --- /dev/null +++ b/.github/workflows/gh-pages.yml @@ -0,0 +1,38 @@ +name: Deploy Web Manual to GitHub Pages + +on: + push: + branches: + - main + - master + - develop + paths: + - 'manual/**' + - 'mkdocs.yml' + - '.github/workflows/gh-pages.yml' + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install MkDocs and Material Theme + run: | + python -m pip install --upgrade pip + pip install mkdocs-material + + - name: Deploy to GitHub Pages + run: mkdocs gh-deploy --force diff --git a/manual/development.md b/manual/development.md new file mode 100644 index 0000000..b9c6f9f --- /dev/null +++ b/manual/development.md @@ -0,0 +1,44 @@ +# 개발 및 기여 가이드 + +이 문서에서는 NewsDOM API의 로컬 개발 방법, 테스트 및 픽스처(Fixture) 관리 규칙을 설명합니다. + +--- + +## 1. 테스트 실행하기 + +NewsDOM API는 Python의 `pytest` 프레임워크를 기반으로 구축되었습니다. + +기본적으로 합성된 픽스처(Synthetic Fixtures)만을 포함하여 배포되므로, 빠르게 테스트할 수 있습니다. + +```bash +# 단위 테스트 실행 +pytest + +# 통합 테스트 포함 실행 (실제 MinerU CLI 및 모델 캐시 필요) +pytest -m "integration" +``` + +--- + +## 2. 픽스처 및 데이터 관리 + +저장소에는 보안과 지적재산권을 고려해 **합성된(Synthetic) 테스트 픽스처와 베이스라인 파싱 구조**만 포함되어 있습니다. + +원본 데이터 파일의 출처 추적, 생성 기록 및 재생성 관리에 대한 자세한 내용은 아래 문서를 참조하십시오: +👉 `tests/fixtures/README.md` + +## 3. 기여하기 (Contributing) + +개발 환경 초기 설정 과정, 픽스처를 처리하는 구체적인 규칙, 로컬-only 베이스라인 유지보수 정책은 프로젝트 루트의 **`CONTRIBUTING.md`**에 상세히 작성되어 있습니다. + +또한 프로젝트의 형상 관리 방식, 브랜치 전략은 다음 문서를 따릅니다: +👉 `docs/workflow/git-flow.md` + +### 3.1. 디렉토리 구조 설명 + +- **`src/newsdom_api/`**: + FastAPI 백엔드, MinerU 래퍼, DOM 빌더 유틸리티, 합성 픽스처 생성기가 포함된 핵심 라이브러리 디렉토리입니다. +- **`tests/`**: + 단위 테스트와 함께 커밋된 합성 픽스처 데이터들이 저장되어 있습니다. +- **`tools/`**: + 로컬 유지보수와 자동화를 위한 스크립트 도구들이 위치해 있습니다. diff --git a/manual/index.md b/manual/index.md new file mode 100644 index 0000000..2d0163c --- /dev/null +++ b/manual/index.md @@ -0,0 +1,26 @@ +# NewsDOM API 시작하기 + +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Seongho-Bae/newsdom-api/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Seongho-Bae/newsdom-api) + +**NewsDOM API**는 스캔된 일본어 신문 PDF 문서를 분석하여, DOM 형태의 문서 트리 구조로 파싱해주는 강력한 API 서비스입니다. + +--- + +## 주요 기능 + +- **핵심 엔진**: `MinerU` 파이프라인 백엔드를 통해 정확한 텍스트 및 레이아웃 인식 +- **서비스 래퍼**: `FastAPI` 기반의 고성능 비동기 API 서버 +- **출력 결과물**: + - 페이지, 기사, 헤드라인, 본문 블록, 이미지, 캡션 및 품질 메타데이터가 모두 포함된 **정규화된 JSON(Canonical JSON)** + +## 왜 NewsDOM인가요? + +과거의 스캔된 신문 이미지는 단순한 텍스트 추출(OCR)만으로는 기사의 흐름을 파악하기 어렵습니다. +NewsDOM API는 복잡한 다단 레이아웃과 이미지, 제목 구조를 파악하여 웹 브라우저의 DOM(Document Object Model)과 유사한 형태로 구조화해 줍니다. + +따라서 프론트엔드나 앱에서 쉽게 렌더링하고, 데이터 분석 작업에 즉시 활용할 수 있습니다. + +--- + +다음 단계로 넘어가 직접 설치해 보세요! +👉 [설치 가이드 확인하기](installation.md) diff --git a/manual/installation.md b/manual/installation.md new file mode 100644 index 0000000..b8c74e3 --- /dev/null +++ b/manual/installation.md @@ -0,0 +1,42 @@ +# 설치 가이드 + +이 문서에서는 NewsDOM API를 로컬 환경에 설치하는 방법을 안내합니다. + +## 시스템 요구사항 + +- **Python**: 3.10 이상 3.14 미만 +- **운영체제**: Linux 또는 macOS 권장 (윈도우의 경우 WSL2 사용 권장) + +--- + +## 1. 기본 설치 (개발/테스트 모드) + +가장 간단한 형태로 파이썬 가상환경(Virtual Environment)을 생성하고 패키지를 설치합니다. + +```bash +# 가상 환경 생성 +python3.10 -m venv .venv + +# 가상 환경 활성화 +source .venv/bin/activate + +# 의존성 패키지와 함께 개발 모드로 설치 +pip install -e .[dev] +``` + +## 2. MinerU 백엔드 포함 실제 파싱 모드 설치 + +`MinerU` 백엔드를 사용하여 실제 PDF 파싱 작업을 수행하려면 `parser` 옵션을 추가로 설치해야 합니다. + +```bash +pip install -e .[parser] +``` + +이 옵션을 통해 `MinerU` 파이프라인(3.0.9 버전)이 설치되며 딥러닝 기반 모델이 준비됩니다. + +--- + +> **참고**: GitHub 저장소에서는 테스트를 위한 합성(Synthetic) 픽스처 데이터만 포함하여 제공하고 있습니다. 실제 파싱은 로컬 컴퓨터의 GPU 환경 등에 따라 성능이 달라질 수 있습니다. + +설치가 완료되었으면 서버를 실행해보세요! +👉 [사용 방법 알아보기](usage.md) \ No newline at end of file diff --git a/manual/usage.md b/manual/usage.md new file mode 100644 index 0000000..dd4aa15 --- /dev/null +++ b/manual/usage.md @@ -0,0 +1,39 @@ +# 사용 방법 + +NewsDOM API 서버를 실행하고, PDF를 업로드하여 파싱하는 기본적인 방법을 안내합니다. + +## 1. 서버 실행하기 + +가상환경을 활성화한 상태에서, `uvicorn`을 이용하여 FastAPI 서버를 구동합니다. + +```bash +# 로컬 개발 시에는 --reload 옵션을 사용하여 코드가 변경될 때 자동 재시작 +uvicorn newsdom_api.main:app --reload +``` + +서버가 실행되면, 기본적으로 `http://127.0.0.1:8000` 주소에서 수신 대기 상태가 됩니다. + +--- + +## 2. API 테스트 - PDF 파싱하기 + +HTTP 클라이언트(예: `curl`)를 사용하여 준비된 일본어 신문 스캔본 PDF(`sample.pdf`)를 전송합니다. + +```bash +# /parse 엔드포인트로 파일 전송 +curl -F "file=@sample.pdf" http://127.0.0.1:8000/parse +``` + +성공적으로 처리가 완료되면, 기사 본문, 이미지 링크, 캡션, 그리고 헤드라인 정보를 담은 **DOM 구조화된 JSON 데이터**가 응답됩니다. + +--- + +## 3. 웹 인터페이스 활용 + +FastAPI는 자동으로 대화형 API 문서를 생성합니다. +웹 브라우저를 열고 다음 주소에 접속하면 시각적인 환경에서 바로 API를 테스트할 수 있습니다. + +- **Swagger UI**: `http://127.0.0.1:8000/docs` +- **ReDoc**: `http://127.0.0.1:8000/redoc` + +해당 페이지에서 **`/parse`** 버튼을 클릭한 후, `Try it out` 기능을 통해 파일을 직접 첨부하고 테스트 결과를 볼 수 있습니다. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..893ea23 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,36 @@ +site_name: NewsDOM API 매뉴얼 +site_url: https://seongho-bae.github.io/newsdom-api/ +site_description: 일본어 신문 스캔 PDF를 DOM 형태의 트리로 파싱하는 API +repo_url: https://github.com/Seongho-Bae/newsdom-api +repo_name: Seongho-Bae/newsdom-api + +docs_dir: manual + +theme: + name: material + language: ko + features: + - navigation.tabs + - navigation.sections + - toc.integrate + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: 다크 모드로 전환 + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: 라이트 모드로 전환 + +nav: + - 시작하기: index.md + - 설치 가이드: installation.md + - 사용 방법: usage.md + - 개발 및 기여: development.md From cd441ead5e3b554e0d96e195d2502f2087ba585c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 08:58:50 +0900 Subject: [PATCH 02/15] docs: Enhance web manual with concrete API schemas, architecture, and contributing rules --- manual/api-reference.md | 126 ++++++++++++++++++++++++++++++++++++++++ manual/development.md | 74 ++++++++++++++++------- manual/index.md | 31 +++++----- manual/installation.md | 40 ++++++++++--- manual/usage.md | 39 ------------- mkdocs.yml | 2 +- 6 files changed, 231 insertions(+), 81 deletions(-) create mode 100644 manual/api-reference.md delete mode 100644 manual/usage.md diff --git a/manual/api-reference.md b/manual/api-reference.md new file mode 100644 index 0000000..bb63c8c --- /dev/null +++ b/manual/api-reference.md @@ -0,0 +1,126 @@ +# 사용 방법 및 API 레퍼런스 + +FastAPI 서버를 실행하고, 스캔된 신문 PDF를 업로드하여 기사 DOM 트리 형태의 Canonical JSON 데이터를 파싱하는 방법을 안내합니다. + +## 1. 서버 실행하기 + +가상환경을 활성화한 상태에서, `uvicorn`을 이용하여 API 서버를 구동합니다. + +```bash +# 개발 시 핫-리로딩(--reload) 모드 사용 +uvicorn newsdom_api.main:app --reload +``` + +## 2. 인터랙티브 웹 UI + +FastAPI는 OpenAPI 기반의 대화형 API 문서를 자동으로 생성합니다. +웹 브라우저를 열고 다음 주소에 접속하면 시각적인 환경에서 바로 API를 테스트할 수 있습니다. + +- **Swagger UI**: `http://127.0.0.1:8000/docs` +- **ReDoc**: `http://127.0.0.1:8000/redoc` + +해당 페이지에서 **`/parse`** 버튼을 클릭한 후, `Try it out` 기능을 통해 파일을 직접 첨부하고 테스트 결과를 볼 수 있습니다. + +--- + +## 3. API 엔드포인트 설명 + +### `POST /parse` + +스캔된 일본어 신문 PDF 문서를 업로드 받아, 기사 단위 DOM 구조가 담긴 JSON 형태로 변환합니다. + +#### 요청 매개변수 (Request Body) +- **`file`** (`UploadFile`, 필수): 변환할 PDF 바이너리 파일 데이터 (`multipart/form-data`) + +#### cURL 테스트 예제 + +```bash +# 로컬 테스트 (sample.pdf를 /parse 엔드포인트로 전송) +curl -X 'POST' \ + 'http://127.0.0.1:8000/parse' \ + -H 'accept: application/json' \ + -H 'Content-Type: multipart/form-data' \ + -F 'file=@sample.pdf;type=application/pdf' +``` + +--- + +### 응답(Response) JSON 스키마 + +파싱 성공 시 반환되는 DOM 형태의 `ParseResponse` 객체 구조입니다. + +```json +{ + "document_id": "string (고유 문서 식별자)", + "pages": [ + { + "page_number": 1, + "width": 800.5, + "height": 1200.0, + "articles": [ + { + "article_id": "article_0", + "headline": "신문 헤드라인 제목", + "bbox": { + "x0": 10.0, "y0": 20.0, "x1": 400.0, "y1": 150.0 + }, + "body_blocks": [ + "첫 번째 단락의 본문입니다.", + "두 번째 단락이 이어집니다." + ], + "images": [ + { + "path": "extracted_images/page1_img1.jpg", + "bbox": { ... }, + "captions": [ + { + "text": "사진 캡션 내용입니다.", + "bbox": { ... } + } + ] + } + ], + "captions": [] + } + ], + "ads": [ + "광고 문구 또는 배너 텍스트" + ], + "headers": [ + "2026년 4월 9일 목요일 조간" + ] + } + ], + "quality": { + "status": "success", + "parser": "mineru", + "warnings": [ + "Page 1: 일부 레이아웃이 겹침" + ] + } +} +``` + +#### 스키마 주요 노드 설명 +1. **`BoundingBox` (`bbox`)**: 요소가 위치한 직사각형 영역 `(x0, y0, x1, y1)`. 신문 지면 상에서의 상대적 또는 절대적 좌표를 나타냅니다. +2. **`PageNode`**: 단일 신문 지면입니다. 여러 개의 기사(`articles`)와 광고(`ads`), 상단 헤더 정보(`headers`)를 포함합니다. +3. **`ArticleNode`**: 가장 핵심적인 DOM 요소로, 제목(`headline`), 문단 블록 배열(`body_blocks`), 기사에 속한 이미지 목록(`images`), 기사 내 독립된 사진 설명(`captions`)으로 구성됩니다. +4. **`ParseQuality`**: 변환 결과를 검증하기 위한 상태, 사용된 파서 엔진 정보(`mineru`), 주의해야할 품질 문제(`warnings`) 등의 메타데이터를 갖습니다. + +--- + +### `GET /health` + +API 서버의 작동 여부를 검사하는 경량 상태 체크 엔드포인트입니다. 로드밸런서나 쿠버네티스 프로브 등에서 서비스가 살아있는지 검사할 때 사용합니다. + +#### 요청 / 응답 + +```bash +curl http://127.0.0.1:8000/health +``` + +```json +{ + "status": "ok" +} +``` \ No newline at end of file diff --git a/manual/development.md b/manual/development.md index b9c6f9f..d9cc829 100644 --- a/manual/development.md +++ b/manual/development.md @@ -1,44 +1,78 @@ # 개발 및 기여 가이드 -이 문서에서는 NewsDOM API의 로컬 개발 방법, 테스트 및 픽스처(Fixture) 관리 규칙을 설명합니다. +이 문서에서는 NewsDOM API의 로컬 개발 방법, 테스트, 그리고 **엄격한 픽스처(Fixture) 관리 규칙**을 설명합니다. --- ## 1. 테스트 실행하기 -NewsDOM API는 Python의 `pytest` 프레임워크를 기반으로 구축되었습니다. - -기본적으로 합성된 픽스처(Synthetic Fixtures)만을 포함하여 배포되므로, 빠르게 테스트할 수 있습니다. +NewsDOM API는 Python의 `pytest` 프레임워크를 기반으로 테스트 코드가 작성되어 있습니다. +CI/CD 환경과 로컬에서 원활히 테스트할 수 있도록, 무거운 모델 실행 없이도 돌아가는 단위 테스트(Unit tests) 위주로 구성되어 있습니다. ```bash -# 단위 테스트 실행 +# 기본 단위 테스트 실행 pytest -# 통합 테스트 포함 실행 (실제 MinerU CLI 및 모델 캐시 필요) +# 파이썬 경고(Warning)를 에러로 취급하여 꼼꼼하게 검사 +PYTHONWARNINGS=error pytest + +# 통합 테스트 포함 실행 (실제 MinerU CLI 및 다운로드된 모델 파일 필요) pytest -m "integration" ``` --- -## 2. 픽스처 및 데이터 관리 +## 2. 픽스처(Fixture) 정책 및 데이터 관리 + +뉴스 스캔본은 저작권 등 민감한 문제가 있을 수 있으므로, **저장소 내부의 파일 유지보수에 매우 엄격한 규칙**이 적용됩니다. + +### 허용되는 항목 (저장소 커밋 가능) +- 합성된(Synthetic) 형태의 더미 PDF 파일 +- 파싱 결과를 보여주기 위한 합성(Synthetic) 사이드카 JSON +- 텍스트 내용은 담지 않은, 구조적 메타데이터나 위치 좌표 등만 남은 파생된 구조 데이터 (Derived baseline) + +### 엄격히 금지되는 항목 (절대 커밋 불가) +- 저작권이 있는 실제 원본 신문 PDF 파일 +- 실제 참조 문서에서 추출되거나 복사된 원본 텍스트(OCR 결과물 전체) +- 원본에서 잘라낸 이미지 크롭 조각들 + +> **주의**: 위 사항들은 `.gitignore`에 정의되어 있더라도, 절대 실수로 `git add` 되지 않도록 주의해야 합니다. 픽스처 생성 이력 및 재생성에 관한 문서는 `tests/fixtures/README.md`를 참고하십시오. + +--- + +## 3. 프라이빗 베이스라인 (Private Baseline) 갱신 -저장소에는 보안과 지적재산권을 고려해 **합성된(Synthetic) 테스트 픽스처와 베이스라인 파싱 구조**만 포함되어 있습니다. +로컬 컴퓨터에서 원본 참조 문서를 가지고 비공개로 모델 성능 개선 테스트를 진행할 경우, 파생된 구조적 베이스라인(JSON) 결과만 저장소로 가져와 갱신할 수 있습니다. -원본 데이터 파일의 출처 추적, 생성 기록 및 재생성 관리에 대한 자세한 내용은 아래 문서를 참조하십시오: -👉 `tests/fixtures/README.md` +로컬에서 베이스라인 갱신 시 제공되는 도구를 사용하십시오: -## 3. 기여하기 (Contributing) +```bash +python tools/derive_private_baseline.py tests/fixtures/private_page_baseline.json +``` + +**반드시 원본 데이터가 아닌 파생된 JSON 파일만 저장소에 반영해야 합니다.** + +--- -개발 환경 초기 설정 과정, 픽스처를 처리하는 구체적인 규칙, 로컬-only 베이스라인 유지보수 정책은 프로젝트 루트의 **`CONTRIBUTING.md`**에 상세히 작성되어 있습니다. +## 4. 브랜치 워크플로우 (Branch Workflow) + +이 프로젝트는 수동 **Git Flow 모델**을 사용합니다. +- 새로운 기능 개발, 버그 수정은 항상 `develop` 브랜치에서 파생(Branch)되어야 합니다. +- 작업 완료 후에는 `develop` 브랜치를 향해 Pull Request를 엽니다. +- `release/*` 및 `hotfix/*` 브랜치는 프로덕션 배포나 긴급 패치 용도로만 사용됩니다. + +자세한 브랜치 전략은 `docs/workflow/git-flow.md` 문서를 확인하십시오. + +--- -또한 프로젝트의 형상 관리 방식, 브랜치 전략은 다음 문서를 따릅니다: -👉 `docs/workflow/git-flow.md` +## 5. 프로젝트 디렉토리 구조 -### 3.1. 디렉토리 구조 설명 +프로젝트 주요 폴더는 다음과 같이 구성되어 있습니다: - **`src/newsdom_api/`**: - FastAPI 백엔드, MinerU 래퍼, DOM 빌더 유틸리티, 합성 픽스처 생성기가 포함된 핵심 라이브러리 디렉토리입니다. -- **`tests/`**: - 단위 테스트와 함께 커밋된 합성 픽스처 데이터들이 저장되어 있습니다. -- **`tools/`**: - 로컬 유지보수와 자동화를 위한 스크립트 도구들이 위치해 있습니다. + - `main.py`: FastAPI 엔드포인트 및 앱 설정 + - `schemas.py`: Pydantic 기반 API 입출력 모델 (`PageNode`, `ArticleNode` 등) + - `service.py`: PDF 파싱 비즈니스 로직 및 MinerU 래퍼 구현체 +- **`tests/`**: 단위 테스트 코드와 커밋이 허용된 합성 픽스처 데이터 보관 +- **`tools/`**: 로컬 유지보수 및 `derive_private_baseline.py` 등 관리용 스크립트 모음 +- **`docs/plans/`**: 개발 디자인 및 구현 설계 기록들 diff --git a/manual/index.md b/manual/index.md index 2d0163c..47c9ada 100644 --- a/manual/index.md +++ b/manual/index.md @@ -1,26 +1,29 @@ -# NewsDOM API 시작하기 +# NewsDOM API 개요 [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Seongho-Bae/newsdom-api/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Seongho-Bae/newsdom-api) -**NewsDOM API**는 스캔된 일본어 신문 PDF 문서를 분석하여, DOM 형태의 문서 트리 구조로 파싱해주는 강력한 API 서비스입니다. +**NewsDOM API**는 스캔된 일본어 신문 PDF 문서를 분석하여, DOM 형태의 문서 트리 구조로 파싱해주는 API 서비스입니다. ---- +과거의 신문 이미지는 단순 텍스트 추출(OCR)만으로는 다단 레이아웃이나 이미지/캡션, 기사별 흐름을 파악하기 매우 어렵습니다. 이 프로젝트는 **MinerU 모델 파이프라인**을 활용해 지면을 단일 기사(Article) 단위로 쪼개고, 마치 웹 브라우저의 DOM(Document Object Model)과 같이 트리 구조의 JSON 데이터로 변환해 줍니다. -## 주요 기능 +--- -- **핵심 엔진**: `MinerU` 파이프라인 백엔드를 통해 정확한 텍스트 및 레이아웃 인식 -- **서비스 래퍼**: `FastAPI` 기반의 고성능 비동기 API 서버 -- **출력 결과물**: - - 페이지, 기사, 헤드라인, 본문 블록, 이미지, 캡션 및 품질 메타데이터가 모두 포함된 **정규화된 JSON(Canonical JSON)** +## 핵심 구조 및 기능 -## 왜 NewsDOM인가요? +- **백엔드 엔진**: `MinerU` 파이프라인 (v3.0.9) +- **API 프레임워크**: `FastAPI` 기반 비동기 API 서버 +- **DOM 변환기**: + - 신문 지면 1장(Page) 단위를 분석하여, 그 안의 기사(Articles), 광고(Ads), 헤더(Headers) 영역을 구분합니다. + - 각 기사는 헤드라인(Headline), 본문 블록(Body blocks), 연관된 이미지(Images) 및 이미지 캡션(Captions)으로 다시 세분화됩니다. + - 모든 구성 요소는 원본 문서 내 위치 좌표(`BoundingBox`)를 포함할 수 있습니다. -과거의 스캔된 신문 이미지는 단순한 텍스트 추출(OCR)만으로는 기사의 흐름을 파악하기 어렵습니다. -NewsDOM API는 복잡한 다단 레이아웃과 이미지, 제목 구조를 파악하여 웹 브라우저의 DOM(Document Object Model)과 유사한 형태로 구조화해 줍니다. +## 아키텍처 개요 -따라서 프론트엔드나 앱에서 쉽게 렌더링하고, 데이터 분석 작업에 즉시 활용할 수 있습니다. +1. **사용자 요청**: 사용자가 API(`/parse`)로 신문 스캔본 PDF 파일을 업로드합니다. +2. **FastAPI 처리**: 비동기 서버가 파일을 메모리로 읽어들여 파싱 모듈로 넘깁니다. +3. **MinerU 파이프라인**: 딥러닝 기반 레이아웃 분석 및 OCR을 수행합니다. +4. **DOM 빌더**: 인식된 영역들을 기사 논리에 맞게 트리 구조(Canonical JSON)로 병합하고 반환합니다. --- -다음 단계로 넘어가 직접 설치해 보세요! -👉 [설치 가이드 확인하기](installation.md) +👉 다음 단계로 **[설치 가이드](installation.md)**를 읽고 직접 환경을 구성해 보세요. \ No newline at end of file diff --git a/manual/installation.md b/manual/installation.md index b8c74e3..b94965d 100644 --- a/manual/installation.md +++ b/manual/installation.md @@ -4,39 +4,65 @@ ## 시스템 요구사항 -- **Python**: 3.10 이상 3.14 미만 +- **Python**: `>=3.10`, `<3.14` 필수 (`python3.10` 권장) - **운영체제**: Linux 또는 macOS 권장 (윈도우의 경우 WSL2 사용 권장) +- **메모리 / GPU**: `MinerU` 딥러닝 기반 파이프라인을 구동하기 위해서는 최소 **8GB 이상의 RAM**이 필요하며, 성능을 위해 **NVIDIA GPU(CUDA 호환)** 환경이 강력히 권장됩니다. --- ## 1. 기본 설치 (개발/테스트 모드) -가장 간단한 형태로 파이썬 가상환경(Virtual Environment)을 생성하고 패키지를 설치합니다. +가장 간단한 형태로 파이썬 가상환경(Virtual Environment)을 생성하고 패키지를 설치합니다. 이 모드에서는 실제 `MinerU` 모델이 로드되지 않으며, `pytest`나 합성 픽스처(Synthetic Fixtures) 기반 테스트 용도로 적합합니다. ```bash # 가상 환경 생성 python3.10 -m venv .venv # 가상 환경 활성화 +# macOS / Linux source .venv/bin/activate +# Windows (WSL 제외) +# .venv\Scripts\activate + +# pip 업그레이드 +python -m pip install --upgrade pip # 의존성 패키지와 함께 개발 모드로 설치 pip install -e .[dev] ``` +설치되는 기본 의존성은 다음과 같습니다: +- `fastapi>=0.115,<1.0` +- `uvicorn>=0.30,<1.0` +- `pydantic>=2.9,<3.0` +- `python-multipart>=0.0.9,<1.0` +- `reportlab>=4.2,<5.0`, `Pillow`, `pypdf` 등 PDF/이미지 처리 라이브러리 +- 개발 환경용: `pytest`, `httpx` + +--- + ## 2. MinerU 백엔드 포함 실제 파싱 모드 설치 -`MinerU` 백엔드를 사용하여 실제 PDF 파싱 작업을 수행하려면 `parser` 옵션을 추가로 설치해야 합니다. +`MinerU` 백엔드를 사용하여 실제 스캔된 일본어 신문 PDF 파싱 작업을 수행하려면 `[parser]` 선택 옵션을 추가로 설치해야 합니다. ```bash +# 파서 모듈까지 모두 설치 pip install -e .[parser] ``` -이 옵션을 통해 `MinerU` 파이프라인(3.0.9 버전)이 설치되며 딥러닝 기반 모델이 준비됩니다. +이 옵션을 통해 **`mineru[pipeline]==3.0.9`** 버전이 설치되며 딥러닝 기반 모델이 함께 준비됩니다. 설치 후 처음 API 서버를 구동하고 PDF를 파싱할 때 모델(Weight) 파일을 백그라운드에서 다운로드할 수 있으므로, 첫 실행에는 다소 시간이 걸릴 수 있습니다. --- -> **참고**: GitHub 저장소에서는 테스트를 위한 합성(Synthetic) 픽스처 데이터만 포함하여 제공하고 있습니다. 실제 파싱은 로컬 컴퓨터의 GPU 환경 등에 따라 성능이 달라질 수 있습니다. +## 확인하기 + +설치가 정상적으로 완료되었는지 확인하려면 기본 테스트를 구동해보세요. + +```bash +# 단위 테스트 구동 (integration 테스트 제외) +pytest +``` + +모든 테스트가 통과했다면 서버를 실행할 준비가 된 것입니다. -설치가 완료되었으면 서버를 실행해보세요! -👉 [사용 방법 알아보기](usage.md) \ No newline at end of file +👉 다음 단계: **[API 레퍼런스 및 사용 방법](api-reference.md)** \ No newline at end of file diff --git a/manual/usage.md b/manual/usage.md deleted file mode 100644 index dd4aa15..0000000 --- a/manual/usage.md +++ /dev/null @@ -1,39 +0,0 @@ -# 사용 방법 - -NewsDOM API 서버를 실행하고, PDF를 업로드하여 파싱하는 기본적인 방법을 안내합니다. - -## 1. 서버 실행하기 - -가상환경을 활성화한 상태에서, `uvicorn`을 이용하여 FastAPI 서버를 구동합니다. - -```bash -# 로컬 개발 시에는 --reload 옵션을 사용하여 코드가 변경될 때 자동 재시작 -uvicorn newsdom_api.main:app --reload -``` - -서버가 실행되면, 기본적으로 `http://127.0.0.1:8000` 주소에서 수신 대기 상태가 됩니다. - ---- - -## 2. API 테스트 - PDF 파싱하기 - -HTTP 클라이언트(예: `curl`)를 사용하여 준비된 일본어 신문 스캔본 PDF(`sample.pdf`)를 전송합니다. - -```bash -# /parse 엔드포인트로 파일 전송 -curl -F "file=@sample.pdf" http://127.0.0.1:8000/parse -``` - -성공적으로 처리가 완료되면, 기사 본문, 이미지 링크, 캡션, 그리고 헤드라인 정보를 담은 **DOM 구조화된 JSON 데이터**가 응답됩니다. - ---- - -## 3. 웹 인터페이스 활용 - -FastAPI는 자동으로 대화형 API 문서를 생성합니다. -웹 브라우저를 열고 다음 주소에 접속하면 시각적인 환경에서 바로 API를 테스트할 수 있습니다. - -- **Swagger UI**: `http://127.0.0.1:8000/docs` -- **ReDoc**: `http://127.0.0.1:8000/redoc` - -해당 페이지에서 **`/parse`** 버튼을 클릭한 후, `Try it out` 기능을 통해 파일을 직접 첨부하고 테스트 결과를 볼 수 있습니다. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 893ea23..8ab9d97 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,5 +32,5 @@ theme: nav: - 시작하기: index.md - 설치 가이드: installation.md - - 사용 방법: usage.md + - 사용 방법 및 API: api-reference.md - 개발 및 기여: development.md From c24751cbb3ce33eb784394a7ad07d2cecf80934d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 09:01:31 +0900 Subject: [PATCH 03/15] docs: Massive rewrite of web manual to be ultra-specific with exact scripts, workflows, and internal architecture --- manual/api-reference.md | 45 +++++++++++++++----- manual/development.md | 93 ++++++++++++++++++++--------------------- manual/index.md | 43 ++++++++++++------- manual/installation.md | 48 +++++++++++---------- 4 files changed, 133 insertions(+), 96 deletions(-) diff --git a/manual/api-reference.md b/manual/api-reference.md index bb63c8c..9fb297e 100644 --- a/manual/api-reference.md +++ b/manual/api-reference.md @@ -4,7 +4,7 @@ FastAPI 서버를 실행하고, 스캔된 신문 PDF를 업로드하여 기사 D ## 1. 서버 실행하기 -가상환경을 활성화한 상태에서, `uvicorn`을 이용하여 API 서버를 구동합니다. +가상환경을 활성화한 상태에서, `uvicorn`을 이용하여 API 서버(`src/newsdom_api/main.py`)를 구동합니다. ```bash # 개발 시 핫-리로딩(--reload) 모드 사용 @@ -19,15 +19,15 @@ FastAPI는 OpenAPI 기반의 대화형 API 문서를 자동으로 생성합니 - **Swagger UI**: `http://127.0.0.1:8000/docs` - **ReDoc**: `http://127.0.0.1:8000/redoc` -해당 페이지에서 **`/parse`** 버튼을 클릭한 후, `Try it out` 기능을 통해 파일을 직접 첨부하고 테스트 결과를 볼 수 있습니다. +해당 페이지에서 **`/parse`** 버튼을 클릭한 후, `Try it out` 기능을 통해 파일을 직접 첨부하고 결과물을 테스트해 볼 수 있습니다. --- -## 3. API 엔드포인트 설명 +## 3. API 엔드포인트 세부 설명 ### `POST /parse` -스캔된 일본어 신문 PDF 문서를 업로드 받아, 기사 단위 DOM 구조가 담긴 JSON 형태로 변환합니다. +스캔된 일본어 신문 PDF 문서를 업로드 받아, 임시 디렉토리에 저장 후 `mineru` 파이프라인을 백그라운드로 실행하고 변환 결과를 기사 단위 DOM 구조가 담긴 JSON 형태로 반환합니다. #### 요청 매개변수 (Request Body) - **`file`** (`UploadFile`, 필수): 변환할 PDF 바이너리 파일 데이터 (`multipart/form-data`) @@ -43,9 +43,32 @@ curl -X 'POST' \ -F 'file=@sample.pdf;type=application/pdf' ``` +#### Python 클라이언트 예제 (`httpx` 또는 `requests`) + +프론트엔드나 타 백엔드 서버에서 NewsDOM API를 호출하는 일반적인 패턴입니다. + +```python +import requests + +url = "http://127.0.0.1:8000/parse" +file_path = "sample_newspaper.pdf" + +with open(file_path, "rb") as f: + files = {"file": ("sample_newspaper.pdf", f, "application/pdf")} + response = requests.post(url, files=files) + +if response.status_code == 200: + dom_json = response.json() + print(f"문서 ID: {dom_json['document_id']}") + # 첫 번째 페이지의 첫 기사 제목 출력 + print(f"헤드라인: {dom_json['pages'][0]['articles'][0]['headline']}") +else: + print(f"에러 발생: {response.status_code}, {response.text}") +``` + --- -### 응답(Response) JSON 스키마 +### 응답(Response) JSON 스키마 (`src/newsdom_api/schemas.py`) 파싱 성공 시 반환되는 DOM 형태의 `ParseResponse` 객체 구조입니다. @@ -59,8 +82,8 @@ curl -X 'POST' \ "height": 1200.0, "articles": [ { - "article_id": "article_0", - "headline": "신문 헤드라인 제목", + "article_id": "article-1", + "headline": "신문 헤드라인 제목 (text_level=1 또는 role=section_headings 기반)", "bbox": { "x0": 10.0, "y0": 20.0, "x1": 400.0, "y1": 150.0 }, @@ -74,7 +97,7 @@ curl -X 'POST' \ "bbox": { ... }, "captions": [ { - "text": "사진 캡션 내용입니다.", + "text": "사진 캡션 내용입니다. (image_caption 블록 연결)", "bbox": { ... } } ] @@ -84,10 +107,10 @@ curl -X 'POST' \ } ], "ads": [ - "광고 문구 또는 배너 텍스트" + "광고 문구 또는 배너 텍스트 (type=ad 또는 role=ad)" ], "headers": [ - "2026년 4월 9일 목요일 조간" + "2026년 4월 9일 목요일 조간 (role=header)" ] } ], @@ -102,7 +125,7 @@ curl -X 'POST' \ ``` #### 스키마 주요 노드 설명 -1. **`BoundingBox` (`bbox`)**: 요소가 위치한 직사각형 영역 `(x0, y0, x1, y1)`. 신문 지면 상에서의 상대적 또는 절대적 좌표를 나타냅니다. +1. **`BoundingBox` (`bbox`)**: 요소가 위치한 직사각형 영역 `(x0, y0, x1, y1)`. 신문 지면 상에서의 절대적 물리 좌표를 나타냅니다. 2. **`PageNode`**: 단일 신문 지면입니다. 여러 개의 기사(`articles`)와 광고(`ads`), 상단 헤더 정보(`headers`)를 포함합니다. 3. **`ArticleNode`**: 가장 핵심적인 DOM 요소로, 제목(`headline`), 문단 블록 배열(`body_blocks`), 기사에 속한 이미지 목록(`images`), 기사 내 독립된 사진 설명(`captions`)으로 구성됩니다. 4. **`ParseQuality`**: 변환 결과를 검증하기 위한 상태, 사용된 파서 엔진 정보(`mineru`), 주의해야할 품질 문제(`warnings`) 등의 메타데이터를 갖습니다. diff --git a/manual/development.md b/manual/development.md index d9cc829..ae91ae9 100644 --- a/manual/development.md +++ b/manual/development.md @@ -1,78 +1,75 @@ # 개발 및 기여 가이드 -이 문서에서는 NewsDOM API의 로컬 개발 방법, 테스트, 그리고 **엄격한 픽스처(Fixture) 관리 규칙**을 설명합니다. +이 문서에서는 NewsDOM API의 로컬 개발 방법, 테스트 프레임워크(`pytest`), 그리고 매우 중요한 **엄격한 픽스처(Fixture) 관리 및 브랜치 규칙**을 설명합니다. --- -## 1. 테스트 실행하기 +## 1. 픽스처(Fixture) 보안 및 데이터 정책 -NewsDOM API는 Python의 `pytest` 프레임워크를 기반으로 테스트 코드가 작성되어 있습니다. -CI/CD 환경과 로컬에서 원활히 테스트할 수 있도록, 무거운 모델 실행 없이도 돌아가는 단위 테스트(Unit tests) 위주로 구성되어 있습니다. +일본어 스캔본 신문 데이터는 저작권 등 법적 문제가 얽혀 있을 수 있으므로, **저장소 내부의 픽스처 유지보수에 매우 엄격한 규칙**이 적용됩니다. -```bash -# 기본 단위 테스트 실행 -pytest - -# 파이썬 경고(Warning)를 에러로 취급하여 꼼꼼하게 검사 -PYTHONWARNINGS=error pytest - -# 통합 테스트 포함 실행 (실제 MinerU CLI 및 다운로드된 모델 파일 필요) -pytest -m "integration" -``` - ---- - -## 2. 픽스처(Fixture) 정책 및 데이터 관리 - -뉴스 스캔본은 저작권 등 민감한 문제가 있을 수 있으므로, **저장소 내부의 파일 유지보수에 매우 엄격한 규칙**이 적용됩니다. +### ✅ 허용되는 항목 (저장소 커밋 가능, `tests/fixtures/` 내) +- **합성된(Synthetic) 더미 PDF 파일** (`synthetic.py` 등 내부 생성기를 통해 만들어진 것만 허용) +- 파싱 로직 테스트를 위한 **합성(Synthetic) 사이드카 JSON** (`_content_list.json` 등) +- 텍스트 내용은 모두 제외하고 위치 좌표(BBox)나 구조적 메타데이터만 남은 파생 구조 데이터 (Derived baseline) -### 허용되는 항목 (저장소 커밋 가능) -- 합성된(Synthetic) 형태의 더미 PDF 파일 -- 파싱 결과를 보여주기 위한 합성(Synthetic) 사이드카 JSON -- 텍스트 내용은 담지 않은, 구조적 메타데이터나 위치 좌표 등만 남은 파생된 구조 데이터 (Derived baseline) +### ❌ 엄격히 금지되는 항목 (절대 커밋 불가) +- 저작권이 있는 실제 원본 신문 PDF 스캔본 파일 +- 실제 참조 문서(Private Reference Page)에서 추출되거나 복사된 OCR 텍스트 전체 +- 원본 스캔본에서 잘라낸 이미지 크롭(Crop) 조각 파일들 -### 엄격히 금지되는 항목 (절대 커밋 불가) -- 저작권이 있는 실제 원본 신문 PDF 파일 -- 실제 참조 문서에서 추출되거나 복사된 원본 텍스트(OCR 결과물 전체) -- 원본에서 잘라낸 이미지 크롭 조각들 - -> **주의**: 위 사항들은 `.gitignore`에 정의되어 있더라도, 절대 실수로 `git add` 되지 않도록 주의해야 합니다. 픽스처 생성 이력 및 재생성에 관한 문서는 `tests/fixtures/README.md`를 참고하십시오. +> **주의**: 위 사항들은 `.gitignore` 파일에 등재되어 있다 하더라도, 절대 `git add` 시 실수로 끼워 넣지 않도록 주의해야 합니다. 픽스처 생성 이력 및 재생성에 관한 문서는 `tests/fixtures/README.md`를 참고하십시오. --- -## 3. 프라이빗 베이스라인 (Private Baseline) 갱신 +## 2. 프라이빗 베이스라인 (Private Baseline) 업데이트 -로컬 컴퓨터에서 원본 참조 문서를 가지고 비공개로 모델 성능 개선 테스트를 진행할 경우, 파생된 구조적 베이스라인(JSON) 결과만 저장소로 가져와 갱신할 수 있습니다. +로컬 컴퓨터에서 원본 참조 문서(Private Page)를 가지고 비공개로 파이프라인/모델 성능 개선 테스트를 진행할 경우, **원본 데이터를 저장소로 올리면 안 되며, 파생된 구조적 베이스라인(JSON)만 갱신해야 합니다.** -로컬에서 베이스라인 갱신 시 제공되는 도구를 사용하십시오: +로컬 베이스라인 갱신 시 `tools/` 디렉토리에 제공된 전용 스크립트를 사용하십시오: ```bash +# 원본 문서는 로컬에 둔 채, 테스트를 위한 껍데기(baseline) JSON만 갱신합니다. python tools/derive_private_baseline.py tests/fixtures/private_page_baseline.json ``` -**반드시 원본 데이터가 아닌 파생된 JSON 파일만 저장소에 반영해야 합니다.** +**반드시 원본 데이터가 아닌 이 스크립트를 통해 생성된 파생 JSON 파일만 저장소에 반영(Commit)해야 합니다.** --- -## 4. 브랜치 워크플로우 (Branch Workflow) +## 3. Git 워크플로우 (Branch Model) + +이 프로젝트는 `git-flow init`과 같은 플러그인을 쓰지 않는 수동 **클래식 Git Flow 모델**(`docs/workflow/git-flow.md`)을 강제합니다. -이 프로젝트는 수동 **Git Flow 모델**을 사용합니다. -- 새로운 기능 개발, 버그 수정은 항상 `develop` 브랜치에서 파생(Branch)되어야 합니다. -- 작업 완료 후에는 `develop` 브랜치를 향해 Pull Request를 엽니다. -- `release/*` 및 `hotfix/*` 브랜치는 프로덕션 배포나 긴급 패치 용도로만 사용됩니다. +### 🌿 브랜치 규칙 +- **`main` 브랜치**: 안정적인 릴리즈(Stable Release) 전용. 직접 푸시 금지. +- **`develop` 브랜치**: 모든 새로운 작업의 시작점이자 통합 브랜치. +- **`feature/`, `fix/`, `chore/`**: + - `develop`에서 파생(Branch)되어야 합니다. + - 작업 완료 후 `develop` 브랜치를 향해 Pull Request를 엽니다. +- **`release/vX.Y.Z` 브랜치**: + - 제품 릴리즈 준비 시 `develop`에서 파생합니다. + - 릴리즈 및 안정화가 완료되면 `main`에 병합(Merge)한 후 버전을 태깅(Tag)하고, 변경 사항을 다시 `develop`으로 백머지(Back-merge)해야 합니다. +- **`hotfix/` 브랜치**: + - 운영(Production) 장애 등 긴급 패치 시 `main`에서 파생합니다. + - 수정 완료 후 `main`과 `develop` 모두에 병합해야 합니다. -자세한 브랜치 전략은 `docs/workflow/git-flow.md` 문서를 확인하십시오. +모든 작업은 반드시 **Pull Request (PR)** 를 거쳐 병합되어야 하며, 로컬 저장소 컨벤션과 GitHub 설정(Default-branch protection)으로 강제됩니다. --- -## 5. 프로젝트 디렉토리 구조 +## 4. 프로젝트 구조 안내 -프로젝트 주요 폴더는 다음과 같이 구성되어 있습니다: +코드를 탐색하기 위한 핵심 프로젝트 폴더 아키텍처입니다: - **`src/newsdom_api/`**: - - `main.py`: FastAPI 엔드포인트 및 앱 설정 - - `schemas.py`: Pydantic 기반 API 입출력 모델 (`PageNode`, `ArticleNode` 등) - - `service.py`: PDF 파싱 비즈니스 로직 및 MinerU 래퍼 구현체 -- **`tests/`**: 단위 테스트 코드와 커밋이 허용된 합성 픽스처 데이터 보관 -- **`tools/`**: 로컬 유지보수 및 `derive_private_baseline.py` 등 관리용 스크립트 모음 -- **`docs/plans/`**: 개발 디자인 및 구현 설계 기록들 + - `main.py`: FastAPI 서버 및 라우팅 (API 진입점) + - `schemas.py`: Pydantic 기반 DOM 데이터 직렬화 모델 (`PageNode`, `ArticleNode` 등) + - `service.py`: 비즈니스 로직. PDF 업로드를 받고 파서를 거쳐 DOM 빌더로 연결 + - `mineru_runner.py`: Python `subprocess`로 외부 `mineru` CLI 엔진 파이프라인 구동 및 JSON 로딩 + - `dom_builder.py`: MinerU의 선형 OCR 데이터를 트기 구조의 DOM으로 재구성 + - `synthetic.py`: 합성 더미 PDF 및 픽스처 생성 엔진 +- **`tests/`**: 단위/통합 테스트 코드와 커밋이 허용된 합성 픽스처(`fixtures/`) 보관소 +- **`tools/`**: `derive_private_baseline.py` 등 개발자용 로컬 스크립트 모음 +- **`docs/plans/`**: 설계(Design) 문서 및 구현 일지, 백로그 +- **`docs/workflow/`**: Git 브랜치 전략 및 운영 정책 규정 \ No newline at end of file diff --git a/manual/index.md b/manual/index.md index 47c9ada..b808afa 100644 --- a/manual/index.md +++ b/manual/index.md @@ -1,29 +1,40 @@ -# NewsDOM API 개요 +# NewsDOM API 개요 및 아키텍처 [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Seongho-Bae/newsdom-api/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Seongho-Bae/newsdom-api) -**NewsDOM API**는 스캔된 일본어 신문 PDF 문서를 분석하여, DOM 형태의 문서 트리 구조로 파싱해주는 API 서비스입니다. +**NewsDOM API**는 스캔된 일본어 신문 PDF 문서를 분석하여, 웹 브라우저의 DOM(Document Object Model)과 유사한 기사(Article) 단위의 트리 구조로 파싱해주는 API 서비스입니다. -과거의 신문 이미지는 단순 텍스트 추출(OCR)만으로는 다단 레이아웃이나 이미지/캡션, 기사별 흐름을 파악하기 매우 어렵습니다. 이 프로젝트는 **MinerU 모델 파이프라인**을 활용해 지면을 단일 기사(Article) 단위로 쪼개고, 마치 웹 브라우저의 DOM(Document Object Model)과 같이 트리 구조의 JSON 데이터로 변환해 줍니다. +과거의 신문 이미지는 단순 텍스트 추출(OCR)만으로는 다단 레이아웃이나 이미지/캡션, 기사별 흐름을 파악하기 매우 어렵습니다. 본 프로젝트는 딥러닝 기반 레이아웃 분석 도구인 **MinerU**를 백엔드로 사용하여 이 문제를 해결합니다. --- -## 핵심 구조 및 기능 +## ⚙️ 시스템 내부 아키텍처 (Under the Hood) -- **백엔드 엔진**: `MinerU` 파이프라인 (v3.0.9) -- **API 프레임워크**: `FastAPI` 기반 비동기 API 서버 -- **DOM 변환기**: - - 신문 지면 1장(Page) 단위를 분석하여, 그 안의 기사(Articles), 광고(Ads), 헤더(Headers) 영역을 구분합니다. - - 각 기사는 헤드라인(Headline), 본문 블록(Body blocks), 연관된 이미지(Images) 및 이미지 캡션(Captions)으로 다시 세분화됩니다. - - 모든 구성 요소는 원본 문서 내 위치 좌표(`BoundingBox`)를 포함할 수 있습니다. +사용자가 API를 통해 PDF 파일을 업로드하면, NewsDOM 내부에서는 다음의 세 단계를 거쳐 데이터를 처리합니다: -## 아키텍처 개요 +### 1. 서비스 래퍼 레이어 (`src/newsdom_api/service.py`) +FastAPI 엔드포인트(`/parse`)가 `UploadFile`로 전달받은 바이너리 데이터를 임시 디렉토리(Temporary Directory)에 저장한 후, 파이프라인 러너를 호출합니다. -1. **사용자 요청**: 사용자가 API(`/parse`)로 신문 스캔본 PDF 파일을 업로드합니다. -2. **FastAPI 처리**: 비동기 서버가 파일을 메모리로 읽어들여 파싱 모듈로 넘깁니다. -3. **MinerU 파이프라인**: 딥러닝 기반 레이아웃 분석 및 OCR을 수행합니다. -4. **DOM 빌더**: 인식된 영역들을 기사 논리에 맞게 트리 구조(Canonical JSON)로 병합하고 반환합니다. +### 2. MinerU 파이프라인 러너 (`src/newsdom_api/mineru_runner.py`) +저장된 PDF를 대상으로 Python `subprocess` 모듈을 이용해 **MinerU CLI**를 백그라운드에서 실행합니다. 실제 내부적으로 실행되는 명령어는 다음과 같습니다: + +```bash +mineru -p <업로드된_PDF> -o <임시출력경로> -b pipeline -m ocr -l japan +``` +> *참고: 일본어 신문 처리에 최적화하기 위해 `-l japan` (Language: Japanese) 옵션과 OCR 파이프라인 모드가 하드코딩되어 있습니다.* + +명령어 실행이 완료되면 러너는 생성된 출력 폴더(OCR 하위 폴더)를 뒤져 `*_content_list.json` 파일과 `*_model.json` 결과물을 메모리로 로드합니다. + +### 3. DOM 빌더 (`src/newsdom_api/dom_builder.py`) +MinerU가 뱉어낸 선형적인(Linear) 블록 리스트(`content_list.json`)를 순회하면서 논리적인 트리 형태의 **`ParseResponse` (Canonical JSON)**로 재구성합니다. +- `role == "header"` 이면 상단 머릿말로(`PageNode.headers`) 분류 +- `type == "ad"` 또는 `role == "ad"` 이면 지면 광고(`PageNode.ads`)로 분류 +- `text_level == 1` 이거나 `role == "section_headings"`인 경우 새로운 기사의 시작(Headline)으로 인식하여 새 `ArticleNode`를 생성 +- 이후 등장하는 일반 텍스트는 해당 기사의 `body_blocks` 배열에 추가 +- `type == "image"` 이면 `ImageNode`를 생성하고 포함된 캡션 배열을 파싱하여 기사에 종속시킴 + +이러한 세밀한 내부 변환 과정을 통해 단순한 OCR 텍스트 덤프가 아닌, 프론트엔드에서 즉시 렌더링이 가능한 **구조화된 DOM 데이터**가 최종 반환됩니다. --- -👉 다음 단계로 **[설치 가이드](installation.md)**를 읽고 직접 환경을 구성해 보세요. \ No newline at end of file +👉 **[설치 가이드](installation.md)**를 읽고 직접 환경을 구성해 보세요. \ No newline at end of file diff --git a/manual/installation.md b/manual/installation.md index b94965d..93500b8 100644 --- a/manual/installation.md +++ b/manual/installation.md @@ -1,27 +1,30 @@ -# 설치 가이드 +# 시스템 환경 및 설치 가이드 이 문서에서는 NewsDOM API를 로컬 환경에 설치하는 방법을 안내합니다. -## 시스템 요구사항 +## 🛠️ 시스템 요구사항 -- **Python**: `>=3.10`, `<3.14` 필수 (`python3.10` 권장) +- **Python**: `>=3.10, <3.14` 필수 (`python3.10` 강력 권장) - **운영체제**: Linux 또는 macOS 권장 (윈도우의 경우 WSL2 사용 권장) -- **메모리 / GPU**: `MinerU` 딥러닝 기반 파이프라인을 구동하기 위해서는 최소 **8GB 이상의 RAM**이 필요하며, 성능을 위해 **NVIDIA GPU(CUDA 호환)** 환경이 강력히 권장됩니다. +- **하드웨어 (GPU)**: `MinerU` 딥러닝 기반 파이프라인을 구동하기 위해서는 최소 **8GB 이상의 RAM**이 필요하며, 실시간 처리를 위해 **NVIDIA GPU(CUDA 11.x/12.x 호환)** 및 `PyTorch` 환경이 권장됩니다. +- **의존성 (Python)**: + - `fastapi>=0.115,<1.0`, `uvicorn>=0.30,<1.0`, `pydantic>=2.9,<3.0` + - `python-multipart`, `reportlab`, `Pillow`, `pypdf` 등 --- -## 1. 기본 설치 (개발/테스트 모드) +## 1. 기본 테스트 및 개발 모드 설치 가장 간단한 형태로 파이썬 가상환경(Virtual Environment)을 생성하고 패키지를 설치합니다. 이 모드에서는 실제 `MinerU` 모델이 로드되지 않으며, `pytest`나 합성 픽스처(Synthetic Fixtures) 기반 테스트 용도로 적합합니다. ```bash -# 가상 환경 생성 +# 가상 환경 생성 (파이썬 3.10 필수) python3.10 -m venv .venv # 가상 환경 활성화 # macOS / Linux source .venv/bin/activate -# Windows (WSL 제외) +# Windows (WSL 환경 제외) # .venv\Scripts\activate # pip 업그레이드 @@ -31,14 +34,6 @@ python -m pip install --upgrade pip pip install -e .[dev] ``` -설치되는 기본 의존성은 다음과 같습니다: -- `fastapi>=0.115,<1.0` -- `uvicorn>=0.30,<1.0` -- `pydantic>=2.9,<3.0` -- `python-multipart>=0.0.9,<1.0` -- `reportlab>=4.2,<5.0`, `Pillow`, `pypdf` 등 PDF/이미지 처리 라이브러리 -- 개발 환경용: `pytest`, `httpx` - --- ## 2. MinerU 백엔드 포함 실제 파싱 모드 설치 @@ -46,23 +41,34 @@ pip install -e .[dev] `MinerU` 백엔드를 사용하여 실제 스캔된 일본어 신문 PDF 파싱 작업을 수행하려면 `[parser]` 선택 옵션을 추가로 설치해야 합니다. ```bash -# 파서 모듈까지 모두 설치 +# 파서 모듈까지 모두 포함하여 설치 pip install -e .[parser] ``` -이 옵션을 통해 **`mineru[pipeline]==3.0.9`** 버전이 설치되며 딥러닝 기반 모델이 함께 준비됩니다. 설치 후 처음 API 서버를 구동하고 PDF를 파싱할 때 모델(Weight) 파일을 백그라운드에서 다운로드할 수 있으므로, 첫 실행에는 다소 시간이 걸릴 수 있습니다. +이 명령어를 통해 **`mineru[pipeline]==3.0.9`** 버전이 설치되며 딥러닝 기반 모델을 위한 준비가 완료됩니다. 설치 후 처음 API 서버를 구동하고 PDF를 파싱할 때 모델(Weight) 파일을 백그라운드에서 다운로드할 수 있으므로, 첫 실행에는 다운로드 대기 시간이 발생할 수 있습니다. + +### 커스텀 MinerU 실행 경로 (고급) +만약 `mineru` CLI 바이너리가 시스템 PATH에 잡혀있지 않거나, 특정 가상환경의 실행 파일을 수동으로 지정하고 싶다면 환경변수를 설정하세요: + +```bash +# newsdom_api/mineru_runner.py 에서 이 환경변수를 우선 탐색합니다. +export NEWSDOM_MINERU_BIN="/path/to/custom/mineru" +``` --- -## 확인하기 +## 3. 설치 확인 및 상태 점검 설치가 정상적으로 완료되었는지 확인하려면 기본 테스트를 구동해보세요. ```bash -# 단위 테스트 구동 (integration 테스트 제외) -pytest +# 파이썬 경고(Warning)를 에러로 취급하여 꼼꼼하게 검사 +PYTHONWARNINGS=error pytest + +# 통합 테스트 포함 실행 (실제 MinerU CLI 및 다운로드된 모델 파일 필요) +pytest -m "integration" ``` -모든 테스트가 통과했다면 서버를 실행할 준비가 된 것입니다. +모든 단위 테스트(`tests/`)가 성공적으로 통과했다면 API 서버를 실행할 준비가 된 것입니다. 👉 다음 단계: **[API 레퍼런스 및 사용 방법](api-reference.md)** \ No newline at end of file From 2c92e6a4e296ccbd89bcc8d234e2f7c37fe33b52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 19:01:35 +0900 Subject: [PATCH 04/15] test: add enforced quality gate (#2) * fix: scope scorecards push to develop * test: add enforced quality gate * test: cover synthetic helper branches * chore: add automated dependency updates * docs: add security reporting policy * ci: pin workflow dependencies * ci: pin workflow actions and broaden PR checks * ci: lock uv installs and PR workflow coverage * ci: add release provenance workflow * ci: force github actions to node24 * docs: record OpenSSF badge decision * docs: add changelog baseline * ci: pin workflow dependencies (#5) * ci: pin workflow dependencies * ci: pin workflow actions and broaden PR checks * ci: lock uv installs and PR workflow coverage * ci: add release provenance workflow (#6) * ci: add release provenance workflow * ci: force github actions to node24 (#7) * ci: force github actions to node24 * docs: record OpenSSF badge decision (#11) * docs: record OpenSSF badge decision * docs: add changelog baseline (#12) * ci: align gh-pages workflow with repo policies * test: tighten review-driven regressions * docs: tighten manual examples * test: strengthen review follow-up assertions * docs: align installation guidance with recommendation * ci: add CircleCI quality gate * ci: harden CircleCI uv install * test: tighten remaining reviewer regressions * docs: clarify supported Python range without implying 3.10-only use * ci: harden docs deploy path for reproducible Pages builds * ci: close remaining automation review gaps * docs: keep dev install examples shell-safe and in sync * ci: enable repo-local CodeRabbit approval workflow --- .circleci/config.yml | 36 + .coderabbit.yaml | 8 + .github/dependabot.yml | 21 + .github/workflows/codeql.yml | 12 +- .github/workflows/dependency-review.yml | 8 +- .github/workflows/gh-pages.yml | 51 +- .github/workflows/quality-gate.yml | 38 + .github/workflows/release.yml | 63 + .github/workflows/scorecards.yml | 12 +- .github/workflows/tests.yml | 17 +- .gitignore | 1 + CHANGELOG.md | 14 + CONTRIBUTING.md | 21 +- README.md | 13 +- SECURITY.md | 33 + docs/adr/0001-openssf-best-practices-badge.md | 36 + docs/adr/README.md | 5 + docs/plans/2026-04-08-quality-gate-design.md | 33 + docs/plans/2026-04-08-quality-gate.md | 66 + manual/api-reference.md | 18 +- manual/development.md | 4 +- manual/installation.md | 16 +- pyproject.toml | 18 +- scripts/__init__.py | 1 + scripts/release/__init__.py | 1 + scripts/release/build_release_manifest.py | 57 + src/newsdom_api/dom_builder.py | 6 + src/newsdom_api/equivalence.py | 6 + src/newsdom_api/main.py | 10 +- src/newsdom_api/mineru_runner.py | 10 + src/newsdom_api/schemas.py | 16 + src/newsdom_api/service.py | 7 +- src/newsdom_api/synthetic.py | 19 +- tests/conftest.py | 3 + tests/test_adr_docs.py | 12 + tests/test_changelog.py | 9 + tests/test_circleci_config.py | 19 + tests/test_coderabbit_config.py | 12 + tests/test_dependabot.py | 21 + tests/test_docstrings.py | 15 + tests/test_dom_builder.py | 54 +- tests/test_equivalence.py | 45 + tests/test_manual_docs.py | 33 + tests/test_mineru_runner_paths.py | 176 +++ tests/test_project_metadata.py | 6 + tests/test_readme.py | 14 + tests/test_release_pipeline.py | 67 + tests/test_security_policy.py | 17 + tests/test_service.py | 58 + tests/test_synthetic_paths.py | 64 + tests/test_workflow_runtime_env.py | 10 + tests/test_workflow_security.py | 133 ++ tests/test_workflows.py | 15 + uv.lock | 1085 +++++++++++++++++ 54 files changed, 2490 insertions(+), 55 deletions(-) create mode 100644 .circleci/config.yml create mode 100644 .coderabbit.yaml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/quality-gate.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 SECURITY.md create mode 100644 docs/adr/0001-openssf-best-practices-badge.md create mode 100644 docs/adr/README.md create mode 100644 docs/plans/2026-04-08-quality-gate-design.md create mode 100644 docs/plans/2026-04-08-quality-gate.md create mode 100644 scripts/__init__.py create mode 100644 scripts/release/__init__.py create mode 100644 scripts/release/build_release_manifest.py create mode 100644 tests/test_adr_docs.py create mode 100644 tests/test_changelog.py create mode 100644 tests/test_circleci_config.py create mode 100644 tests/test_coderabbit_config.py create mode 100644 tests/test_dependabot.py create mode 100644 tests/test_docstrings.py create mode 100644 tests/test_equivalence.py create mode 100644 tests/test_manual_docs.py create mode 100644 tests/test_mineru_runner_paths.py create mode 100644 tests/test_project_metadata.py create mode 100644 tests/test_release_pipeline.py create mode 100644 tests/test_security_policy.py create mode 100644 tests/test_service.py create mode 100644 tests/test_synthetic_paths.py create mode 100644 tests/test_workflow_runtime_env.py create mode 100644 tests/test_workflow_security.py create mode 100644 tests/test_workflows.py create mode 100644 uv.lock diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..70c42de --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,36 @@ +version: 2.1 + +jobs: + quality-gate: + docker: + - image: cimg/python:3.10 + steps: + - checkout + - run: + name: Install uv + command: | + export UV_UNMANAGED_INSTALL=1 + export UV_NO_MODIFY_PATH=1 + curl -LsSf -o /tmp/uv-install.sh https://astral.sh/uv/0.11.3/install.sh + sh /tmp/uv-install.sh + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV" + - run: + name: Install project dependencies + command: | + source "$BASH_ENV" + uv sync --locked --extra dev + - run: + name: Run warnings-as-errors tests + command: | + source "$BASH_ENV" + PYTHONWARNINGS=error uv run pytest + - run: + name: Run coverage quality gate + command: | + source "$BASH_ENV" + uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100 + +workflows: + quality-gate: + jobs: + - quality-gate diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..1d45d05 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,8 @@ +language: ko + +reviews: + profile: chill + request_changes_workflow: true + auto_review: + enabled: true + auto_incremental_review: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2b3e2cb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + target-branch: develop + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "pip" + directory: "/" + target-branch: develop + schedule: + interval: weekly + groups: + python: + patterns: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7593d1d..1cd2fe4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,10 +1,12 @@ name: codeql +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: push: branches: [main, develop] pull_request: - branches: [main, develop] schedule: - cron: '43 5 * * 1' @@ -19,15 +21,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 with: languages: python - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 - name: Analyze - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 27402f1..9bda0b9 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,8 +1,10 @@ name: dependency-review +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: pull_request: - branches: [main, develop] permissions: contents: read @@ -14,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Dependency review - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 93f24de..f77daf2 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -1,5 +1,8 @@ name: Deploy Web Manual to GitHub Pages +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: push: branches: @@ -13,26 +16,56 @@ on: workflow_dispatch: permissions: - contents: write + contents: read + +concurrency: + group: github-pages + cancel-in-progress: true jobs: - deploy: + build: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: fetch-depth: 0 + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: python-version: '3.10' - - name: Install MkDocs and Material Theme - run: | - python -m pip install --upgrade pip - pip install mkdocs-material + - name: Set up uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + with: + version: '0.9.29' + enable-cache: true + + - name: Install locked docs dependencies + run: uv sync --frozen --extra docs + + - name: Build documentation site + run: uv run mkdocs build --strict + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: - name: Deploy to GitHub Pages - run: mkdocs gh-deploy --force + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml new file mode 100644 index 0000000..9fe052f --- /dev/null +++ b/.github/workflows/quality-gate.yml @@ -0,0 +1,38 @@ +name: quality-gate + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + push: + branches: [main, develop] + pull_request: + +permissions: + contents: read + +jobs: + quality-gate: + name: quality-gate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.10' + + - name: Setup uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + with: + version: '0.11.3' + + - name: Install package + run: uv sync --locked --extra dev + + - name: Run quality gate + env: + PYTHONWARNINGS: error + run: uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a8b734c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: release + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + attestations: write + id-token: write + +jobs: + release: + name: release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.10' + + - name: Setup uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + with: + version: '0.11.3' + + - name: Build artifacts + run: uv build + + - name: Generate checksums and manifest + run: | + sha256sum dist/* > dist/SHA256SUMS.txt + python scripts/release/build_release_manifest.py dist dist/release-manifest.json + + - name: Upload release artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-artifacts + path: dist/* + + - name: Attest build provenance + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be + with: + subject-path: 'dist/*' + + - name: Publish GitHub release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release upload "${GITHUB_REF_NAME}" dist/* --clobber + else + gh release create "${GITHUB_REF_NAME}" dist/* --generate-notes + fi diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 9029c0e..14501bd 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -1,10 +1,12 @@ name: scorecards +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: push: - branches: [main, develop] + branches: [develop] pull_request: - branches: [main, develop] schedule: - cron: '31 5 * * 1' @@ -21,12 +23,12 @@ jobs: actions: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: persist-credentials: false - name: Run analysis - uses: ossf/scorecard-action@v2.4.0 + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 with: results_file: results.sarif results_format: sarif @@ -34,6 +36,6 @@ jobs: - name: Upload SARIF results if: github.event_name != 'pull_request' - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 with: sarif_file: results.sarif diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a98541b..3ff5c22 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,10 +1,12 @@ name: tests +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: push: branches: [main, develop] pull_request: - branches: [main, develop] permissions: contents: read @@ -14,19 +16,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: python-version: '3.10' + - name: Setup uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + - name: Install package - run: | - python -m pip install --upgrade pip - pip install -e .[dev] + run: uv sync --locked --extra dev - name: Run tests with warnings as errors env: PYTHONWARNINGS: error - run: pytest + run: uv run pytest diff --git a/.gitignore b/.gitignore index f351756..f7b6ddb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.egg-info/ .pytest_cache/ +.coverage .venv/ dist/ build/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..15098c1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- MinerU-backed DOM parsing API for scanned Japanese newspaper PDFs +- Synthetic newspaper fixture generation and structural equivalence checks +- Protected-branch CI, security gates, release provenance workflow, and Git Flow documentation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3921c39..15b1c3e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,13 +5,13 @@ ```bash python3.10 -m venv .venv source .venv/bin/activate -pip install -e .[dev] +pip install -e ".[dev]" ``` Install the parser stack only when you need live MinerU execution: ```bash -pip install -e .[parser] +pip install "mineru[pipeline]==3.0.9" ``` ## Test commands @@ -19,8 +19,24 @@ pip install -e .[parser] ```bash pytest PYTHONWARNINGS=error pytest +pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100 ``` +CI installs dependencies from `uv.lock`, and workflow actions are pinned by immutable commit SHA. Keep both policies intact when editing `.github/` automation. + +CircleCI parity is defined in `.circleci/config.yml` and mirrors the same uv-locked warnings-as-errors and 100% coverage quality gate. + +## Documentation build + +The GitHub Pages workflow installs documentation tooling from `uv.lock` via the optional `docs` extra. For local maintainer work, sync all extras so the docs build does not drop the test toolchain from the active environment. + +```bash +uv sync --frozen --all-extras +uv run mkdocs build --strict +``` + +Tagged releases use `.github/workflows/release.yml` to build artifacts, generate SHA256 checksums, emit a JSON manifest, and publish a GitHub Release with provenance attestation. + ## Fixture policy This project intentionally separates public test artifacts from private validation material. @@ -51,6 +67,7 @@ The source page must remain local. - `README.md`: user-facing overview and quickstart - `CONTRIBUTING.md`: maintainer workflow and safety rules +- `SECURITY.md`: vulnerability reporting and supported-branch policy - `docs/workflow/git-flow.md`: canonical branch workflow - `tests/fixtures/README.md`: fixture provenance and regeneration notes - `docs/plans/`: design and implementation planning notes diff --git a/README.md b/README.md index da769db..0399bac 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@ NewsDOM API parses scanned Japanese newspaper PDFs into DOM-like article trees. ```bash python3.10 -m venv .venv source .venv/bin/activate -pip install -e .[dev] +pip install -e ".[dev]" ``` -To enable real parsing with MinerU: +To enable real parsing with MinerU, install the MinerU CLI separately in the environment that will execute parsing: ```bash -pip install -e .[parser] +pip install "mineru[pipeline]==3.0.9" ``` ### Run @@ -44,6 +44,8 @@ curl -F "file=@sample.pdf" http://127.0.0.1:8000/parse pytest ``` +The repository also enforces a `quality-gate` workflow with 100% source coverage and docstring audit coverage. + ## Fixtures and provenance This repository ships only synthetic test fixtures and derived structural baselines. For fixture provenance and regeneration notes, see `tests/fixtures/README.md`. @@ -52,6 +54,11 @@ This repository ships only synthetic test fixtures and derived structural baseli Development setup, fixture handling rules, and local-only baseline maintenance are documented in `CONTRIBUTING.md`. +Security reporting guidance is documented in `SECURITY.md`. +Version tags trigger a GitHub-native release workflow that builds distribution artifacts, checksums, and provenance attestations. + +Project history is tracked in `CHANGELOG.md`. + Repository branch workflow is documented in `docs/workflow/git-flow.md`. ## Repository layout diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c203013 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# Security Policy + +## Reporting a vulnerability + +Please report a vulnerability privately by opening a GitHub Security Advisory draft for this repository or by contacting the maintainer through the repository owner profile. Do not open a public issue for an unpatched vulnerability. + +When reporting, include: + +- affected branch or commit +- reproduction steps +- impact assessment +- any proof-of-concept input or sanitized logs needed to reproduce safely + +Avoid sending secrets, production credentials, or copyrighted third-party source documents in reports. + +## Supported branches + +- `develop`: actively maintained integration branch +- `main`: stable release branch + +Security fixes should target the appropriate Git Flow branch and be back-merged when required by `docs/workflow/git-flow.md`. + +## Disclosure expectations + +- acknowledgement target: within 7 days +- triage/update target: within 30 days when a fix is feasible +- coordinated disclosure preferred after a fix or mitigation is available + +## Safe handling notes + +- use synthetic fixtures whenever possible +- keep private reference inputs out of the repository +- provide sanitized evidence that preserves reproducibility without exposing sensitive data diff --git a/docs/adr/0001-openssf-best-practices-badge.md b/docs/adr/0001-openssf-best-practices-badge.md new file mode 100644 index 0000000..658530e --- /dev/null +++ b/docs/adr/0001-openssf-best-practices-badge.md @@ -0,0 +1,36 @@ +# ADR-0001: OpenSSF Best Practices Badge Enrollment + +## Status + +Accepted + +## Context + +The repository already has branch protection, CI checks, CodeQL, OpenSSF Scorecard, Dependabot, a security policy, locked workflow dependencies, and a planned release pipeline. Scorecard still reports a best-practices gap because the OpenSSF Best Practices badge program has not been started. + +The current repository also has only one organization member and one repository collaborator, so external reviewer capacity is not yet in place. The first tagged release is not available yet because the current PR stack still needs external review before it can merge into protected branches. + +## Decision + +We will **defer** OpenSSF Best Practices badge enrollment until after: + +1. the current protected-branch PR stack is merged, +2. the first tagged release has been produced with release provenance, and +3. at least one external reviewer is available for normal protected-branch review flow. + +## Consequences + +### Positive + +- Keeps focus on finishing concrete repository hardening already underway. +- Avoids starting a badge questionnaire before the release and review processes are stable. +- Preserves a clear, auditable decision in the repository. + +### Negative + +- Scorecard will continue to report the best-practices gap until enrollment is revisited. + +## Follow-up + +- Revisit enrollment after issue #8 and issue #10 are resolved. +- If the repository still intends to pursue the badge at that time, assign an owner and complete the OpenSSF questionnaire. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..e62188d --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,5 @@ +# Architecture Decision Records + +| ADR | Title | Status | +| --- | ----- | ------ | +| [0001](0001-openssf-best-practices-badge.md) | OpenSSF Best Practices Badge Enrollment | Accepted | diff --git a/docs/plans/2026-04-08-quality-gate-design.md b/docs/plans/2026-04-08-quality-gate-design.md new file mode 100644 index 0000000..6ffd7c0 --- /dev/null +++ b/docs/plans/2026-04-08-quality-gate-design.md @@ -0,0 +1,33 @@ +# Quality Gate Design + +**Date:** 2026-04-08 + +## Goal + +Establish an enforced repository quality baseline for source documentation and line coverage, then bring the current codebase up to that baseline. + +## Evidence + +- The repository has no coverage tooling or thresholds in `pyproject.toml`. +- The test workflow runs plain `pytest` only. +- Source docstrings are effectively absent outside `__init__.py`. +- Current source footprint is small enough to raise standards immediately. + +## Decision + +Introduce a single `quality-gate` workflow and local test command that enforce: + +- 100% line coverage for `src/newsdom_api` +- 100% docstring coverage for source modules, classes, and functions + +## Scope + +- Add coverage dependencies and config. +- Add a docstring audit test. +- Backfill docstrings for all source modules, classes, and functions. +- Add focused tests for uncovered modules and branches. +- Add a required `quality-gate` workflow on `main` and `develop`. + +## Rationale + +Security and branch workflows are already in place, but they do not stop shallow or undocumented code from merging. A strict quality gate closes the highest remaining merge-risk gap with a small-codebase-friendly change. diff --git a/docs/plans/2026-04-08-quality-gate.md b/docs/plans/2026-04-08-quality-gate.md new file mode 100644 index 0000000..6122e8e --- /dev/null +++ b/docs/plans/2026-04-08-quality-gate.md @@ -0,0 +1,66 @@ +# Quality Gate Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a repository quality gate for 100% line coverage and 100% source docstring coverage, then make the codebase satisfy it. + +**Architecture:** Use `pytest-cov` for line coverage and a repo-local AST-based docstring audit test for source modules. Backfill tests and docstrings until both local execution and CI can enforce the baseline. + +**Tech Stack:** Python 3.10, pytest, pytest-cov, GitHub Actions. + +--- + +## Task 1: Add failing docstring audit test + +**Files:** +- Create: `tests/test_docstrings.py` + +**Step 1: Write the failing test** +- Audit every source module/class/function in `src/newsdom_api`. +- Fail if any object lacks a docstring. + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_docstrings.py -v` + +**Step 3: Add minimal code changes** +- Backfill docstrings. + +## Task 2: Add coverage tooling and failing coverage measurement + +**Files:** +- Modify: `pyproject.toml` + +**Step 1: Add `pytest-cov` to dev dependencies** + +**Step 2: Run coverage command to measure current gap** + +Run: `pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100` + +Expected: FAIL before additional tests are written. + +## Task 3: Backfill tests to 100% line coverage + +**Files:** +- Create/modify targeted tests under `tests/` + +**Step 1: Add tests for uncovered branches in** +- `mineru_runner.py` +- `service.py` +- `synthetic.py` +- `equivalence.py` +- `dom_builder.py` + +**Step 2: Re-run full suite with 100% threshold** + +## Task 4: Add CI workflow and required check + +**Files:** +- Create: `.github/workflows/quality-gate.yml` +- Modify: branch protection required checks + +**Step 1: Add workflow job named `quality-gate`** + +**Step 2: Verify YAML, tests, and coverage locally** + +**Step 3: Update branch protection to require `quality-gate` on `main` and `develop`** diff --git a/manual/api-reference.md b/manual/api-reference.md index 9fb297e..aa0b9ce 100644 --- a/manual/api-reference.md +++ b/manual/api-reference.md @@ -43,7 +43,7 @@ curl -X 'POST' \ -F 'file=@sample.pdf;type=application/pdf' ``` -#### Python 클라이언트 예제 (`httpx` 또는 `requests`) +#### Python 클라이언트 예제 (requests) 프론트엔드나 타 백엔드 서버에서 NewsDOM API를 호출하는 일반적인 패턴입니다. @@ -94,11 +94,21 @@ else: "images": [ { "path": "extracted_images/page1_img1.jpg", - "bbox": { ... }, + "bbox": { + "x0": 120.0, + "y0": 320.0, + "x1": 420.0, + "y1": 560.0 + }, "captions": [ { "text": "사진 캡션 내용입니다. (image_caption 블록 연결)", - "bbox": { ... } + "bbox": { + "x0": 120.0, + "y0": 565.0, + "x1": 420.0, + "y1": 620.0 + } } ] } @@ -146,4 +156,4 @@ curl http://127.0.0.1:8000/health { "status": "ok" } -``` \ No newline at end of file +``` diff --git a/manual/development.md b/manual/development.md index ae91ae9..f43c468 100644 --- a/manual/development.md +++ b/manual/development.md @@ -67,9 +67,9 @@ python tools/derive_private_baseline.py tests/fixtures/private_page_baseline.jso - `schemas.py`: Pydantic 기반 DOM 데이터 직렬화 모델 (`PageNode`, `ArticleNode` 등) - `service.py`: 비즈니스 로직. PDF 업로드를 받고 파서를 거쳐 DOM 빌더로 연결 - `mineru_runner.py`: Python `subprocess`로 외부 `mineru` CLI 엔진 파이프라인 구동 및 JSON 로딩 - - `dom_builder.py`: MinerU의 선형 OCR 데이터를 트기 구조의 DOM으로 재구성 + - `dom_builder.py`: MinerU의 선형 OCR 데이터를 트리 구조의 DOM으로 재구성 - `synthetic.py`: 합성 더미 PDF 및 픽스처 생성 엔진 - **`tests/`**: 단위/통합 테스트 코드와 커밋이 허용된 합성 픽스처(`fixtures/`) 보관소 - **`tools/`**: `derive_private_baseline.py` 등 개발자용 로컬 스크립트 모음 - **`docs/plans/`**: 설계(Design) 문서 및 구현 일지, 백로그 -- **`docs/workflow/`**: Git 브랜치 전략 및 운영 정책 규정 \ No newline at end of file +- **`docs/workflow/`**: Git 브랜치 전략 및 운영 정책 규정 diff --git a/manual/installation.md b/manual/installation.md index 93500b8..501a666 100644 --- a/manual/installation.md +++ b/manual/installation.md @@ -4,7 +4,7 @@ ## 🛠️ 시스템 요구사항 -- **Python**: `>=3.10, <3.14` 필수 (`python3.10` 강력 권장) +- **Python**: Required: `>=3.10, <3.14` - **운영체제**: Linux 또는 macOS 권장 (윈도우의 경우 WSL2 사용 권장) - **하드웨어 (GPU)**: `MinerU` 딥러닝 기반 파이프라인을 구동하기 위해서는 최소 **8GB 이상의 RAM**이 필요하며, 실시간 처리를 위해 **NVIDIA GPU(CUDA 11.x/12.x 호환)** 및 `PyTorch` 환경이 권장됩니다. - **의존성 (Python)**: @@ -15,10 +15,10 @@ ## 1. 기본 테스트 및 개발 모드 설치 -가장 간단한 형태로 파이썬 가상환경(Virtual Environment)을 생성하고 패키지를 설치합니다. 이 모드에서는 실제 `MinerU` 모델이 로드되지 않으며, `pytest`나 합성 픽스처(Synthetic Fixtures) 기반 테스트 용도로 적합합니다. +가장 간단한 형태로 파이썬 가상환경(Virtual Environment)을 생성하고 패키지를 설치합니다. 이 모드에서는 실제 `MinerU` 모델이 로드되지 않으며, `pytest`나 합성 픽스처(Synthetic Fixtures) 기반 테스트 용도로 적합합니다. 예시 명령은 `python3.10`을 사용하지만, 지원 범위 안의 다른 인터프리터도 동일하게 사용할 수 있습니다. ```bash -# 가상 환경 생성 (파이썬 3.10 필수) +# 가상 환경 생성 (권장 예시: python3.10) python3.10 -m venv .venv # 가상 환경 활성화 @@ -31,18 +31,18 @@ source .venv/bin/activate python -m pip install --upgrade pip # 의존성 패키지와 함께 개발 모드로 설치 -pip install -e .[dev] +pip install -e ".[dev]" ``` --- ## 2. MinerU 백엔드 포함 실제 파싱 모드 설치 -`MinerU` 백엔드를 사용하여 실제 스캔된 일본어 신문 PDF 파싱 작업을 수행하려면 `[parser]` 선택 옵션을 추가로 설치해야 합니다. +`MinerU` 백엔드를 사용하여 실제 스캔된 일본어 신문 PDF 파싱 작업을 수행하려면 MinerU CLI를 별도로 설치해야 합니다. ```bash -# 파서 모듈까지 모두 포함하여 설치 -pip install -e .[parser] +# MinerU 파이프라인 CLI 설치 +pip install "mineru[pipeline]==3.0.9" ``` 이 명령어를 통해 **`mineru[pipeline]==3.0.9`** 버전이 설치되며 딥러닝 기반 모델을 위한 준비가 완료됩니다. 설치 후 처음 API 서버를 구동하고 PDF를 파싱할 때 모델(Weight) 파일을 백그라운드에서 다운로드할 수 있으므로, 첫 실행에는 다운로드 대기 시간이 발생할 수 있습니다. @@ -71,4 +71,4 @@ pytest -m "integration" 모든 단위 테스트(`tests/`)가 성공적으로 통과했다면 API 서버를 실행할 준비가 된 것입니다. -👉 다음 단계: **[API 레퍼런스 및 사용 방법](api-reference.md)** \ No newline at end of file +👉 다음 단계: **[API 레퍼런스 및 사용 방법](api-reference.md)** diff --git a/pyproject.toml b/pyproject.toml index a017b53..b49c30f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,10 +23,13 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.3,<9.0", - "httpx>=0.28,<1.0" + "pytest-cov>=5.0,<7.0", + "httpx>=0.28,<1.0", + "pyyaml>=6.0,<7.0", ] -parser = [ - "mineru[pipeline]==3.0.9" +docs = [ + "mkdocs>=1.6,<2.0", + "mkdocs-material>=9.6,<9.7", ] [tool.setuptools] @@ -40,3 +43,12 @@ testpaths = ["tests"] markers = [ "integration: requires MinerU CLI and model cache" ] + +[tool.coverage.run] +source = ["src/newsdom_api"] +branch = true + +[tool.coverage.report] +fail_under = 100 +show_missing = true +skip_covered = false diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..2ec200b --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Project-local automation helpers.""" diff --git a/scripts/release/__init__.py b/scripts/release/__init__.py new file mode 100644 index 0000000..5c59b09 --- /dev/null +++ b/scripts/release/__init__.py @@ -0,0 +1 @@ +"""Release helper scripts for build provenance and manifests.""" diff --git a/scripts/release/build_release_manifest.py b/scripts/release/build_release_manifest.py new file mode 100644 index 0000000..571b769 --- /dev/null +++ b/scripts/release/build_release_manifest.py @@ -0,0 +1,57 @@ +"""Build a release manifest for generated distribution artifacts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + + +def _sha256(path: Path) -> str: + """Return the SHA-256 digest for a file.""" + + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_manifest( + dist_dir: Path, *, exclude: set[Path] | None = None +) -> dict[str, object]: + """Build manifest metadata for all regular files in a dist directory.""" + + excluded = {path.resolve() for path in (exclude or set())} + artifacts = [] + for path in sorted( + (p for p in dist_dir.iterdir() if p.is_file()), key=lambda p: p.name + ): + if path.name == "release-manifest.json" or path.resolve() in excluded: + continue + artifacts.append( + { + "name": path.name, + "size": path.stat().st_size, + "sha256": _sha256(path), + } + ) + return {"dist_dir": str(dist_dir), "artifacts": artifacts} + + +def main() -> None: + """Write the release manifest JSON for a distribution directory.""" + + parser = argparse.ArgumentParser() + parser.add_argument("dist_dir", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + + output = args.output.resolve() + manifest = build_manifest(args.dist_dir, exclude={output}) + output.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/src/newsdom_api/dom_builder.py b/src/newsdom_api/dom_builder.py index 93e7976..ee74c67 100644 --- a/src/newsdom_api/dom_builder.py +++ b/src/newsdom_api/dom_builder.py @@ -1,3 +1,5 @@ +"""Build canonical NewsDOM page/article structures from parser output blocks.""" + from __future__ import annotations from itertools import count @@ -14,6 +16,8 @@ def _bbox_from_values(values: list[float] | None) -> BoundingBox | None: + """Convert a four-value bounding-box list into a typed schema object.""" + if not values or len(values) != 4: return None return BoundingBox( @@ -25,6 +29,8 @@ def _bbox_from_values(values: list[float] | None) -> BoundingBox | None: def build_dom(content_list: list[dict[str, Any]], document_id: str) -> ParseResponse: + """Normalize MinerU-style content blocks into the canonical NewsDOM schema.""" + page = PageNode(page_number=1) article_seq = count(1) current_article: ArticleNode | None = None diff --git a/src/newsdom_api/equivalence.py b/src/newsdom_api/equivalence.py index 551106b..832a603 100644 --- a/src/newsdom_api/equivalence.py +++ b/src/newsdom_api/equivalence.py @@ -1,3 +1,5 @@ +"""Compare synthetic fixture metrics against the committed structural baseline.""" + from __future__ import annotations import json @@ -6,12 +8,16 @@ def load_metrics(path: Path) -> dict[str, Any]: + """Load a JSON metrics file from disk using UTF-8 encoding.""" + return json.loads(path.read_text(encoding="utf-8")) def compare_fixture_to_baseline( truth_path: Path, baseline: dict[str, Any] ) -> dict[str, Any]: + """Compare a synthetic fixture metrics file against the committed baseline.""" + truth = load_metrics(truth_path) failures: list[str] = [] diff --git a/src/newsdom_api/main.py b/src/newsdom_api/main.py index 37b0125..d985a0c 100644 --- a/src/newsdom_api/main.py +++ b/src/newsdom_api/main.py @@ -1,5 +1,9 @@ +"""FastAPI entrypoints for the NewsDOM service.""" + from __future__ import annotations +from typing import Annotated + from fastapi import FastAPI, File, UploadFile from .schemas import ParseResponse @@ -10,9 +14,13 @@ @app.get("/health") def health() -> dict[str, str]: + """Return a minimal liveness response for health checks.""" + return {"status": "ok"} @app.post("/parse", response_model=ParseResponse) -async def parse(file: UploadFile = File(...)) -> ParseResponse: +async def parse(file: Annotated[UploadFile, File(...)]) -> ParseResponse: + """Parse an uploaded PDF into the canonical DOM response model.""" + return parse_pdf_bytes(await file.read(), filename=file.filename or "upload.pdf") diff --git a/src/newsdom_api/mineru_runner.py b/src/newsdom_api/mineru_runner.py index 55b8563..7490342 100644 --- a/src/newsdom_api/mineru_runner.py +++ b/src/newsdom_api/mineru_runner.py @@ -1,3 +1,5 @@ +"""Invoke MinerU as an external parser and collect its structured outputs.""" + from __future__ import annotations import json @@ -12,6 +14,8 @@ def build_mineru_command( input_pdf: Path, output_dir: Path, mineru_bin: str = "mineru" ) -> list[str]: + """Build the MinerU CLI command for OCR pipeline execution.""" + return [ mineru_bin, "-p", @@ -28,6 +32,8 @@ def build_mineru_command( def _resolve_mineru_bin() -> str: + """Resolve the MinerU executable path from env override or PATH.""" + configured = os.environ.get("NEWSDOM_MINERU_BIN") if configured: return configured @@ -35,6 +41,8 @@ def _resolve_mineru_bin() -> str: def _find_output_dir(base_output_dir: Path) -> Path: + """Locate the OCR output directory created by MinerU.""" + candidates = list(base_output_dir.glob("*/ocr")) if not candidates: raise FileNotFoundError(f"No MinerU OCR output found under {base_output_dir}") @@ -42,6 +50,8 @@ def _find_output_dir(base_output_dir: Path) -> Path: def run_mineru(input_pdf: Path) -> dict[str, Any]: + """Run MinerU on a PDF and return parsed JSON artifacts plus raw process output.""" + mineru_bin = _resolve_mineru_bin() with tempfile.TemporaryDirectory(prefix="newsdom-mineru-") as tempdir: output_dir = Path(tempdir) diff --git a/src/newsdom_api/schemas.py b/src/newsdom_api/schemas.py index 0abb020..4114436 100644 --- a/src/newsdom_api/schemas.py +++ b/src/newsdom_api/schemas.py @@ -1,3 +1,5 @@ +"""Canonical response schemas for NewsDOM parsing results.""" + from __future__ import annotations from typing import List, Optional @@ -6,6 +8,8 @@ class BoundingBox(BaseModel): + """Axis-aligned bounding box expressed in page coordinates.""" + x0: float y0: float x1: float @@ -13,17 +17,23 @@ class BoundingBox(BaseModel): class CaptionNode(BaseModel): + """Caption text associated with an image or figure.""" + text: str bbox: Optional[BoundingBox] = None class ImageNode(BaseModel): + """Image metadata preserved in the canonical page structure.""" + path: str bbox: Optional[BoundingBox] = None captions: List[CaptionNode] = Field(default_factory=list) class ArticleNode(BaseModel): + """Article-level grouping of headline, body blocks, and related media.""" + article_id: str headline: str bbox: Optional[BoundingBox] = None @@ -33,6 +43,8 @@ class ArticleNode(BaseModel): class PageNode(BaseModel): + """Single parsed page including article, ad, and header groupings.""" + page_number: int width: Optional[float] = None height: Optional[float] = None @@ -42,12 +54,16 @@ class PageNode(BaseModel): class ParseQuality(BaseModel): + """Quality metadata describing parser provenance and warnings.""" + status: str = "success" parser: str = "mineru" warnings: List[str] = Field(default_factory=list) class ParseResponse(BaseModel): + """Top-level API response for a parsed document.""" + document_id: str pages: List[PageNode] = Field(default_factory=list) quality: ParseQuality = Field(default_factory=ParseQuality) diff --git a/src/newsdom_api/service.py b/src/newsdom_api/service.py index c9cb0aa..c045ecc 100644 --- a/src/newsdom_api/service.py +++ b/src/newsdom_api/service.py @@ -1,3 +1,5 @@ +"""Service-layer orchestration for temporary-file parsing requests.""" + from __future__ import annotations import tempfile @@ -9,8 +11,11 @@ def parse_pdf_bytes(data: bytes, filename: str = "upload.pdf") -> ParseResponse: + """Persist uploaded PDF bytes temporarily and return the normalized parse result.""" + with tempfile.TemporaryDirectory(prefix="newsdom-upload-") as tempdir: - pdf_path = Path(tempdir) / filename + safe_name = Path(filename).name or "upload.pdf" + pdf_path = Path(tempdir) / safe_name pdf_path.write_bytes(data) mineru_output = run_mineru(pdf_path) response = build_dom(mineru_output["content_list"], document_id=pdf_path.stem) diff --git a/src/newsdom_api/synthetic.py b/src/newsdom_api/synthetic.py index 1b6a2f9..44128ea 100644 --- a/src/newsdom_api/synthetic.py +++ b/src/newsdom_api/synthetic.py @@ -1,8 +1,9 @@ +"""Synthetic newspaper fixture generation for redistributable repository tests.""" + from __future__ import annotations import json from pathlib import Path -from typing import Iterable from PIL import Image, ImageDraw, ImageFilter, ImageFont from reportlab.lib.utils import ImageReader @@ -14,6 +15,8 @@ def _font_candidates() -> list[str]: + """Return preferred macOS Japanese font candidates for fixture rendering.""" + return [ "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", "/System/Library/Fonts/ヒラギノ明朝 ProN.ttc", @@ -22,6 +25,8 @@ def _font_candidates() -> list[str]: def _load_font(size: int) -> ImageFont.FreeTypeFont: + """Load the first available Japanese-capable font at the requested size.""" + for candidate in _font_candidates(): if Path(candidate).exists(): return ImageFont.truetype(candidate, size=size) @@ -36,6 +41,8 @@ def _draw_vertical_text( font: ImageFont.ImageFont, line_height: int, ) -> None: + """Render a string as simple top-to-bottom vertical glyph placement.""" + cursor_y = y for char in text: draw.text((x, cursor_y), char, fill="black", font=font) @@ -43,6 +50,8 @@ def _draw_vertical_text( def _split_vertical(text: str, max_chars: int) -> list[str]: + """Split text into vertical columns constrained by the page height budget.""" + return [text[idx : idx + max_chars] for idx in range(0, len(text), max_chars)] @@ -52,6 +61,8 @@ def _draw_vertical_columns( text: str, font: ImageFont.ImageFont, ) -> None: + """Render multiple vertical columns of text inside the supplied bounding box.""" + x0, y0, x1, y1 = bbox font_size = int(getattr(font, "size", 24)) line_height = int(font_size * 1.1) @@ -69,6 +80,8 @@ def _draw_vertical_columns( def _article_block( headline: str, body: str, bbox: tuple[int, int, int, int], vertical: bool = True ) -> dict: + """Create one synthetic article descriptor for the fixture ground truth.""" + return { "headline": headline, "body": body, @@ -78,6 +91,8 @@ def _article_block( def _ground_truth() -> dict: + """Return the deterministic article/image/ad structure used for the fixture.""" + return { "page_size": [PAGE_WIDTH, PAGE_HEIGHT], "column_count": 4, @@ -121,6 +136,8 @@ def _ground_truth() -> dict: def generate_fixture(output_dir: Path, seed: int = 7) -> tuple[Path, Path]: + """Generate a synthetic scanned-newspaper PDF fixture and ground-truth JSON.""" + output_dir.mkdir(parents=True, exist_ok=True) image_path = output_dir / f"synthetic_newspaper_{seed}.png" pdf_path = output_dir / f"synthetic_newspaper_{seed}.pdf" diff --git a/tests/conftest.py b/tests/conftest.py index df150dc..8a23fa0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,5 +7,8 @@ ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) diff --git a/tests/test_adr_docs.py b/tests/test_adr_docs.py new file mode 100644 index 0000000..a4203c5 --- /dev/null +++ b/tests/test_adr_docs.py @@ -0,0 +1,12 @@ +from pathlib import Path + + +def test_best_practices_decision_adr_exists_and_documents_deferral(): + path = Path("docs/adr/0001-openssf-best-practices-badge.md") + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "Status" in text + assert "Accepted" in text + assert "defer" in text.lower() + assert "first tagged release" in text.lower() + assert "external reviewer" in text.lower() diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..e7c7e49 --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,9 @@ +from pathlib import Path + + +def test_changelog_exists_and_uses_keep_a_changelog_format(): + text = Path("CHANGELOG.md").read_text(encoding="utf-8") + assert text.startswith("# Changelog") + assert "Keep a Changelog" in text + assert "## [Unreleased]" in text + assert "Semantic Versioning" in text diff --git a/tests/test_circleci_config.py b/tests/test_circleci_config.py new file mode 100644 index 0000000..87abd9c --- /dev/null +++ b/tests/test_circleci_config.py @@ -0,0 +1,19 @@ +from pathlib import Path + + +def test_circleci_config_exists_and_uses_uv_quality_gate(): + text = Path(".circleci/config.yml").read_text(encoding="utf-8") + assert "version: 2.1" in text + assert "cimg/python:3.10" in text + assert "UV_UNMANAGED_INSTALL=1" in text + assert "UV_NO_MODIFY_PATH=1" in text + assert "https://astral.sh/uv/0.11.3/install.sh" in text + assert "curl -LsSf -o /tmp/uv-install.sh" in text + assert "sh /tmp/uv-install.sh" in text + assert "curl -LsSf https://astral.sh/uv/install.sh | sh" not in text + assert "uv sync --locked --extra dev" in text + assert "PYTHONWARNINGS=error uv run pytest" in text + assert ( + "uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100" + in text + ) diff --git a/tests/test_coderabbit_config.py b/tests/test_coderabbit_config.py new file mode 100644 index 0000000..f4dea57 --- /dev/null +++ b/tests/test_coderabbit_config.py @@ -0,0 +1,12 @@ +from pathlib import Path + +import yaml + + +def test_coderabbit_configuration_file_exists(): + assert Path(".coderabbit.yaml").exists() + + +def test_coderabbit_request_changes_workflow_is_enabled(): + data = yaml.safe_load(Path(".coderabbit.yaml").read_text(encoding="utf-8")) + assert data["reviews"]["request_changes_workflow"] is True diff --git a/tests/test_dependabot.py b/tests/test_dependabot.py new file mode 100644 index 0000000..0ac335e --- /dev/null +++ b/tests/test_dependabot.py @@ -0,0 +1,21 @@ +from pathlib import Path + +import yaml + + +def _dependabot_package_ecosystems() -> set[str]: + data = yaml.safe_load(Path(".github/dependabot.yml").read_text(encoding="utf-8")) + return { + update["package-ecosystem"] + for update in data.get("updates", []) + if "package-ecosystem" in update + } + + +def test_dependabot_configuration_exists_for_pip_and_actions(): + assert _dependabot_package_ecosystems() == {"github-actions", "pip"} + + +def test_dependabot_package_ecosystems_are_loaded_structurally(): + data = yaml.safe_load(Path(".github/dependabot.yml").read_text(encoding="utf-8")) + assert isinstance(data.get("updates"), list) diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py new file mode 100644 index 0000000..2d4b027 --- /dev/null +++ b/tests/test_docstrings.py @@ -0,0 +1,15 @@ +import ast +from pathlib import Path + + +def test_all_source_modules_have_docstrings(): + missing = [] + for path in sorted(Path("src/newsdom_api").rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + if not ast.get_docstring(tree): + missing.append(f"module:{path}") + for node in ast.walk(tree): + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + if not ast.get_docstring(node): + missing.append(f"{path}:{node.lineno}:{node.name}") + assert not missing, missing diff --git a/tests/test_dom_builder.py b/tests/test_dom_builder.py index d4208be..a09545a 100644 --- a/tests/test_dom_builder.py +++ b/tests/test_dom_builder.py @@ -1,7 +1,7 @@ import json from pathlib import Path -from newsdom_api.dom_builder import build_dom +from newsdom_api.dom_builder import _bbox_from_values, build_dom def test_build_dom_extracts_articles_from_mineru_sample(): @@ -12,3 +12,55 @@ def test_build_dom_extracts_articles_from_mineru_sample(): assert len(dom.pages) == 1 assert len(dom.pages[0].articles) >= 2 assert dom.pages[0].articles[0].headline == "次世代電池材料" + + +def test_bbox_helper_returns_none_for_invalid_values(): + assert _bbox_from_values(None) is None + assert _bbox_from_values([1, 2, 3]) is None + + +def test_build_dom_handles_non_headline_paths(): + dom = build_dom( + [ + { + "type": "text", + "text": "ignore me", + "bbox": [0, 0, 10, 10], + "role": "header", + }, + {"type": "ad", "text": "buy now", "bbox": [1, 1, 2, 2]}, + {"type": "text", "text": "", "bbox": [1, 1, 2, 2]}, + { + "type": "image", + "img_path": "img.png", + "bbox": [1, 1, 2, 2], + "image_caption": ["caption"], + }, + {"type": "table", "table_body": "
", "bbox": [1, 1, 2, 2]}, + {"type": "text", "text": "body text", "bbox": [1, 1, 2, 2]}, + ], + document_id="doc2", + ) + page = dom.pages[0] + assert page.headers == ["ignore me"] + assert page.ads == ["buy now"] + assert page.articles[0].headline == "(untitled)" + assert page.articles[0].images[0].captions[0].text == "caption" + assert "
" in page.articles[0].body_blocks + assert "body text" in page.articles[0].body_blocks + + +def test_build_dom_creates_table_article_when_needed(): + dom = build_dom( + [{"type": "table", "table_body": "
", "bbox": [1, 1, 2, 2]}], + document_id="doc3", + ) + assert dom.pages[0].articles[0].headline == "(table-block)" + + +def test_build_dom_creates_untitled_article_for_plain_text(): + dom = build_dom( + [{"type": "text", "text": "plain body", "bbox": [1, 1, 2, 2]}], + document_id="doc4", + ) + assert dom.pages[0].articles[0].headline == "(untitled)" diff --git a/tests/test_equivalence.py b/tests/test_equivalence.py new file mode 100644 index 0000000..7c93b4f --- /dev/null +++ b/tests/test_equivalence.py @@ -0,0 +1,45 @@ +import json +from pathlib import Path + +from newsdom_api.equivalence import compare_fixture_to_baseline, load_metrics + + +def test_load_metrics_reads_json(tmp_path: Path): + path = tmp_path / "metrics.json" + path.write_text(json.dumps({"column_count": 1}), encoding="utf-8") + assert load_metrics(path)["column_count"] == 1 + + +def test_compare_fixture_to_baseline_reports_failures(tmp_path: Path): + truth_path = tmp_path / "truth.json" + truth_path.write_text( + json.dumps( + { + "column_count": 10, + "article_count": 10, + "image_count": 10, + "ad_count": 10, + "headline_blocks": 10, + "vertical_article_ratio": 0.0, + } + ), + encoding="utf-8", + ) + baseline = { + "column_count": 1, + "article_count": 1, + "image_count": 1, + "ad_count": 1, + "headline_blocks": 1, + "vertical_article_ratio": 1.0, + } + result = compare_fixture_to_baseline(truth_path, baseline) + assert result["equivalent"] is False + assert set(result["failures"]) == { + "column_count", + "article_count", + "image_count", + "ad_count", + "headline_blocks", + "vertical_article_ratio", + } diff --git a/tests/test_manual_docs.py b/tests/test_manual_docs.py new file mode 100644 index 0000000..1171099 --- /dev/null +++ b/tests/test_manual_docs.py @@ -0,0 +1,33 @@ +import json +from pathlib import Path + + +def _extract_first_json_block(text: str) -> str: + marker = "```json\n" + start = text.index(marker) + len(marker) + end = text.index("\n```", start) + return text[start:end] + + +def test_api_reference_examples_are_consistent_and_valid_json(): + text = Path("manual/api-reference.md").read_text(encoding="utf-8") + assert "#### Python 클라이언트 예제 (requests)" in text + payload = json.loads(_extract_first_json_block(text)) + assert payload["pages"][0]["articles"][0]["images"][0]["bbox"]["x0"] == 120.0 + + +def test_development_doc_uses_tree_wording(): + text = Path("manual/development.md").read_text(encoding="utf-8") + assert "트리 구조의 DOM" in text + + +def test_installation_doc_uses_quoted_extras_and_clear_python_wording(): + text = Path("manual/installation.md").read_text(encoding="utf-8") + assert "Required: `>=3.10, <3.14`" in text + assert ( + "예시 명령은 `python3.10`을 사용하지만, 지원 범위 안의 다른 인터프리터도 동일하게 사용할 수 있습니다." + in text + ) + assert "python3.10 -m venv .venv" in text + assert 'pip install -e ".[dev]"' in text + assert 'pip install "mineru[pipeline]==3.0.9"' in text diff --git a/tests/test_mineru_runner_paths.py b/tests/test_mineru_runner_paths.py new file mode 100644 index 0000000..6e646f1 --- /dev/null +++ b/tests/test_mineru_runner_paths.py @@ -0,0 +1,176 @@ +import json +from pathlib import Path + +import pytest + +from newsdom_api import mineru_runner + + +class _FakeTempDir: + def __init__(self, path: Path): + self.path = path + + def __enter__(self): + self.path.mkdir(parents=True, exist_ok=True) + return str(self.path) + + def __exit__(self, exc_type, exc, tb): + return False + + +def test_resolve_mineru_bin_prefers_env(monkeypatch): + monkeypatch.setenv("NEWSDOM_MINERU_BIN", "/opt/mineru") + assert mineru_runner._resolve_mineru_bin() == "/opt/mineru" + + +def test_resolve_mineru_bin_falls_back_to_default_name(monkeypatch): + monkeypatch.delenv("NEWSDOM_MINERU_BIN", raising=False) + monkeypatch.setattr(mineru_runner.shutil, "which", lambda name: None) + assert mineru_runner._resolve_mineru_bin() == "mineru" + + +def test_find_output_dir_raises_when_missing(tmp_path: Path): + with pytest.raises(FileNotFoundError): + mineru_runner._find_output_dir(tmp_path) + + +def test_run_mineru_reads_generated_json(monkeypatch, tmp_path: Path): + tempdir = tmp_path / "temp" + ocr_dir = tempdir / "sample" / "ocr" + ocr_dir.mkdir(parents=True) + (ocr_dir / "alt_content_list.json").write_text( + json.dumps([{"type": "text", "text": "ok"}]), encoding="utf-8" + ) + (ocr_dir / "alt_model.json").write_text( + json.dumps([{"layout_dets": []}]), encoding="utf-8" + ) + + monkeypatch.setenv("NEWSDOM_MINERU_BIN", "/opt/mineru") + monkeypatch.setattr( + mineru_runner.tempfile, + "TemporaryDirectory", + lambda prefix: _FakeTempDir(tempdir), + ) + + called = {} + + def fake_run(cmd, check, capture_output, text): + assert check is True + assert capture_output is True + assert text is True + called["cmd"] = cmd + + class Result: + stdout = "stdout" + stderr = "stderr" + + return Result() + + monkeypatch.setattr(mineru_runner.subprocess, "run", fake_run) + + result = mineru_runner.run_mineru(Path("sample.pdf")) + assert called["cmd"][0] == "/opt/mineru" + assert result["content_list"][0]["text"] == "ok" + assert result["stderr"] == "stderr" + + +def test_run_mineru_prefers_exact_stem_content_json(monkeypatch, tmp_path: Path): + tempdir = tmp_path / "temp" + ocr_dir = tempdir / "sample" / "ocr" + ocr_dir.mkdir(parents=True) + (ocr_dir / "sample_content_list.json").write_text( + json.dumps([{"type": "text", "text": "exact"}]), encoding="utf-8" + ) + (ocr_dir / "alt_content_list.json").write_text( + json.dumps([{"type": "text", "text": "fallback"}]), encoding="utf-8" + ) + (ocr_dir / "alt_model.json").write_text( + json.dumps([{"layout_dets": []}]), encoding="utf-8" + ) + + monkeypatch.setenv("NEWSDOM_MINERU_BIN", "/opt/mineru") + monkeypatch.setattr( + mineru_runner.tempfile, + "TemporaryDirectory", + lambda prefix: _FakeTempDir(tempdir), + ) + + def fake_run(cmd, check, capture_output, text): + assert check is True + assert capture_output is True + assert text is True + + class Result: + stdout = "stdout" + stderr = "stderr" + + return Result() + + monkeypatch.setattr(mineru_runner.subprocess, "run", fake_run) + + result = mineru_runner.run_mineru(Path("sample.pdf")) + assert result["content_list"][0]["text"] == "exact" + + +def test_run_mineru_raises_when_content_json_missing(monkeypatch, tmp_path: Path): + tempdir = tmp_path / "temp" + ocr_dir = tempdir / "sample" / "ocr" + ocr_dir.mkdir(parents=True) + (ocr_dir / "alt_model.json").write_text( + json.dumps([{"layout_dets": []}]), encoding="utf-8" + ) + + monkeypatch.setenv("NEWSDOM_MINERU_BIN", "/opt/mineru") + monkeypatch.setattr( + mineru_runner.tempfile, + "TemporaryDirectory", + lambda prefix: _FakeTempDir(tempdir), + ) + + def fake_run(cmd, check, capture_output, text): + assert check is True + assert capture_output is True + assert text is True + + class Result: + stdout = "" + stderr = "" + + return Result() + + monkeypatch.setattr(mineru_runner.subprocess, "run", fake_run) + + with pytest.raises(FileNotFoundError): + mineru_runner.run_mineru(Path("sample.pdf")) + + +def test_run_mineru_raises_when_model_json_missing(monkeypatch, tmp_path: Path): + tempdir = tmp_path / "temp" + ocr_dir = tempdir / "sample" / "ocr" + ocr_dir.mkdir(parents=True) + (ocr_dir / "alt_content_list.json").write_text( + json.dumps([{"type": "text", "text": "ok"}]), encoding="utf-8" + ) + + monkeypatch.setenv("NEWSDOM_MINERU_BIN", "/opt/mineru") + monkeypatch.setattr( + mineru_runner.tempfile, + "TemporaryDirectory", + lambda prefix: _FakeTempDir(tempdir), + ) + + def fake_run(cmd, check, capture_output, text): + assert check is True + assert capture_output is True + assert text is True + + class Result: + stdout = "" + stderr = "" + + return Result() + + monkeypatch.setattr(mineru_runner.subprocess, "run", fake_run) + + with pytest.raises(FileNotFoundError): + mineru_runner.run_mineru(Path("sample.pdf")) diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py new file mode 100644 index 0000000..2ff240b --- /dev/null +++ b/tests/test_project_metadata.py @@ -0,0 +1,6 @@ +from pathlib import Path + + +def test_project_metadata_does_not_bundle_mineru_extra(): + text = Path("pyproject.toml").read_text(encoding="utf-8") + assert "mineru[pipeline]" not in text diff --git a/tests/test_readme.py b/tests/test_readme.py index 601ca0b..38d12ef 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -18,6 +18,16 @@ def test_contributing_mentions_develop_branch(): assert "develop" in text +def test_readme_quotes_dev_extra_install_command(): + text = Path("README.md").read_text(encoding="utf-8") + assert 'pip install -e ".[dev]"' in text + + +def test_contributing_quotes_dev_extra_install_command(): + text = Path("CONTRIBUTING.md").read_text(encoding="utf-8") + assert 'pip install -e ".[dev]"' in text + + def test_pull_request_template_exists(): assert Path(".github/pull_request_template.md").exists() @@ -26,3 +36,7 @@ def test_security_workflows_exist(): assert Path(".github/workflows/scorecards.yml").exists() assert Path(".github/workflows/codeql.yml").exists() assert Path(".github/workflows/dependency-review.yml").exists() + + +def test_quality_gate_workflow_exists(): + assert Path(".github/workflows/quality-gate.yml").exists() diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py new file mode 100644 index 0000000..21d406b --- /dev/null +++ b/tests/test_release_pipeline.py @@ -0,0 +1,67 @@ +import hashlib +import json +from pathlib import Path +import re + + +def test_release_workflow_exists(): + assert Path(".github/workflows/release.yml").exists() + + +def test_release_workflow_mentions_attestation_and_checksums(): + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + assert "uses: actions/attest-build-provenance@" in text + assert re.search(r"sha256sum dist/\* > dist/SHA256SUMS.txt", text) + + +def test_release_manifest_script_exists(): + assert Path("scripts/release/build_release_manifest.py").exists() + + +def test_release_manifest_script_outputs_json(tmp_path: Path): + from scripts.release.build_release_manifest import build_manifest + + dist = tmp_path / "dist" + dist.mkdir() + artifact = dist / "demo.txt" + artifact.write_text("demo", encoding="utf-8") + manifest_path = dist / "release-manifest.json" + manifest_path.write_text("{}", encoding="utf-8") + manifest = build_manifest(dist) + expected_sha = hashlib.sha256(artifact.read_bytes()).hexdigest() + assert len(manifest["artifacts"]) == 1 + assert manifest["artifacts"][0]["name"] == "demo.txt" + assert manifest["artifacts"][0]["size"] == artifact.stat().st_size + assert manifest["artifacts"][0]["sha256"] == expected_sha + assert all( + item["name"] != "release-manifest.json" for item in manifest["artifacts"] + ) + json.loads(json.dumps(manifest)) + + +def test_release_manifest_script_excludes_explicit_output_path(tmp_path: Path): + from scripts.release.build_release_manifest import build_manifest + + dist = tmp_path / "dist" + dist.mkdir() + artifact = dist / "demo.txt" + artifact.write_text("demo", encoding="utf-8") + output_path = dist / "custom-manifest.json" + output_path.write_text("{}", encoding="utf-8") + + manifest = build_manifest(dist, exclude={output_path}) + + assert [item["name"] for item in manifest["artifacts"]] == ["demo.txt"] + + +def test_release_workflow_publish_step_is_idempotent(): + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + assert 'gh release view "${GITHUB_REF_NAME}"' in text + assert 'gh release upload "${GITHUB_REF_NAME}" dist/* --clobber' in text + assert 'gh release create "${GITHUB_REF_NAME}" dist/* --generate-notes' in text + + +def test_release_workflow_pins_uv_version(): + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + assert "astral-sh/setup-uv@" in text + assert "version: '0.11.3'" in text diff --git a/tests/test_security_policy.py b/tests/test_security_policy.py new file mode 100644 index 0000000..92b88e9 --- /dev/null +++ b/tests/test_security_policy.py @@ -0,0 +1,17 @@ +from pathlib import Path + + +def test_security_policy_file_exists(): + assert Path("SECURITY.md").exists() + + +def test_security_policy_covers_reporting_and_supported_branches(): + text = Path("SECURITY.md").read_text(encoding="utf-8") + assert "report a vulnerability" in text.lower() + assert "develop" in text + assert "main" in text + + +def test_readme_and_contributing_link_security_policy(): + assert "SECURITY.md" in Path("README.md").read_text(encoding="utf-8") + assert "SECURITY.md" in Path("CONTRIBUTING.md").read_text(encoding="utf-8") diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..6fce968 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,58 @@ +from pathlib import Path + +from newsdom_api.schemas import ParseResponse +from newsdom_api.service import parse_pdf_bytes + + +def test_parse_pdf_bytes_writes_temp_file_and_builds_dom(monkeypatch): + observed = {} + + def fake_run_mineru(path: Path): + observed["path_name"] = path.name + observed["bytes"] = path.read_bytes() + return { + "content_list": [ + { + "type": "text", + "text": "headline", + "text_level": 1, + "bbox": [0, 0, 1, 1], + } + ] + } + + def fake_build_dom(content_list, document_id: str) -> ParseResponse: + observed["document_id"] = document_id + observed["content_list"] = content_list + return ParseResponse(document_id=document_id, pages=[]) + + monkeypatch.setattr("newsdom_api.service.run_mineru", fake_run_mineru) + monkeypatch.setattr("newsdom_api.service.build_dom", fake_build_dom) + + result = parse_pdf_bytes(b"pdf-bytes", filename="fixture.pdf") + assert observed["path_name"] == "fixture.pdf" + assert observed["bytes"] == b"pdf-bytes" + assert observed["document_id"] == "fixture" + assert result.document_id == "fixture" + + +def test_parse_pdf_bytes_sanitizes_client_filename(monkeypatch): + observed = {} + + def fake_run_mineru(path: Path): + observed["path_name"] = path.name + return { + "content_list": [ + { + "type": "text", + "text": "headline", + "text_level": 1, + "bbox": [0, 0, 1, 1], + } + ] + } + + monkeypatch.setattr("newsdom_api.service.run_mineru", fake_run_mineru) + result = parse_pdf_bytes(b"pdf-bytes", filename="../../nested/unsafe.pdf") + assert observed["path_name"] == "unsafe.pdf" + assert result.document_id == "unsafe" diff --git a/tests/test_synthetic_paths.py b/tests/test_synthetic_paths.py new file mode 100644 index 0000000..6449390 --- /dev/null +++ b/tests/test_synthetic_paths.py @@ -0,0 +1,64 @@ +from pathlib import Path + +from PIL import Image, ImageDraw + +from newsdom_api import synthetic + + +def test_load_font_falls_back_to_default(monkeypatch): + sentinel = object() + monkeypatch.setattr(synthetic, "_font_candidates", lambda: ["/does/not/exist.ttf"]) + monkeypatch.setattr(synthetic.Path, "exists", lambda self: False) + monkeypatch.setattr(synthetic.ImageFont, "load_default", lambda: sentinel) + font = synthetic._load_font(12) + assert font is sentinel + + +def test_load_font_uses_first_existing_candidate(monkeypatch): + sentinel = object() + called = {} + monkeypatch.setattr(synthetic, "_font_candidates", lambda: ["/pretend/font.ttf"]) + monkeypatch.setattr(synthetic.Path, "exists", lambda self: True) + + def fake_truetype(candidate, size): + called["candidate"] = candidate + called["size"] = size + return sentinel + + monkeypatch.setattr(synthetic.ImageFont, "truetype", fake_truetype) + assert synthetic._load_font(14) is sentinel + assert called == {"candidate": "/pretend/font.ttf", "size": 14} + + +def test_draw_vertical_columns_stops_when_width_exhausted(monkeypatch): + x_calls = [] + image = Image.new("L", (100, 100), color=255) + draw = ImageDraw.Draw(image) + monkeypatch.setattr(synthetic, "_split_vertical", lambda *_: ["A"] * 50) + monkeypatch.setattr( + synthetic, + "_draw_vertical_text", + lambda draw, text, x, y, font, line_height: x_calls.append(x), + ) + + class Font: + size = 24 + + synthetic._draw_vertical_columns(draw, (0, 0, 40, 120), "ABCDEFGHIJKL", Font()) + assert x_calls == [16] + + +def test_generate_fixture_supports_horizontal_article_branch( + monkeypatch, tmp_path: Path +): + original_ground_truth = synthetic._ground_truth + + def fake_truth(): + data = original_ground_truth() + data["articles"][0]["vertical"] = False + return data + + monkeypatch.setattr(synthetic, "_ground_truth", fake_truth) + pdf_path, truth_path = synthetic.generate_fixture(tmp_path, seed=9) + assert pdf_path.exists() + assert truth_path.exists() diff --git a/tests/test_workflow_runtime_env.py b/tests/test_workflow_runtime_env.py new file mode 100644 index 0000000..445aa9f --- /dev/null +++ b/tests/test_workflow_runtime_env.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +def test_javascript_actions_are_forced_to_node24(): + workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) + sorted( + Path(".github/workflows").glob("*.yaml") + ) + for workflow_path in workflow_paths: + text = workflow_path.read_text(encoding="utf-8") + assert "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true" in text, workflow_path diff --git a/tests/test_workflow_security.py b/tests/test_workflow_security.py new file mode 100644 index 0000000..1f3e6b1 --- /dev/null +++ b/tests/test_workflow_security.py @@ -0,0 +1,133 @@ +import re +from pathlib import Path + + +PINNED_ACTION_RE = re.compile(r"(?:-\s+)?uses:\s+[\w./-]+@[0-9a-f]{40}") + + +def _iter_workflow_paths(workflow_dir: Path | None = None) -> list[Path]: + workflow_dir = workflow_dir or Path(".github/workflows") + return sorted([*workflow_dir.glob("*.yml"), *workflow_dir.glob("*.yaml")]) + + +def _is_pinned_action_line(line: str) -> bool: + return bool(PINNED_ACTION_RE.fullmatch(line.split("#", 1)[0].rstrip())) + + +def _has_pull_request_branch_filter(text: str) -> bool: + in_pull_request = False + pull_request_indent = None + + for line in text.splitlines(): + stripped = line.strip() + indent = len(line) - len(line.lstrip()) + + if stripped.startswith("pull_request:"): + in_pull_request = True + pull_request_indent = indent + continue + + if in_pull_request: + if stripped and indent <= (pull_request_indent or 0): + in_pull_request = False + pull_request_indent = None + continue + + if stripped.startswith("branches:") or stripped.startswith( + "branches-ignore:" + ): + return True + + return False + + +def test_workflow_actions_are_pinned_by_sha(): + for workflow_path in _iter_workflow_paths(): + text = workflow_path.read_text(encoding="utf-8") + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("uses:") or stripped.startswith("- uses:"): + assert _is_pinned_action_line(stripped), ( + f"unpinned action in {workflow_path}: {stripped}" + ) + + +def test_ci_workflows_do_not_use_pip_install_commands(): + for workflow_name in ["tests.yml", "quality-gate.yml"]: + text = Path(f".github/workflows/{workflow_name}").read_text(encoding="utf-8") + assert not re.search(r"\b(?:python\s+-m\s+)?pip3?\s+install\b", text) + + +def test_ci_workflows_run_pytest_through_uv(): + tests_text = Path(".github/workflows/tests.yml").read_text(encoding="utf-8") + quality_text = Path(".github/workflows/quality-gate.yml").read_text( + encoding="utf-8" + ) + assert "uv run pytest" in tests_text + assert ( + "uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100" + in quality_text + ) + + +def test_uv_lock_exists_for_ci_reproducibility(): + assert Path("uv.lock").exists() + + +def test_coverage_config_enables_branch_coverage(): + text = Path("pyproject.toml").read_text(encoding="utf-8") + assert "branch = true" in text + + +def test_ci_workflows_run_for_all_pull_requests(): + for workflow_path in _iter_workflow_paths(): + text = workflow_path.read_text(encoding="utf-8") + assert not _has_pull_request_branch_filter(text), ( + f"pull_request branch filter blocks stacked PR checks in {workflow_path}" + ) + + +def test_docs_workflow_uses_least_privilege_pages_permissions(): + text = Path(".github/workflows/gh-pages.yml").read_text(encoding="utf-8") + assert "contents: write" not in text + assert "pages: write" in text + assert "id-token: write" in text + + +def test_docs_workflow_installs_docs_tooling_from_locked_uv_dependencies(): + text = Path(".github/workflows/gh-pages.yml").read_text(encoding="utf-8") + assert not re.search(r"\b(?:python\s+-m\s+)?pip3?\s+install\b", text) + assert "astral-sh/setup-uv@" in text + assert "uv sync --frozen --extra docs" in text + assert "uv run mkdocs build --strict" in text + + +def test_docs_workflow_uses_pages_artifact_deploy_path(): + text = Path(".github/workflows/gh-pages.yml").read_text(encoding="utf-8") + assert "mkdocs gh-deploy" not in text + assert "actions/upload-pages-artifact@" in text + assert "actions/deploy-pages@" in text + + +def test_quality_gate_workflow_pins_uv_version(): + text = Path(".github/workflows/quality-gate.yml").read_text(encoding="utf-8") + assert "astral-sh/setup-uv@" in text + assert "version: '0.11.3'" in text + + +def test_iter_workflow_paths_includes_yaml_extension(tmp_path: Path): + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "alpha.yml").write_text("name: alpha\n", encoding="utf-8") + (workflow_dir / "beta.yaml").write_text("name: beta\n", encoding="utf-8") + + assert [path.name for path in _iter_workflow_paths(workflow_dir)] == [ + "alpha.yml", + "beta.yaml", + ] + + +def test_is_pinned_action_line_rejects_sha_only_in_comment(): + assert not _is_pinned_action_line( + "- uses: actions/checkout@v4 # 34e114876b0b11c390a56381ad16ebd13914f8d5" + ) diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..f0e444d --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,15 @@ +from pathlib import Path + + +def test_scorecards_push_runs_only_on_default_branch(): + text = Path(".github/workflows/scorecards.yml").read_text(encoding="utf-8") + push_section = text.split("pull_request:", 1)[0] + assert "branches: [develop]" in push_section + assert "branches: [main, develop]" not in push_section + + +def test_scorecards_pull_requests_cover_main_and_develop(): + text = Path(".github/workflows/scorecards.yml").read_text(encoding="utf-8") + assert "pull_request:" in text + pull_request_section = text.split("pull_request:", 1)[1].split("schedule:", 1)[0] + assert "branches:" not in pull_request_section diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..30257f1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1085 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.14" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" }, + { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, + { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "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", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "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", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.6.23" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/de/cc1d5139c2782b1a49e1ed1845b3298ed6076b9ba1c740ad7c952d8ffcf9/mkdocs_material-9.6.23.tar.gz", hash = "sha256:62ebc9cdbe90e1ae4f4e9b16a6aa5c69b93474c7b9e79ebc0b11b87f9f055e00", size = 4048130, upload-time = "2025-11-01T16:33:11.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/df/bc583e857174b0dc6df67d555123533f09e7e1ac0f3fae7693fb6840c0a3/mkdocs_material-9.6.23-py3-none-any.whl", hash = "sha256:3bf3f1d82d269f3a14ed6897bfc3a844cc05e1dc38045386691b91d7e6945332", size = 9210689, upload-time = "2025-11-01T16:33:08.196Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "newsdom-api" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "pypdf" }, + { name = "python-multipart" }, + { name = "reportlab" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pyyaml" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115,<1.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28,<1.0" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6,<2.0" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.6,<9.7" }, + { name = "pillow", specifier = ">=11.0,<13.0" }, + { name = "pydantic", specifier = ">=2.9,<3.0" }, + { name = "pypdf", specifier = ">=5.0,<7.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<9.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0,<7.0" }, + { name = "python-multipart", specifier = ">=0.0.9,<1.0" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0,<7.0" }, + { name = "reportlab", specifier = ">=4.2,<5.0" }, + { name = "uvicorn", specifier = ">=0.30,<1.0" }, +] +provides-extras = ["dev", "docs"] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, +] + +[[package]] +name = "pypdf" +version = "6.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837, upload-time = "2026-03-23T14:53:27.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744, upload-time = "2026-03-23T14:53:26.573Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/45/e23b5dc14ddb9918ae4a625379506b17b6f8fc56ca1d82db62462f59aea6/python_multipart-0.0.24.tar.gz", hash = "sha256:9574c97e1c026e00bc30340ef7c7d76739512ab4dfd428fec8c330fa6a5cc3c8", size = 37695, upload-time = "2026-04-05T20:49:13.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/73/89930efabd4da63cea44a3f438aeb753d600123570e6d6264e763617a9ce/python_multipart-0.0.24-py3-none-any.whl", hash = "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950", size = 24420, upload-time = "2026-04-05T20:49:12.555Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "reportlab" +version = "4.4.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/57/28bfbf0a775b618b6e4d854ef8dd3f5c8988e5d614d8898703502a35f61c/reportlab-4.4.10.tar.gz", hash = "sha256:5cbbb34ac3546039d0086deb2938cdec06b12da3cdb836e813258eb33cd28487", size = 3714962, upload-time = "2026-02-12T10:45:21.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/2e/e1798b8b248e1517e74c6cdf10dd6edd485044e7edf46b5f11ffcc5a0add/reportlab-4.4.10-py3-none-any.whl", hash = "sha256:5abc815746ae2bc44e7ff25db96814f921349ca814c992c7eac3c26029bf7c24", size = 1955400, upload-time = "2026-02-12T10:45:18.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] From d726acd390c55336f10ed11608b3a0006106a7d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 19:26:09 +0900 Subject: [PATCH 05/15] ci: keep Node24 forcing without tripping scorecard checks (#16) --- .github/workflows/codeql.yml | 5 ++--- .github/workflows/dependency-review.yml | 5 ++--- .github/workflows/gh-pages.yml | 7 ++++--- .github/workflows/quality-gate.yml | 5 ++--- .github/workflows/release.yml | 5 ++--- .github/workflows/scorecards.yml | 5 ++--- .github/workflows/tests.yml | 5 ++--- tests/test_workflow_runtime_env.py | 24 ++++++++++++++++++++---- 8 files changed, 36 insertions(+), 25 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1cd2fe4..c04ae78 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,8 +1,5 @@ name: codeql -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - on: push: branches: [main, develop] @@ -19,6 +16,8 @@ jobs: analyze: name: codeql (python) runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 9bda0b9..aecb4b4 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,8 +1,5 @@ name: dependency-review -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - on: pull_request: @@ -14,6 +11,8 @@ jobs: dependency-review: name: dependency-review runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index f77daf2..03c93b4 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -1,8 +1,5 @@ name: Deploy Web Manual to GitHub Pages -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - on: push: branches: @@ -27,6 +24,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 @@ -62,6 +61,8 @@ jobs: permissions: pages: write id-token: write + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 9fe052f..71daac1 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -1,8 +1,5 @@ name: quality-gate -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - on: push: branches: [main, develop] @@ -15,6 +12,8 @@ jobs: quality-gate: name: quality-gate runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8b734c..ffe34be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,5 @@ name: release -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - on: push: tags: @@ -18,6 +15,8 @@ jobs: release: name: release runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 14501bd..00f8dc6 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -1,8 +1,5 @@ name: scorecards -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - on: push: branches: [develop] @@ -21,6 +18,8 @@ jobs: id-token: write contents: read actions: read + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3ff5c22..134b4aa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,8 +1,5 @@ name: tests -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - on: push: branches: [main, develop] @@ -14,6 +11,8 @@ permissions: jobs: pytest: runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 diff --git a/tests/test_workflow_runtime_env.py b/tests/test_workflow_runtime_env.py index 445aa9f..4da7b24 100644 --- a/tests/test_workflow_runtime_env.py +++ b/tests/test_workflow_runtime_env.py @@ -1,10 +1,26 @@ from pathlib import Path +import yaml -def test_javascript_actions_are_forced_to_node24(): - workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) + sorted( + +def _workflow_paths() -> list[Path]: + return sorted(Path(".github/workflows").glob("*.yml")) + sorted( Path(".github/workflows").glob("*.yaml") ) - for workflow_path in workflow_paths: + + +def test_each_workflow_job_forces_javascript_actions_to_node24(): + for workflow_path in _workflow_paths(): + data = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + for job_name, job_data in data["jobs"].items(): + assert job_data["env"]["FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"] is True, ( + workflow_path, + job_name, + ) + + +def test_workflows_do_not_use_top_level_env_blocks(): + for workflow_path in _workflow_paths(): text = workflow_path.read_text(encoding="utf-8") - assert "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true" in text, workflow_path + assert not text.startswith("env:\n") + assert "\nenv:\n" not in text.split("jobs:", 1)[0], workflow_path From 6721ac4881aea8f19d85003f136ef64df99a62aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 19:41:56 +0900 Subject: [PATCH 06/15] ci: keep Node24 forcing without tripping scorecard checks (#17) --- .github/workflows/scorecards.yml | 6 ++++-- tests/test_workflow_runtime_env.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 00f8dc6..e2ecf9e 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -18,11 +18,11 @@ jobs: id-token: write contents: read actions: read - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true with: persist-credentials: false @@ -36,5 +36,7 @@ jobs: - name: Upload SARIF results if: github.event_name != 'pull_request' uses: github/codeql-action/upload-sarif@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true with: sarif_file: results.sarif diff --git a/tests/test_workflow_runtime_env.py b/tests/test_workflow_runtime_env.py index 4da7b24..f4b0587 100644 --- a/tests/test_workflow_runtime_env.py +++ b/tests/test_workflow_runtime_env.py @@ -13,6 +13,8 @@ def test_each_workflow_job_forces_javascript_actions_to_node24(): for workflow_path in _workflow_paths(): data = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) for job_name, job_data in data["jobs"].items(): + if workflow_path.name == "scorecards.yml": + continue assert job_data["env"]["FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"] is True, ( workflow_path, job_name, @@ -24,3 +26,22 @@ def test_workflows_do_not_use_top_level_env_blocks(): text = workflow_path.read_text(encoding="utf-8") assert not text.startswith("env:\n") assert "\nenv:\n" not in text.split("jobs:", 1)[0], workflow_path + + +def test_scorecards_workflow_keeps_node24_force_out_of_job_env(): + data = yaml.safe_load( + Path(".github/workflows/scorecards.yml").read_text(encoding="utf-8") + ) + scorecard_job = data["jobs"]["scorecard"] + steps_by_name = {step["name"]: step for step in scorecard_job["steps"]} + + assert "env" not in scorecard_job + assert ( + steps_by_name["Checkout"]["env"]["FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"] is True + ) + assert ( + steps_by_name["Upload SARIF results"]["env"][ + "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24" + ] + is True + ) From 860e851e4ceb6be9fe224f6b08ff2c8029225b83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 20:00:24 +0900 Subject: [PATCH 07/15] ci: scope workflow write permissions to the jobs that need them (#18) --- .github/workflows/codeql.yml | 4 +++- .github/workflows/release.yml | 8 +++++--- tests/test_release_pipeline.py | 9 +++++++++ tests/test_workflow_security.py | 8 ++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c04ae78..e2458c2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -10,12 +10,14 @@ on: permissions: actions: read contents: read - security-events: write jobs: analyze: name: codeql (python) runs-on: ubuntu-latest + permissions: + contents: read + security-events: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffe34be..04a15e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,14 +7,16 @@ on: workflow_dispatch: permissions: - contents: write - attestations: write - id-token: write + contents: read jobs: release: name: release runs-on: ubuntu-latest + permissions: + contents: write + attestations: write + id-token: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 21d406b..229b943 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -65,3 +65,12 @@ def test_release_workflow_pins_uv_version(): text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") assert "astral-sh/setup-uv@" in text assert "version: '0.11.3'" in text + + +def test_release_workflow_scopes_write_permissions_to_job_level(): + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + assert "contents: read" in text.split("jobs:", 1)[0] + assert "contents: write" not in text.split("jobs:", 1)[0] + assert "contents: write" in text.split("jobs:", 1)[1] + assert "attestations: write" in text.split("jobs:", 1)[1] + assert "id-token: write" in text.split("jobs:", 1)[1] diff --git a/tests/test_workflow_security.py b/tests/test_workflow_security.py index 1f3e6b1..a3c3125 100644 --- a/tests/test_workflow_security.py +++ b/tests/test_workflow_security.py @@ -94,6 +94,14 @@ def test_docs_workflow_uses_least_privilege_pages_permissions(): assert "id-token: write" in text +def test_codeql_workflow_scopes_security_events_write_to_job_level(): + text = Path(".github/workflows/codeql.yml").read_text(encoding="utf-8") + assert "actions: read" in text.split("jobs:", 1)[0] + assert "contents: read" in text.split("jobs:", 1)[0] + assert "security-events: write" not in text.split("jobs:", 1)[0] + assert "security-events: write" in text.split("jobs:", 1)[1] + + def test_docs_workflow_installs_docs_tooling_from_locked_uv_dependencies(): text = Path(".github/workflows/gh-pages.yml").read_text(encoding="utf-8") assert not re.search(r"\b(?:python\s+-m\s+)?pip3?\s+install\b", text) From ed8637df8d5ce983879488712d0f92676f7f4dd9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:17:45 +0000 Subject: [PATCH 08/15] chore(deps): bump the github-actions group with 9 updates (#15) Bumps the github-actions group with 9 updates: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `4.3.1` | `6.0.2` | | [github/codeql-action](https://github.com/github/codeql-action) | `3.35.1` | `4.35.1` | | [actions/setup-python](https://github.com/actions/setup-python) | `5.6.0` | `6.2.0` | | [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `6.8.0` | `8.0.0` | | [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) | `3.0.1` | `4.0.0` | | [actions/deploy-pages](https://github.com/actions/deploy-pages) | `4.0.5` | `5.0.0` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4.6.2` | `7.0.0` | | [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) | `2.4.0` | `4.1.0` | | [ossf/scorecard-action](https://github.com/ossf/scorecard-action) | `2.4.0` | `2.4.3` | Updates `actions/checkout` from 4.3.1 to 6.0.2 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd) Updates `github/codeql-action` from 3.35.1 to 4.35.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/5c8a8a642e79153f5d047b10ec1cba1d1cc65699...c10b8064de6f491fea524254123dbe5e09572f13) Updates `actions/setup-python` from 5.6.0 to 6.2.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/a26af69be951a213d495a4c3e4e4022e16d87065...a309ff8b426b58ec0e2a45f0f869d46889d02405) Updates `astral-sh/setup-uv` from 6.8.0 to 8.0.0 - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/d0cc045d04ccac9d8b7881df0226f9e82c39688e...cec208311dfd045dd5311c1add060b2062131d57) Updates `actions/upload-pages-artifact` from 3.0.1 to 4.0.0 - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/56afc609e74202658d3ffba0e8f6dda462b719fa...7b1f4a764d45c48632c6b24a0339c27f5614fb0b) Updates `actions/deploy-pages` from 4.0.5 to 5.0.0 - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e...cd2ce8fcbc39b97be8ca5fce6e763baed58fa128) Updates `actions/upload-artifact` from 4.6.2 to 7.0.0 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f) Updates `actions/attest-build-provenance` from 2.4.0 to 4.1.0 - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/e8998f949152b193b063cb0ec769d69d929409be...a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32) Updates `ossf/scorecard-action` from 2.4.0 to 2.4.3 - [Release notes](https://github.com/ossf/scorecard-action/releases) - [Changelog](https://github.com/ossf/scorecard-action/blob/main/RELEASE.md) - [Commits](https://github.com/ossf/scorecard-action/compare/62b2cac7ed8198b15735ed49ab1e5cf35480ba46...4eaacf0543bb3f2c246792bd56e8cdeffafb205a) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.2 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: github/codeql-action dependency-version: 4.35.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-python dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: astral-sh/setup-uv dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/upload-pages-artifact dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/deploy-pages dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/upload-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/attest-build-provenance dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: ossf/scorecard-action dependency-version: 2.4.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/dependency-review.yml | 2 +- .github/workflows/gh-pages.yml | 10 +++++----- .github/workflows/quality-gate.yml | 6 +++--- .github/workflows/release.yml | 10 +++++----- .github/workflows/scorecards.yml | 6 +++--- .github/workflows/tests.yml | 6 +++--- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e2458c2..b768e6b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,15 +22,15 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Initialize CodeQL - uses: github/codeql-action/init@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 with: languages: python - name: Autobuild - uses: github/codeql-action/autobuild@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 + uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 - name: Analyze - uses: github/codeql-action/analyze@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index aecb4b4..65b0012 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -15,7 +15,7 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Dependency review uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 03c93b4..c91befc 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -28,18 +28,18 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 persist-credentials: false - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: '3.10' - name: Set up uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 with: version: '0.9.29' enable-cache: true @@ -51,7 +51,7 @@ jobs: run: uv run mkdocs build --strict - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b with: path: site @@ -69,4 +69,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 71daac1..3043a14 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -16,15 +16,15 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: '3.10' - name: Setup uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 with: version: '0.11.3' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04a15e0..1ed2845 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,15 +21,15 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: '3.10' - name: Setup uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 with: version: '0.11.3' @@ -42,13 +42,13 @@ jobs: python scripts/release/build_release_manifest.py dist dist/release-manifest.json - name: Upload release artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: release-artifacts path: dist/* - name: Attest build provenance - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 with: subject-path: 'dist/*' diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index e2ecf9e..68362ee 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -20,14 +20,14 @@ jobs: actions: read steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true with: persist-credentials: false - name: Run analysis - uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a with: results_file: results.sarif results_format: sarif @@ -35,7 +35,7 @@ jobs: - name: Upload SARIF results if: github.event_name != 'pull_request' - uses: github/codeql-action/upload-sarif@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 134b4aa..66e6075 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,15 +15,15 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: '3.10' - name: Setup uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 - name: Install package run: uv sync --locked --extra dev From 86187bee2164e3d025b8dcd76714bb0618f5f50c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 20:44:21 +0900 Subject: [PATCH 09/15] chore(release): prepare initial 0.1.0 changelog metadata (#23) --- CHANGELOG.md | 5 +++++ tests/test_changelog.py | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15098c1..9f89abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0] - 2026-04-09 + ### Added - MinerU-backed DOM parsing API for scanned Japanese newspaper PDFs - Synthetic newspaper fixture generation and structural equivalence checks - Protected-branch CI, security gates, release provenance workflow, and Git Flow documentation + +[Unreleased]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/Seongho-Bae/newsdom-api/releases/tag/v0.1.0 diff --git a/tests/test_changelog.py b/tests/test_changelog.py index e7c7e49..1d6c650 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -7,3 +7,30 @@ def test_changelog_exists_and_uses_keep_a_changelog_format(): assert "Keep a Changelog" in text assert "## [Unreleased]" in text assert "Semantic Versioning" in text + + +def test_changelog_prepares_the_initial_0_1_0_release_entry(): + text = Path("CHANGELOG.md").read_text(encoding="utf-8") + assert "## [0.1.0] - 2026-04-09" in text + + +def test_initial_release_entry_keeps_added_section_and_links(): + text = Path("CHANGELOG.md").read_text(encoding="utf-8") + assert "## [0.1.0] - 2026-04-09\n\n### Added" in text + assert "- MinerU-backed DOM parsing API for scanned Japanese newspaper PDFs" in text + assert ( + "- Synthetic newspaper fixture generation and structural equivalence checks" + in text + ) + assert ( + "- Protected-branch CI, security gates, release provenance workflow, and Git Flow documentation" + in text + ) + assert ( + "[Unreleased]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.0...HEAD" + in text + ) + assert ( + "[0.1.0]: https://github.com/Seongho-Bae/newsdom-api/releases/tag/v0.1.0" + in text + ) From 5da1029d083830af15977852af2225e4248f2a6f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:03:49 +0000 Subject: [PATCH 10/15] chore(deps-dev): bump the python group with 3 updates (#14) * chore(deps-dev): bump the python group with 3 updates Updates the requirements on [pytest](https://github.com/pytest-dev/pytest), [pytest-cov](https://github.com/pytest-dev/pytest-cov) and [mkdocs-material](https://github.com/squidfunk/mkdocs-material) to permit the latest version. Updates `pytest` to 9.0.3 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.3.0...9.0.3) Updates `pytest-cov` to 7.1.0 - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v5.0.0...v7.1.0) Updates `mkdocs-material` to 9.7.6 - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.6.0...9.7.6) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:development dependency-group: python - dependency-name: pytest-cov dependency-version: 7.1.0 dependency-type: direct:development dependency-group: python - dependency-name: mkdocs-material dependency-version: 9.7.6 dependency-type: direct:development dependency-group: python ... Signed-off-by: dependabot[bot] * chore: keep docs theme below warning release --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Seongho Bae --- pyproject.toml | 4 ++-- tests/test_project_metadata.py | 5 +++++ uv.lock | 16 ++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b49c30f..a467609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,8 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=8.3,<9.0", - "pytest-cov>=5.0,<7.0", + "pytest>=8.3,<10.0", + "pytest-cov>=5.0,<8.0", "httpx>=0.28,<1.0", "pyyaml>=6.0,<7.0", ] diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 2ff240b..eb6bb15 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -4,3 +4,8 @@ def test_project_metadata_does_not_bundle_mineru_extra(): text = Path("pyproject.toml").read_text(encoding="utf-8") assert "mineru[pipeline]" not in text + + +def test_docs_theme_range_stays_below_warning_release(): + text = Path("pyproject.toml").read_text(encoding="utf-8") + assert '"mkdocs-material>=9.6,<9.7"' in text diff --git a/uv.lock b/uv.lock index 30257f1..8aa6313 100644 --- a/uv.lock +++ b/uv.lock @@ -545,8 +545,8 @@ requires-dist = [ { name = "pillow", specifier = ">=11.0,<13.0" }, { name = "pydantic", specifier = ">=2.9,<3.0" }, { name = "pypdf", specifier = ">=5.0,<7.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<9.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0,<7.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<10.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0,<8.0" }, { name = "python-multipart", specifier = ">=0.0.9,<1.0" }, { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0,<7.0" }, { name = "reportlab", specifier = ">=4.2,<5.0" }, @@ -813,7 +813,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -824,23 +824,23 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-cov" -version = "6.3.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] From da52912008ba7d9210f56eb7b87091fa410a7547 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 21:27:33 +0900 Subject: [PATCH 11/15] fix(actions): vendor Pages artifact upload on node24 (#24) * fix(actions): scope Node24 forcing away from Pages artifact upload * fix(actions): vendor Pages artifact upload on node24 --- .../actions/upload-pages-artifact/action.yml | 85 +++++++++++++++++++ .github/workflows/gh-pages.yml | 12 +-- tests/test_workflow_runtime_env.py | 31 ++++++- tests/test_workflow_security.py | 15 +++- 4 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 .github/actions/upload-pages-artifact/action.yml diff --git a/.github/actions/upload-pages-artifact/action.yml b/.github/actions/upload-pages-artifact/action.yml new file mode 100644 index 0000000..87da418 --- /dev/null +++ b/.github/actions/upload-pages-artifact/action.yml @@ -0,0 +1,85 @@ +name: "Upload GitHub Pages artifact" +description: "Prepare and upload the static site artifact for GitHub Pages deployment" + +inputs: + name: + description: "Artifact name" + required: false + default: "github-pages" + path: + description: "Path of the directory containing the static assets." + required: true + default: "_site/" + retention-days: + description: "Duration after which artifact will expire in days." + required: false + default: "1" + +outputs: + artifact_id: + description: "The ID of the artifact that was uploaded." + value: ${{ steps.upload-artifact.outputs.artifact-id }} + +runs: + using: composite + steps: + - name: Archive artifact + shell: sh + if: runner.os == 'Linux' + run: | + echo ::group::Archive artifact + tar \ + --dereference --hard-dereference \ + --directory "$INPUT_PATH" \ + -cvf "$RUNNER_TEMP/artifact.tar" \ + --exclude=.git \ + --exclude=.github \ + --exclude=".[^/]*" \ + . + echo ::endgroup:: + env: + INPUT_PATH: ${{ inputs.path }} + + - name: Archive artifact + shell: sh + if: runner.os == 'macOS' + run: | + echo ::group::Archive artifact + gtar \ + --dereference --hard-dereference \ + --directory "$INPUT_PATH" \ + -cvf "$RUNNER_TEMP/artifact.tar" \ + --exclude=.git \ + --exclude=.github \ + --exclude=".[^/]*" \ + . + echo ::endgroup:: + env: + INPUT_PATH: ${{ inputs.path }} + + - name: Archive artifact + shell: bash + if: runner.os == 'Windows' + run: | + echo ::group::Archive artifact + tar \ + --dereference --hard-dereference \ + --directory "$INPUT_PATH" \ + -cvf "$RUNNER_TEMP\artifact.tar" \ + --exclude=.git \ + --exclude=.github \ + --exclude=".[^/]*" \ + --force-local \ + "." + echo ::endgroup:: + env: + INPUT_PATH: ${{ inputs.path }} + + - name: Upload artifact + id: upload-artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f + with: + name: ${{ inputs.name }} + path: ${{ runner.temp }}/artifact.tar + retention-days: ${{ inputs.retention-days }} + if-no-files-found: error diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index c91befc..d079bf8 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -24,22 +24,26 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true with: fetch-depth: 0 persist-credentials: false - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true with: python-version: '3.10' - name: Set up uv uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true with: version: '0.9.29' enable-cache: true @@ -51,7 +55,7 @@ jobs: run: uv run mkdocs build --strict - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b + uses: ./.github/actions/upload-pages-artifact with: path: site @@ -61,8 +65,6 @@ jobs: permissions: pages: write id-token: write - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/tests/test_workflow_runtime_env.py b/tests/test_workflow_runtime_env.py index f4b0587..45e97b9 100644 --- a/tests/test_workflow_runtime_env.py +++ b/tests/test_workflow_runtime_env.py @@ -13,7 +13,7 @@ def test_each_workflow_job_forces_javascript_actions_to_node24(): for workflow_path in _workflow_paths(): data = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) for job_name, job_data in data["jobs"].items(): - if workflow_path.name == "scorecards.yml": + if workflow_path.name in {"scorecards.yml", "gh-pages.yml"}: continue assert job_data["env"]["FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"] is True, ( workflow_path, @@ -45,3 +45,32 @@ def test_scorecards_workflow_keeps_node24_force_out_of_job_env(): ] is True ) + + +def test_gh_pages_workflow_keeps_node24_force_off_upload_pages_artifact_step(): + data = yaml.safe_load( + Path(".github/workflows/gh-pages.yml").read_text(encoding="utf-8") + ) + build_job = data["jobs"]["build"] + deploy_job = data["jobs"]["deploy"] + build_steps_by_name = {step["name"]: step for step in build_job["steps"]} + + assert "env" not in build_job + assert "env" not in deploy_job + assert ( + build_steps_by_name["Checkout repository"]["env"][ + "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24" + ] + is True + ) + assert ( + build_steps_by_name["Set up Python"]["env"][ + "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24" + ] + is True + ) + assert ( + build_steps_by_name["Set up uv"]["env"]["FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"] + is True + ) + assert "env" not in build_steps_by_name["Upload GitHub Pages artifact"] diff --git a/tests/test_workflow_security.py b/tests/test_workflow_security.py index a3c3125..3cda70a 100644 --- a/tests/test_workflow_security.py +++ b/tests/test_workflow_security.py @@ -11,7 +11,10 @@ def _iter_workflow_paths(workflow_dir: Path | None = None) -> list[Path]: def _is_pinned_action_line(line: str) -> bool: - return bool(PINNED_ACTION_RE.fullmatch(line.split("#", 1)[0].rstrip())) + candidate = line.split("#", 1)[0].rstrip() + if candidate.startswith("uses: ./") or candidate.startswith("- uses: ./"): + return True + return bool(PINNED_ACTION_RE.fullmatch(candidate)) def _has_pull_request_branch_filter(text: str) -> bool: @@ -113,10 +116,18 @@ def test_docs_workflow_installs_docs_tooling_from_locked_uv_dependencies(): def test_docs_workflow_uses_pages_artifact_deploy_path(): text = Path(".github/workflows/gh-pages.yml").read_text(encoding="utf-8") assert "mkdocs gh-deploy" not in text - assert "actions/upload-pages-artifact@" in text + assert "./.github/actions/upload-pages-artifact" in text + assert "actions/upload-pages-artifact@" not in text assert "actions/deploy-pages@" in text +def test_local_pages_artifact_action_uses_node24_upload_artifact(): + text = Path(".github/actions/upload-pages-artifact/action.yml").read_text( + encoding="utf-8" + ) + assert "actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" in text + + def test_quality_gate_workflow_pins_uv_version(): text = Path(".github/workflows/quality-gate.yml").read_text(encoding="utf-8") assert "astral-sh/setup-uv@" in text From bf430a1a0d6901e72fe168500f2b33ab3edaaf5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 22:41:55 +0900 Subject: [PATCH 12/15] ci: close immediate in-repo OpenSSF Scorecard gaps (#26) --- .github/workflows/release.yml | 19 +++-- .github/workflows/scorecards.yml | 1 + CONTRIBUTING.md | 4 +- SECURITY.md | 7 +- .../release/export_release_attestations.py | 79 +++++++++++++++++++ tests/test_release_pipeline.py | 61 ++++++++++++++ tests/test_security_policy.py | 6 ++ tests/test_workflows.py | 5 ++ 8 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 scripts/release/export_release_attestations.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ed2845..2149ed6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,17 +41,24 @@ jobs: sha256sum dist/* > dist/SHA256SUMS.txt python scripts/release/build_release_manifest.py dist dist/release-manifest.json - - name: Upload release artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f - with: - name: release-artifacts - path: dist/* - - name: Attest build provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 with: subject-path: 'dist/*' + - name: Export release attestation bundles + env: + GH_TOKEN: ${{ github.token }} + run: python scripts/release/export_release_attestations.py dist "${GITHUB_REPOSITORY}" + + - name: Upload release artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f + with: + name: release-artifacts + path: | + dist/* + dist/*.intoto.jsonl + - name: Publish GitHub release if: startsWith(github.ref, 'refs/tags/') env: diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 68362ee..3bd229d 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -29,6 +29,7 @@ jobs: - name: Run analysis uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a with: + repo_token: ${{ secrets.SCORECARD_TOKEN || github.token }} results_file: results.sarif results_format: sarif publish_results: ${{ github.event_name != 'pull_request' }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15b1c3e..d03b896 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,9 @@ uv sync --frozen --all-extras uv run mkdocs build --strict ``` -Tagged releases use `.github/workflows/release.yml` to build artifacts, generate SHA256 checksums, emit a JSON manifest, and publish a GitHub Release with provenance attestation. +Tagged releases use `.github/workflows/release.yml` to build artifacts, generate SHA256 checksums, emit a JSON manifest, export `*.intoto.jsonl` provenance bundles, and publish a GitHub Release with provenance attestation. + +For full OpenSSF Scorecard branch-protection visibility against classic GitHub branch protection rules, set a repository secret named `SCORECARD_TOKEN` with the fine-grained administration-read scope recommended by the Scorecard Action documentation. Without that secret, Scorecard still runs but may report the Branch-Protection check as inconclusive. ## Fixture policy diff --git a/SECURITY.md b/SECURITY.md index c203013..468e209 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,12 @@ ## Reporting a vulnerability -Please report a vulnerability privately by opening a GitHub Security Advisory draft for this repository or by contacting the maintainer through the repository owner profile. Do not open a public issue for an unpatched vulnerability. +Please report a vulnerability privately by opening a GitHub Security Advisory draft for this repository: + +- https://github.com/Seongho-Bae/newsdom-api/security/advisories/new +- https://github.com/seonghobae + +Do not open a public issue for an unpatched vulnerability. When reporting, include: diff --git a/scripts/release/export_release_attestations.py b/scripts/release/export_release_attestations.py new file mode 100644 index 0000000..6767b59 --- /dev/null +++ b/scripts/release/export_release_attestations.py @@ -0,0 +1,79 @@ +"""Export GitHub attestation bundles as Scorecard-recognized release assets.""" + +from __future__ import annotations + +import argparse +import hashlib +import shutil +import subprocess +from pathlib import Path + + +def _bundle_candidates(working_dir: Path, digest: str) -> tuple[Path, Path]: + """Return possible attestation bundle filenames for a given digest.""" + + return ( + working_dir / f"sha256:{digest}.jsonl", + working_dir / f"sha256-{digest}.jsonl", + ) + + +def export_attestations( + dist_dir: Path, repo: str, *, working_dir: Path | None = None +) -> list[Path]: + """Download attestation bundles for release artifacts and rename them for Scorecard.""" + + working_dir = (working_dir or Path.cwd()).resolve() + exported: list[Path] = [] + + for artifact in sorted(dist_dir.iterdir()): + if not artifact.is_file(): + continue + if artifact.name in {"SHA256SUMS.txt", "release-manifest.json"}: + continue + if artifact.name.endswith(".intoto.jsonl"): + continue + + subprocess.run( + ["gh", "attestation", "download", str(artifact), "-R", repo], + check=True, + ) + + digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + bundle_path = next( + ( + candidate + for candidate in _bundle_candidates(working_dir, digest) + if candidate.exists() + ), + None, + ) + if bundle_path is None: + raise FileNotFoundError( + f"No attestation bundle downloaded for {artifact.name}" + ) + + output_path = dist_dir / f"{artifact.name}.intoto.jsonl" + if output_path.exists(): + output_path.unlink() + shutil.move(str(bundle_path), output_path) + exported.append(output_path) + + return exported + + +def main() -> None: + """Download and rename attestation bundles for the release dist directory.""" + + parser = argparse.ArgumentParser( + description="Export GitHub attestation bundles into dist/*.intoto.jsonl files" + ) + parser.add_argument("dist_dir", type=Path) + parser.add_argument("repo") + args = parser.parse_args() + + export_attestations(args.dist_dir, args.repo) + + +if __name__ == "__main__": + main() diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 229b943..15770ea 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -12,12 +12,20 @@ def test_release_workflow_mentions_attestation_and_checksums(): text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") assert "uses: actions/attest-build-provenance@" in text assert re.search(r"sha256sum dist/\* > dist/SHA256SUMS.txt", text) + assert ( + 'python scripts/release/export_release_attestations.py dist "${GITHUB_REPOSITORY}"' + in text + ) def test_release_manifest_script_exists(): assert Path("scripts/release/build_release_manifest.py").exists() +def test_release_attestation_export_script_exists(): + assert Path("scripts/release/export_release_attestations.py").exists() + + def test_release_manifest_script_outputs_json(tmp_path: Path): from scripts.release.build_release_manifest import build_manifest @@ -74,3 +82,56 @@ def test_release_workflow_scopes_write_permissions_to_job_level(): assert "contents: write" in text.split("jobs:", 1)[1] assert "attestations: write" in text.split("jobs:", 1)[1] assert "id-token: write" in text.split("jobs:", 1)[1] + + +def test_release_workflow_uploads_intoto_assets_with_release_artifacts(): + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + assert "dist/*.intoto.jsonl" in text + + +def test_release_workflow_exports_attestations_before_uploading_artifacts(): + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + assert text.index("Export release attestation bundles") < text.index( + "Upload release artifacts" + ) + + +def test_release_attestation_export_script_writes_named_intoto_files( + tmp_path: Path, monkeypatch +): + from scripts.release.export_release_attestations import export_attestations + + dist = tmp_path / "dist" + dist.mkdir() + artifact = dist / "demo.whl" + artifact.write_text("demo", encoding="utf-8") + + digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + downloaded = tmp_path / f"sha256:{digest}.jsonl" + downloaded.write_text('{"bundle": true}', encoding="utf-8") + + calls: list[list[str]] = [] + + def fake_run(cmd, check): + calls.append(cmd) + assert check is True + + monkeypatch.setattr( + "scripts.release.export_release_attestations.subprocess.run", fake_run + ) + + export_attestations(dist, "Seongho-Bae/newsdom-api", working_dir=tmp_path) + + assert calls == [ + [ + "gh", + "attestation", + "download", + str(artifact), + "-R", + "Seongho-Bae/newsdom-api", + ] + ] + assert (dist / "demo.whl.intoto.jsonl").read_text( + encoding="utf-8" + ) == '{"bundle": true}' diff --git a/tests/test_security_policy.py b/tests/test_security_policy.py index 92b88e9..401b8b9 100644 --- a/tests/test_security_policy.py +++ b/tests/test_security_policy.py @@ -12,6 +12,12 @@ def test_security_policy_covers_reporting_and_supported_branches(): assert "main" in text +def test_security_policy_includes_explicit_reporting_links(): + text = Path("SECURITY.md").read_text(encoding="utf-8") + assert "https://github.com/Seongho-Bae/newsdom-api/security/advisories/new" in text + assert "https://github.com/seonghobae" in text + + def test_readme_and_contributing_link_security_policy(): assert "SECURITY.md" in Path("README.md").read_text(encoding="utf-8") assert "SECURITY.md" in Path("CONTRIBUTING.md").read_text(encoding="utf-8") diff --git a/tests/test_workflows.py b/tests/test_workflows.py index f0e444d..bbb4d7e 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -13,3 +13,8 @@ def test_scorecards_pull_requests_cover_main_and_develop(): assert "pull_request:" in text pull_request_section = text.split("pull_request:", 1)[1].split("schedule:", 1)[0] assert "branches:" not in pull_request_section + + +def test_scorecards_workflow_supports_optional_repo_token_for_branch_protection(): + text = Path(".github/workflows/scorecards.yml").read_text(encoding="utf-8") + assert "repo_token: ${{ secrets.SCORECARD_TOKEN || github.token }}" in text From 25febfc87126287f26ee1cabb112ed336ed8c222 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 10 Apr 2026 03:31:27 +0900 Subject: [PATCH 13/15] ci: ship lean multi-arch images with optional NVIDIA publish (#27) * ci: ship lean multi-arch images with optional NVIDIA publish * test: make container workflow assertions structural --- .dockerignore | 10 +++ .github/workflows/container-image.yml | 102 ++++++++++++++++++++++++++ .github/workflows/gh-pages.yml | 3 + .github/workflows/tests.yml | 2 + Dockerfile | 38 ++++++++++ Dockerfile.nvidia | 49 +++++++++++++ README.md | 22 ++++++ tests/test_docker_delivery.py | 74 +++++++++++++++++++ tests/test_workflow_security.py | 14 ++++ 9 files changed, 314 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/container-image.yml create mode 100644 Dockerfile create mode 100644 Dockerfile.nvidia create mode 100644 tests/test_docker_delivery.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d3e2e91 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.venv +__pycache__ +.pytest_cache +.coverage +*.pyc +site +dist +build diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml new file mode 100644 index 0000000..528def2 --- /dev/null +++ b/.github/workflows/container-image.yml @@ -0,0 +1,102 @@ +name: container-image + +on: + pull_request: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + publish_nvidia: + description: Publish the linux/amd64 NVIDIA parsing image + required: false + default: 'false' + +permissions: + contents: read + +jobs: + image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + REGISTRY: ghcr.io + IMAGE_NAME: seongho-bae/newsdom-api + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and optionally push image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name != 'pull_request' }} + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + image-nvidia: + if: github.event_name == 'workflow_dispatch' && github.event.inputs.publish_nvidia == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + REGISTRY: ghcr.io + IMAGE_NAME: seongho-bae/newsdom-api-nvidia + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Extract NVIDIA image metadata + id: meta-nvidia + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=nvidia + + - name: Build and push NVIDIA image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: . + file: ./Dockerfile.nvidia + push: true + platforms: linux/amd64 + tags: ${{ steps.meta-nvidia.outputs.tags }} + labels: ${{ steps.meta-nvidia.outputs.labels }} diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index d079bf8..9fbf7e7 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -9,6 +9,9 @@ on: paths: - 'manual/**' - 'mkdocs.yml' + - 'pyproject.toml' + - 'uv.lock' + - '.github/actions/upload-pages-artifact/**' - '.github/workflows/gh-pages.yml' workflow_dispatch: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 66e6075..cd21495 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,8 @@ jobs: - name: Setup uv uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 + with: + version: '0.11.3' - name: Install package run: uv sync --locked --extra dev diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0098a97 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.12-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN python -m pip install --no-cache-dir "uv==0.11.3" + +COPY pyproject.toml uv.lock README.md ./ +COPY src/ src/ + +RUN uv sync --frozen --no-dev + +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + NEWSDOM_MINERU_BIN=mineru \ + PATH="/app/.venv/bin:${PATH}" + +WORKDIR /app + +RUN useradd --create-home --home-dir /home/newsdom --shell /usr/sbin/nologin newsdom + +COPY --from=builder /app /app + +RUN chown -R newsdom:newsdom /app + +USER newsdom + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=5 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()" + +CMD ["uvicorn", "newsdom_api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.nvidia b/Dockerfile.nvidia new file mode 100644 index 0000000..e65f7f2 --- /dev/null +++ b/Dockerfile.nvidia @@ -0,0 +1,49 @@ +FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04 AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3 python3-pip python3-venv ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +RUN python3 -m pip install --no-cache-dir "uv==0.11.3" + +COPY pyproject.toml uv.lock README.md ./ +COPY src/ src/ + +RUN uv sync --python python3 --frozen --no-dev && \ + uv pip install --python .venv/bin/python "mineru[pipeline]==3.0.9" + +FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04 AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + NEWSDOM_MINERU_BIN=mineru \ + NVIDIA_VISIBLE_DEVICES=all \ + NVIDIA_DRIVER_CAPABILITIES=compute,utility \ + PATH="/app/.venv/bin:${PATH}" + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3 ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --home-dir /home/newsdom --shell /usr/sbin/nologin newsdom + +COPY --from=builder /app /app + +RUN chown -R newsdom:newsdom /app + +USER newsdom + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=5 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()" + +CMD ["uvicorn", "newsdom_api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 0399bac..265ac7c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,28 @@ pip install "mineru[pipeline]==3.0.9" uvicorn newsdom_api.main:app --reload ``` +### Docker + +```bash +docker build -t newsdom-api . +docker run -p 8000:8000 newsdom-api +``` + +The default image exposes the REST API on port `8000` as a lean multi-arch service image. It is suitable for `linux/amd64` and `linux/arm64`, including Apple Silicon hosts running the API service inside Docker. + +The lean image is intentionally a REST API shell: `/health`, `/docs`, and OpenAPI endpoints are available immediately, while real `/parse` execution still requires a compatible MinerU runtime to be available inside the container image. + +For heavier parsing deployments, build the optional NVIDIA-oriented variant: + +```bash +docker build -f Dockerfile.nvidia -t newsdom-api:nvidia . +docker run --gpus all -p 8000:8000 newsdom-api:nvidia +``` + +`Dockerfile.nvidia` is intended for Linux/NVIDIA environments and is `linux/amd64`-only. Apple Silicon can run the lean API image, but Docker Desktop does not expose Apple GPU acceleration to Linux containers, so real GPU-accelerated parsing should stay on a native Apple Silicon path instead of the containerized runtime. + +The NVIDIA variant is `linux/amd64`-only and is meant for hosts that can provide the CUDA user-space/runtime stack required by MinerU. + ### Parse a PDF ```bash diff --git a/tests/test_docker_delivery.py b/tests/test_docker_delivery.py new file mode 100644 index 0000000..1413572 --- /dev/null +++ b/tests/test_docker_delivery.py @@ -0,0 +1,74 @@ +from pathlib import Path + +import yaml + + +def test_dockerfile_exists(): + assert Path("Dockerfile").exists() + + +def test_nvidia_dockerfile_exists(): + assert Path("Dockerfile.nvidia").exists() + + +def test_dockerignore_exists(): + assert Path(".dockerignore").exists() + + +def test_dockerfile_uses_project_metadata_and_src_layout(): + text = Path("Dockerfile").read_text(encoding="utf-8") + assert "pyproject.toml" in text + assert "uv.lock" in text + assert "src/" in text + + +def test_dockerfile_runs_uvicorn_with_healthcheck_and_external_mineru_path(): + text = Path("Dockerfile").read_text(encoding="utf-8") + assert "uvicorn" in text + assert "newsdom_api.main:app" in text + assert "NEWSDOM_MINERU_BIN" in text + assert "--host" in text + assert "0.0.0.0" in text + assert "8000" in text + assert "HEALTHCHECK" in text + assert "/health" in text + + +def test_nvidia_dockerfile_installs_mineru_pipeline_stack(): + text = Path("Dockerfile.nvidia").read_text(encoding="utf-8") + assert "mineru[pipeline]==3.0.9" in text + assert "NEWSDOM_MINERU_BIN" in text + + +def test_dockerignore_excludes_local_noise(): + text = Path(".dockerignore").read_text(encoding="utf-8") + for entry in [".git", ".venv", "__pycache__", ".pytest_cache", ".coverage"]: + assert entry in text + + +def test_readme_documents_docker_build_and_run(): + text = Path("README.md").read_text(encoding="utf-8") + assert "docker build -t newsdom-api" in text + assert "docker run -p 8000:8000 newsdom-api" in text + assert "Dockerfile.nvidia" in text + assert "Apple Silicon" in text + assert "NVIDIA" in text + + +def test_container_image_workflow_exists_for_ghcr_release(): + data = yaml.safe_load( + Path(".github/workflows/container-image.yml").read_text(encoding="utf-8") + ) + assert data["jobs"]["image"]["env"]["REGISTRY"] == "ghcr.io" + assert ( + data["jobs"]["image"]["steps"][4]["uses"] + == "docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8" + ) + assert ( + data["jobs"]["image"]["steps"][4]["with"]["platforms"] + == "linux/amd64,linux/arm64" + ) + assert ( + data["jobs"]["image-nvidia"]["steps"][4]["with"]["file"] + == "./Dockerfile.nvidia" + ) diff --git a/tests/test_workflow_security.py b/tests/test_workflow_security.py index 3cda70a..2fd8bc3 100644 --- a/tests/test_workflow_security.py +++ b/tests/test_workflow_security.py @@ -134,6 +134,20 @@ def test_quality_gate_workflow_pins_uv_version(): assert "version: '0.11.3'" in text +def test_tests_workflow_pins_uv_version(): + text = Path(".github/workflows/tests.yml").read_text(encoding="utf-8") + assert "astral-sh/setup-uv@" in text + assert "version: '0.11.3'" in text + + +def test_docs_workflow_paths_cover_lockfile_and_local_action_inputs(): + text = Path(".github/workflows/gh-pages.yml").read_text(encoding="utf-8") + push_section = text.split("workflow_dispatch:", 1)[0] + assert "- 'pyproject.toml'" in push_section + assert "- 'uv.lock'" in push_section + assert "- '.github/actions/upload-pages-artifact/**'" in push_section + + def test_iter_workflow_paths_includes_yaml_extension(tmp_path: Path): workflow_dir = tmp_path / ".github" / "workflows" workflow_dir.mkdir(parents=True) From 09d34723015a18e5b170a41846d2042ca4d6b8bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 10 Apr 2026 04:08:34 +0900 Subject: [PATCH 14/15] ci: add clusterfuzzlite smoke integration for dom normalization (#28) --- .clusterfuzzlite/Dockerfile | 4 ++ .clusterfuzzlite/build.sh | 20 ++++++ .clusterfuzzlite/project.yaml | 1 + .github/workflows/clusterfuzzlite.yml | 32 ++++++++++ README.md | 6 ++ .../dom_builder_fuzzer/mineru_sample.json | 6 ++ fuzzers/dom_builder_fuzzer.py | 62 +++++++++++++++++++ pyproject.toml | 2 +- tests/test_fuzzing_integration.py | 43 +++++++++++++ tests/test_project_metadata.py | 6 ++ 10 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 .clusterfuzzlite/Dockerfile create mode 100644 .clusterfuzzlite/build.sh create mode 100644 .clusterfuzzlite/project.yaml create mode 100644 .github/workflows/clusterfuzzlite.yml create mode 100644 fuzzers/corpus/dom_builder_fuzzer/mineru_sample.json create mode 100644 fuzzers/dom_builder_fuzzer.py create mode 100644 tests/test_fuzzing_integration.py diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile new file mode 100644 index 0000000..dcad367 --- /dev/null +++ b/.clusterfuzzlite/Dockerfile @@ -0,0 +1,4 @@ +FROM gcr.io/oss-fuzz-base/base-builder-python + +WORKDIR /src/newsdom-api +COPY . . diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh new file mode 100644 index 0000000..159f5af --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail + +cd "$SRC/newsdom-api" + +pip3 install . pyinstaller atheris + +for fuzzer in $(find fuzzers -name '*_fuzzer.py'); do + fuzzer_basename=$(basename -s .py "$fuzzer") + fuzzer_package="${fuzzer_basename}.pkg" + + pyinstaller --distpath "$OUT" --onefile --name "$fuzzer_package" "$fuzzer" + + cat >"$OUT/$fuzzer_basename" < list[dict[str, Any]]: + """Return a MinerU-like content list or an empty list.""" + + if not isinstance(candidate, list): + return [] + return [item for item in candidate if isinstance(item, dict)] + + +def exercise_dom_builder(raw_bytes: bytes) -> None: + """Exercise build_dom with bytes that may or may not decode into JSON blocks.""" + + try: + decoded = raw_bytes.decode("utf-8", errors="ignore") + candidate = json.loads(decoded) + except Exception: + return + build_dom(_coerce_content_list(candidate), document_id="fuzz") + + +def _run_smoke(seed_path: Path) -> None: + """Run one deterministic normalization pass from a known corpus seed.""" + + sample = json.loads(seed_path.read_text(encoding="utf-8")) + build_dom(_coerce_content_list(sample), document_id="smoke") + + +def main(argv: list[str] | None = None) -> int: + """Run either deterministic smoke mode or Atheris fuzz mode.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--smoke", type=Path) + args = parser.parse_args(argv) + + if args.smoke is not None: + _run_smoke(args.smoke) + return 0 + + import atheris + + def test_one_input(data: bytes) -> None: + exercise_dom_builder(data) + + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index a467609..931fccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "DOM-style parser API for scanned Japanese newspaper PDFs" readme = "README.md" requires-python = ">=3.10,<3.14" -license = {text = "MIT"} +license = "MIT" authors = [{name = "Seongho Bae"}] dependencies = [ "fastapi>=0.115,<1.0", diff --git a/tests/test_fuzzing_integration.py b/tests/test_fuzzing_integration.py new file mode 100644 index 0000000..5d0cf7b --- /dev/null +++ b/tests/test_fuzzing_integration.py @@ -0,0 +1,43 @@ +import subprocess +import sys +from pathlib import Path + + +def test_clusterfuzzlite_integration_files_exist(): + assert Path(".clusterfuzzlite/project.yaml").exists() + assert Path(".clusterfuzzlite/Dockerfile").exists() + assert Path(".clusterfuzzlite/build.sh").exists() + assert Path(".github/workflows/clusterfuzzlite.yml").exists() + assert Path("fuzzers/dom_builder_fuzzer.py").exists() + assert Path("fuzzers/corpus/dom_builder_fuzzer/mineru_sample.json").exists() + + +def test_clusterfuzzlite_workflow_runs_pinned_python_code_change_fuzzing(): + text = Path(".github/workflows/clusterfuzzlite.yml").read_text(encoding="utf-8") + assert ( + "google/clusterfuzzlite/actions/build_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c" + in text + ) + assert ( + "google/clusterfuzzlite/actions/run_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c" + in text + ) + assert "language: python" in text + assert "mode: code-change" in text + assert "fuzz-seconds: 300" in text + + +def test_dom_builder_fuzzer_smoke_mode_runs_without_cluster(): + completed = subprocess.run( + [ + sys.executable, + "fuzzers/dom_builder_fuzzer.py", + "--smoke", + "tests/fixtures/mineru_sample.json", + ], + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr + assert "Traceback" not in completed.stderr diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index eb6bb15..deab61b 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -9,3 +9,9 @@ def test_project_metadata_does_not_bundle_mineru_extra(): def test_docs_theme_range_stays_below_warning_release(): text = Path("pyproject.toml").read_text(encoding="utf-8") assert '"mkdocs-material>=9.6,<9.7"' in text + + +def test_project_uses_spdx_license_string_not_deprecated_table(): + text = Path("pyproject.toml").read_text(encoding="utf-8") + assert 'license = "MIT"' in text + assert 'license = {text = "MIT"}' not in text From a3edbce6b8224d427109fcadaf9f4b2d4dea5419 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 10 Apr 2026 05:02:56 +0900 Subject: [PATCH 15/15] chore: pin new Docker and fuzz dependencies by digest (#30) * chore: pin new Docker and fuzz dependencies by digest * chore: refresh lockfile for pinned docker and fuzz extras * fix: keep fuzzing branch lockfile CI-safe * fix: keep fuzzing branch lockfile CI-safe --- .clusterfuzzlite/Dockerfile | 8 +- .clusterfuzzlite/build.sh | 3 +- .github/workflows/clusterfuzzlite.yml | 1 + Dockerfile | 11 ++- Dockerfile.nvidia | 11 ++- pyproject.toml | 4 + tests/test_docker_delivery.py | 6 +- tests/test_fuzzing_integration.py | 14 ++++ tests/test_project_metadata.py | 11 ++- uv.lock | 108 +++++++++++++++++++++++++- 10 files changed, 166 insertions(+), 11 deletions(-) diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile index dcad367..b07dfc0 100644 --- a/.clusterfuzzlite/Dockerfile +++ b/.clusterfuzzlite/Dockerfile @@ -1,4 +1,10 @@ -FROM gcr.io/oss-fuzz-base/base-builder-python +ARG UV_IMAGE=ghcr.io/astral-sh/uv@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 + +FROM ${UV_IMAGE} AS uv-bin + +FROM gcr.io/oss-fuzz-base/base-builder-python@sha256:60e8ef87f2c0367254ff979a4dea61dad2684b001e3e666e2cc6fe992064dbbf WORKDIR /src/newsdom-api +COPY --from=uv-bin /uv /uvx /usr/local/bin/ COPY . . +COPY .clusterfuzzlite/build.sh /src/build.sh diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh index 159f5af..27a583a 100644 --- a/.clusterfuzzlite/build.sh +++ b/.clusterfuzzlite/build.sh @@ -3,7 +3,8 @@ set -euo pipefail cd "$SRC/newsdom-api" -pip3 install . pyinstaller atheris +uv sync --frozen --extra fuzz +export PATH="$SRC/newsdom-api/.venv/bin:$PATH" for fuzzer in $(find fuzzers -name '*_fuzzer.py'); do fuzzer_basename=$(basename -s .py "$fuzzer") diff --git a/.github/workflows/clusterfuzzlite.yml b/.github/workflows/clusterfuzzlite.yml index 2908ab4..8891a6b 100644 --- a/.github/workflows/clusterfuzzlite.yml +++ b/.github/workflows/clusterfuzzlite.yml @@ -21,6 +21,7 @@ jobs: with: language: python sanitizer: address + github-token: ${{ github.token }} - name: Run fuzzers uses: google/clusterfuzzlite/actions/run_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c diff --git a/Dockerfile b/Dockerfile index 0098a97..93779d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,9 @@ -FROM python:3.12-slim AS builder +ARG PYTHON_BASE=python:3.12-slim@sha256:5072b08ad74609c5329ab4085a96dfa873de565fb4751a4cfcd7dcc427661df0 +ARG UV_IMAGE=ghcr.io/astral-sh/uv@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 + +FROM ${UV_IMAGE} AS uv-bin + +FROM ${PYTHON_BASE} AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ @@ -6,14 +11,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -RUN python -m pip install --no-cache-dir "uv==0.11.3" +COPY --from=uv-bin /uv /uvx /bin/ COPY pyproject.toml uv.lock README.md ./ COPY src/ src/ RUN uv sync --frozen --no-dev -FROM python:3.12-slim AS runtime +FROM ${PYTHON_BASE} AS runtime ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ diff --git a/Dockerfile.nvidia b/Dockerfile.nvidia index e65f7f2..6668fa8 100644 --- a/Dockerfile.nvidia +++ b/Dockerfile.nvidia @@ -1,4 +1,9 @@ -FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04 AS builder +ARG NVIDIA_BASE=nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04@sha256:46cb48a4abfbc40c836fe57bc05a07101b6458fffc63bbdfd6a50db98c9358bd +ARG UV_IMAGE=ghcr.io/astral-sh/uv@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 + +FROM ${UV_IMAGE} AS uv-bin + +FROM --platform=linux/amd64 ${NVIDIA_BASE} AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ @@ -10,7 +15,7 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends python3 python3-pip python3-venv ca-certificates && \ rm -rf /var/lib/apt/lists/* -RUN python3 -m pip install --no-cache-dir "uv==0.11.3" +COPY --from=uv-bin /uv /uvx /usr/local/bin/ COPY pyproject.toml uv.lock README.md ./ COPY src/ src/ @@ -18,7 +23,7 @@ COPY src/ src/ RUN uv sync --python python3 --frozen --no-dev && \ uv pip install --python .venv/bin/python "mineru[pipeline]==3.0.9" -FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04 AS runtime +FROM --platform=linux/amd64 ${NVIDIA_BASE} AS runtime ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ diff --git a/pyproject.toml b/pyproject.toml index 931fccb..ecdb65f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,10 @@ docs = [ "mkdocs>=1.6,<2.0", "mkdocs-material>=9.6,<9.7", ] +fuzz = [ + "atheris==3.0.0 ; platform_system == 'Linux' and python_version >= '3.11'", + "pyinstaller==6.16.0", +] [tool.setuptools] package-dir = {"" = "src"} diff --git a/tests/test_docker_delivery.py b/tests/test_docker_delivery.py index 1413572..823abef 100644 --- a/tests/test_docker_delivery.py +++ b/tests/test_docker_delivery.py @@ -20,6 +20,8 @@ def test_dockerfile_uses_project_metadata_and_src_layout(): assert "pyproject.toml" in text assert "uv.lock" in text assert "src/" in text + assert "python:3.12-slim@sha256:" in text + assert "ghcr.io/astral-sh/uv@sha256:" in text def test_dockerfile_runs_uvicorn_with_healthcheck_and_external_mineru_path(): @@ -36,7 +38,9 @@ def test_dockerfile_runs_uvicorn_with_healthcheck_and_external_mineru_path(): def test_nvidia_dockerfile_installs_mineru_pipeline_stack(): text = Path("Dockerfile.nvidia").read_text(encoding="utf-8") - assert "mineru[pipeline]==3.0.9" in text + assert "nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04@sha256:" in text + assert "ghcr.io/astral-sh/uv@sha256:" in text + assert 'uv pip install --python .venv/bin/python "mineru[pipeline]==3.0.9"' in text assert "NEWSDOM_MINERU_BIN" in text diff --git a/tests/test_fuzzing_integration.py b/tests/test_fuzzing_integration.py index 5d0cf7b..48f0c8d 100644 --- a/tests/test_fuzzing_integration.py +++ b/tests/test_fuzzing_integration.py @@ -25,6 +25,20 @@ def test_clusterfuzzlite_workflow_runs_pinned_python_code_change_fuzzing(): assert "language: python" in text assert "mode: code-change" in text assert "fuzz-seconds: 300" in text + assert "github-token: ${{ github.token }}" in text + + +def test_clusterfuzzlite_dockerfile_places_build_script_at_src_root(): + text = Path(".clusterfuzzlite/Dockerfile").read_text(encoding="utf-8") + assert "gcr.io/oss-fuzz-base/base-builder-python@sha256:" in text + assert "ghcr.io/astral-sh/uv@sha256:" in text + assert "COPY .clusterfuzzlite/build.sh /src/build.sh" in text + + +def test_clusterfuzzlite_build_script_uses_locked_uv_fuzz_extra(): + text = Path(".clusterfuzzlite/build.sh").read_text(encoding="utf-8") + assert "uv sync --frozen --extra fuzz" in text + assert "pip3 install . pyinstaller atheris" not in text def test_dom_builder_fuzzer_smoke_mode_runs_without_cluster(): diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index deab61b..461ef4a 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -3,7 +3,8 @@ def test_project_metadata_does_not_bundle_mineru_extra(): text = Path("pyproject.toml").read_text(encoding="utf-8") - assert "mineru[pipeline]" not in text + dependencies_section = text.split("dependencies = [", 1)[1].split("]", 1)[0] + assert "mineru[pipeline]" not in dependencies_section def test_docs_theme_range_stays_below_warning_release(): @@ -15,3 +16,11 @@ def test_project_uses_spdx_license_string_not_deprecated_table(): text = Path("pyproject.toml").read_text(encoding="utf-8") assert 'license = "MIT"' in text assert 'license = {text = "MIT"}' not in text + + +def test_project_declares_locked_fuzz_extra_without_bundling_nvidia_stack(): + text = Path("pyproject.toml").read_text(encoding="utf-8") + assert "fuzz = [" in text + assert '"atheris==3.0.0 ;' in text + assert '"pyinstaller==6.16.0"' in text + assert "nvidia = [" not in text diff --git a/uv.lock b/uv.lock index 8aa6313..2df98ac 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -40,6 +49,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "atheris" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/58/5965955898e16bee17c8379eae12194993bf641c4629016991248b862069/atheris-3.0.0.tar.gz", hash = "sha256:1f0929c7bc3040f3fe4102e557718734190cf2d7718bbb8e3ce6d3eb56ef5bb3", size = 373239, upload-time = "2025-11-24T23:54:02.15Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/15/cf109e2e8696a54c8c4bc3ef79a79bec32361eceb64eaa36690a682e83a9/atheris-3.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8a5c8a781467c187da40fd29139784193e2647058831f837f675d0bb8cbd8746", size = 34805555, upload-time = "2025-11-24T23:53:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/85/8c/e9960b996e70e5f6a523670431166b2b238de52fef094955515dcf854da1/atheris-3.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:510e502c57b6dc615fb174066407af620d4c7f73cf08a782c86e7761bf12c4eb", size = 34907016, upload-time = "2025-11-24T23:53:56.535Z" }, + { url = "https://files.pythonhosted.org/packages/db/48/df670f75f458cc7c1752a01a394fd59c830b08172dd59cf29d73f31050f9/atheris-3.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a402cdca8a650d1371050b1f9552eb4cdc488d2db64950d603c4560318365eac", size = 34858525, upload-time = "2025-11-24T23:53:59.925Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -360,6 +380,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -535,15 +567,21 @@ docs = [ { name = "mkdocs" }, { name = "mkdocs-material" }, ] +fuzz = [ + { name = "atheris", marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "pyinstaller" }, +] [package.metadata] requires-dist = [ + { name = "atheris", marker = "python_full_version >= '3.11' and sys_platform == 'linux' and extra == 'fuzz'", specifier = "==3.0.0" }, { name = "fastapi", specifier = ">=0.115,<1.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28,<1.0" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6,<2.0" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.6,<9.7" }, { name = "pillow", specifier = ">=11.0,<13.0" }, { name = "pydantic", specifier = ">=2.9,<3.0" }, + { name = "pyinstaller", marker = "extra == 'fuzz'", specifier = "==6.16.0" }, { name = "pypdf", specifier = ">=5.0,<7.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<10.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0,<8.0" }, @@ -552,7 +590,7 @@ requires-dist = [ { name = "reportlab", specifier = ">=4.2,<5.0" }, { name = "uvicorn", specifier = ">=0.30,<1.0" }, ] -provides-extras = ["dev", "docs"] +provides-extras = ["dev", "docs", "fuzz"] [[package]] name = "packaging" @@ -581,6 +619,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "pefile" +version = "2023.2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854, upload-time = "2023-02-07T12:23:55.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791, upload-time = "2023-02-07T12:28:36.678Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -786,6 +833,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyinstaller" +version = "6.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/94/1f62e95e4a28b64cfbb5b922ef3046f968b47170d37a1e1a029f56ac9cb4/pyinstaller-6.16.0.tar.gz", hash = "sha256:53559fe1e041a234f2b4dcc3288ea8bdd57f7cad8a6644e422c27bb407f3edef", size = 4008473, upload-time = "2025-09-13T20:07:01.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0a/c42ce6e5d3de287f2e9432a074fb209f1fb72a86a72f3903849fdb5e4829/pyinstaller-6.16.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:7fd1c785219a87ca747c21fa92f561b0d2926a7edc06d0a0fe37f3736e00bd7a", size = 1027899, upload-time = "2025-09-13T20:05:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d0/f18fedde32835d5a758f464c75924e2154065625f09d5456c3c303527654/pyinstaller-6.16.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:b756ddb9007b8141c5476b553351f9d97559b8af5d07f9460869bfae02be26b0", size = 727990, upload-time = "2025-09-13T20:06:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/7a/db/c8bb47514ce857b24bf9294cf1ff74844b6a489fa0ab4ef6f923288c4e38/pyinstaller-6.16.0-py3-none-manylinux2014_i686.whl", hash = "sha256:0a48f55b85ff60f83169e10050f2759019cf1d06773ad1c4da3a411cd8751058", size = 739238, upload-time = "2025-09-13T20:06:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/451dc784a8fcca0fe9f9b6b802d58555364a95b60f253613a2c83fc6b023/pyinstaller-6.16.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:73ba72e04fcece92e32518bbb1e1fb5ac2892677943dfdff38e01a06e8742851", size = 737142, upload-time = "2025-09-13T20:06:11.732Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/2f457479ef8fa2821cdb448acee2421dfb19fbe908bf5499d1930c164084/pyinstaller-6.16.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b1752488248f7899281b17ca3238eefb5410521291371a686a4f5830f29f52b3", size = 734133, upload-time = "2025-09-13T20:06:15.477Z" }, + { url = "https://files.pythonhosted.org/packages/63/c4/0f7daac4d062a4d1ac2571d8a8b9b5d6812094fcd914d139af591ca5e1ba/pyinstaller-6.16.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ba618a61627ee674d6d68e5de084ba17c707b59a4f2a856084b3999bdffbd3f0", size = 733817, upload-time = "2025-09-13T20:06:19.683Z" }, + { url = "https://files.pythonhosted.org/packages/11/e4/b6127265b42bef883e8873d850becadf748bc5652e5a7029b059328f3c31/pyinstaller-6.16.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:c8b7ef536711617e12fef4673806198872033fa06fa92326ad7fd1d84a9fa454", size = 732912, upload-time = "2025-09-13T20:06:23.46Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/c6663107bdf814b2916e71563beabd09f693c47712213bc228994cb2cc65/pyinstaller-6.16.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d1ebf84d02c51fed19b82a8abb4df536923abd55bb684d694e1356e4ae2a0ce5", size = 732773, upload-time = "2025-09-13T20:06:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/a3/14/cabe9bc5f60b95d2e70e7d045ab94b0015ff8f6c8b16e2142d3597e30749/pyinstaller-6.16.0-py3-none-win32.whl", hash = "sha256:6d5f8617f3650ff9ef893e2ab4ddbf3c0d23d0c602ef74b5df8fbef4607840c8", size = 1313878, upload-time = "2025-09-13T20:06:33.234Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/2005efbc297e7813c1d6f18484aa94a1a81ce87b6a5b497c563681f4c4ea/pyinstaller-6.16.0-py3-none-win_amd64.whl", hash = "sha256:bc10eb1a787f99fea613509f55b902fbd2d8b73ff5f51ff245ea29a481d97d41", size = 1374706, upload-time = "2025-09-13T20:06:39.95Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f4/4dfcf69b86d60fcaae05a42bbff1616d48a91e71726e5ed795d773dae9b3/pyinstaller-6.16.0-py3-none-win_arm64.whl", hash = "sha256:d0af8a401de792c233c32c44b16d065ca9ab8262ee0c906835c12bdebc992a64", size = 1315923, upload-time = "2025-09-13T20:06:45.846Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/fe/9278c29394bf69169febc21f96b4252c3ee7c8ec22c2fc545004bed47e71/pyinstaller_hooks_contrib-2026.4.tar.gz", hash = "sha256:766c281acb1ecc32e21c8c667056d7ebf5da0aabd5e30c219f9c2a283620eeaa", size = 173050, upload-time = "2026-03-31T14:10:51.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/f4/035fb8c06deff827f540a9a4ed9122c54e5376fca3e42eddf0c263730775/pyinstaller_hooks_contrib-2026.4-py3-none-any.whl", hash = "sha256:1de1a5e49a878122010b88c7e295502bc69776c157c4a4dc78741a4e6178b00f", size = 455496, upload-time = "2026-03-31T14:10:49.867Z" }, +] + [[package]] name = "pymdown-extensions" version = "10.21.2" @@ -864,6 +952,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/73/89930efabd4da63cea44a3f438aeb753d600123570e6d6264e763617a9ce/python_multipart-0.0.24-py3-none-any.whl", hash = "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950", size = 24420, upload-time = "2026-04-05T20:49:12.555Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -950,6 +1047,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "six" version = "1.17.0"