From c69efca189f68aa8e92246e8145716d55bde7663 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 9 Apr 2026 08:47:45 +0900 Subject: [PATCH 01/21] 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 00000000..93f24de1 --- /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 00000000..b9c6f9f7 --- /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 00000000..2d0163c7 --- /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 00000000..b8c74e34 --- /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 00000000..dd4aa15a --- /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 00000000..893ea23c --- /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/21] 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 00000000..bb63c8ce --- /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 b9c6f9f7..d9cc8299 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 2d0163c7..47c9ada1 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 b8c74e34..b94965d1 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 dd4aa15a..00000000 --- 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 893ea23c..8ab9d97d 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/21] 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 bb63c8ce..9fb297e7 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 d9cc8299..ae91ae9d 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 47c9ada1..b808afa1 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 b94965d1..93500b81 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/21] 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 00000000..70c42de8 --- /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 00000000..1d45d057 --- /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 00000000..2b3e2cbc --- /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 7593d1da..1cd2fe41 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 27402f16..9bda0b9c 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 93f24de1..f77daf2f 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 00000000..9fe052ff --- /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 00000000..a8b734c4 --- /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 9029c0ea..14501bde 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 a98541b3..3ff5c228 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 f3517564..f7b6ddba 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 00000000..15098c15 --- /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 3921c39a..15b1c3ef 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 da769db7..0399baca 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 00000000..c2030132 --- /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 00000000..658530eb --- /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 00000000..e62188d7 --- /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 00000000..6ffd7c06 --- /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 00000000..6122e8eb --- /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 9fb297e7..aa0b9ce4 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 ae91ae9d..f43c4687 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 93500b81..501a666c 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 a017b53a..b49c30fe 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 00000000..2ec200bd --- /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 00000000..5c59b092 --- /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 00000000..571b769c --- /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 93e79763..ee74c67f 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 551106b5..832a603a 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 37b01259..d985a0cd 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 55b8563e..74903426 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 0abb020f..41144366 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 c9cb0aa8..c045eccf 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 1b6a2f95..44128ead 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 df150dc5..8a23fa03 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 00000000..a4203c54 --- /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 00000000..e7c7e499 --- /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 00000000..87abd9c8 --- /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 00000000..f4dea57b --- /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 00000000..0ac335e7 --- /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 00000000..2d4b0277 --- /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 d4208bec..a09545a6 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 00000000..7c93b4f5 --- /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 00000000..11710997 --- /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 00000000..6e646f1e --- /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 00000000..2ff240b2 --- /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 601ca0b0..38d12efe 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 00000000..21d406bc --- /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 00000000..92b88e98 --- /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 00000000..6fce9686 --- /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 00000000..64493902 --- /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 00000000..445aa9f3 --- /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 00000000..1f3e6b1a --- /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 00000000..f0e444d2 --- /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 00000000..30257f1d --- /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/21] 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 1cd2fe41..c04ae781 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 9bda0b9c..aecb4b44 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 f77daf2f..03c93b4f 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 9fe052ff..71daac19 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 a8b734c4..ffe34bed 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 14501bde..00f8dc64 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 3ff5c228..134b4aa2 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 445aa9f3..4da7b243 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/21] 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 00f8dc64..e2ecf9e3 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 4da7b243..f4b05874 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/21] 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 c04ae781..e2458c2a 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 ffe34bed..04a15e05 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 21d406bc..229b9432 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 1f3e6b1a..a3c31256 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/21] 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 e2458c2a..b768e6b9 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 aecb4b44..65b00128 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 03c93b4f..c91befcb 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 71daac19..3043a14f 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 04a15e05..1ed28459 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 e2ecf9e3..68362eee 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 134b4aa2..66e6075a 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/21] 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 15098c15..9f89abda 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 e7c7e499..1d6c6503 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/21] 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 b49c30fe..a4676094 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 2ff240b2..eb6bb158 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 30257f1d..8aa6313f 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/21] 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 00000000..87da4186 --- /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 c91befcb..d079bf88 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 f4b05874..45e97b91 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 a3c31256..3cda70a2 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/21] 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 1ed28459..2149ed60 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 68362eee..3bd229d1 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 15b1c3ef..d03b896f 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 c2030132..468e2090 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 00000000..6767b59e --- /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 229b9432..15770eaa 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 92b88e98..401b8b95 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 f0e444d2..bbb4d7e4 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/21] 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 00000000..d3e2e91d --- /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 00000000..528def2d --- /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 d079bf88..9fbf7e77 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 66e6075a..cd214953 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 00000000..0098a97a --- /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 00000000..e65f7f2b --- /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 0399baca..265ac7cf 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 00000000..1413572a --- /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 3cda70a2..2fd8bc3d 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/21] 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 00000000..dcad3676 --- /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 00000000..159f5af0 --- /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 a4676094..931fccbd 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 00000000..5d0cf7b6 --- /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 eb6bb158..deab61b9 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/21] 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 dcad3676..b07dfc02 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 159f5af0..27a583a6 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 2908ab49..8891a6b3 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 0098a97a..93779d42 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 e65f7f2b..6668fa87 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 931fccbd..ecdb65f7 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 1413572a..823abef4 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 5d0cf7b6..48f0c8d1 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 deab61b9..461ef4aa 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 8aa6313f..2df98ac3 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" From 63a6d2744de35d874021e3e850affc84c0de1fc4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Apr 2026 18:08:09 +0900 Subject: [PATCH 16/21] ci: expand CodeQL coverage and tighten repo guardrails (#35) * ci: expand CodeQL coverage and tighten repo guardrails * fix: unblock fuzz CI and harden governance tests * fix: forward libFuzzer flags so ClusterFuzzLite fuzz jobs run * docs: align repository truth sources with current workflow state * docs: add canonical engineering truth sources * fix: harden workflow attestation and fuzz builder paths * docs: scope markdownlint around active repository docs * docs: pin the supported MkDocs toolchain stance * docs: align public setup guidance with uv defaults * fix: lock pypdf to patched release * docs: record reviewer-capacity ruleset alignment plan * docs: align governance truth with single-maintainer exception --- .clusterfuzzlite/build.sh | 6 +- .github/CODEOWNERS | 6 + .github/workflows/clusterfuzzlite.yml | 1 + .github/workflows/codeql.yml | 4 +- .github/workflows/gh-pages.yml | 1 - .gitignore | 3 + .markdownlint-cli2.jsonc | 10 + AGENTS.md | 57 ++++ ARCHITECTURE.md | 49 +++ CONTRIBUTING.md | 73 ++++- README.md | 54 +++- docs/adr/0001-openssf-best-practices-badge.md | 27 +- ...0002-single-maintainer-review-exception.md | 56 ++++ docs/agents/README.md | 20 ++ docs/coderabbit/review-commands.md | 19 ++ docs/engineering/acceptance-criteria.md | 29 ++ docs/engineering/canonical-docs.md | 51 +++ docs/engineering/execution-policy.md | 31 ++ docs/engineering/harness-engineering.md | 30 ++ docs/engineering/review-policy.md | 38 +++ docs/engineering/runtime-data-policy.md | 31 ++ docs/engineering/skills-subagents-mcp.md | 27 ++ docs/operations/deploy-runbook.md | 34 ++ .../plans/2026-04-08-security-gates-design.md | 17 +- docs/plans/2026-04-08-security-gates.md | 64 ++-- ...anning-hardening-and-manual-screenshots.md | 196 ++++++++++++ ...026-04-10-truth-source-alignment-design.md | 70 ++++ .../2026-04-10-truth-source-alignment.md | 135 ++++++++ ...iewer-capacity-ruleset-alignment-design.md | 110 +++++++ ...-11-reviewer-capacity-ruleset-alignment.md | 299 ++++++++++++++++++ ...4-11-reviewer-capacity-ruleset-before.json | 84 +++++ docs/security/api-security-checklist.md | 29 ++ docs/workflow/git-flow.md | 18 +- docs/workflow/one-day-delivery-plan.md | 15 + docs/workflow/pr-continuity.md | 23 ++ fuzzers/dom_builder_fuzzer.py | 14 +- manual/api-reference.md | 36 ++- manual/assets/redoc.png | Bin 0 -> 96356 bytes manual/assets/swagger-ui.png | Bin 0 -> 73302 bytes manual/development.md | 57 +++- manual/index.md | 53 +++- manual/installation.md | 70 ++-- pyproject.toml | 2 + .../release/export_release_attestations.py | 16 +- tests/test_engineering_canonical_docs.py | 139 ++++++++ tests/test_fuzzing_integration.py | 38 +++ tests/test_manual_docs.py | 29 +- tests/test_markdownlint_policy.py | 35 ++ tests/test_project_metadata.py | 33 ++ tests/test_readme.py | 27 +- tests/test_release_pipeline.py | 31 +- tests/test_repository_governance.py | 102 ++++++ tests/test_truth_source_alignment.py | 67 ++++ uv.lock | 6 +- 54 files changed, 2313 insertions(+), 159 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .markdownlint-cli2.jsonc create mode 100644 AGENTS.md create mode 100644 ARCHITECTURE.md create mode 100644 docs/adr/0002-single-maintainer-review-exception.md create mode 100644 docs/agents/README.md create mode 100644 docs/coderabbit/review-commands.md create mode 100644 docs/engineering/acceptance-criteria.md create mode 100644 docs/engineering/canonical-docs.md create mode 100644 docs/engineering/execution-policy.md create mode 100644 docs/engineering/harness-engineering.md create mode 100644 docs/engineering/review-policy.md create mode 100644 docs/engineering/runtime-data-policy.md create mode 100644 docs/engineering/skills-subagents-mcp.md create mode 100644 docs/operations/deploy-runbook.md create mode 100644 docs/plans/2026-04-10-code-scanning-hardening-and-manual-screenshots.md create mode 100644 docs/plans/2026-04-10-truth-source-alignment-design.md create mode 100644 docs/plans/2026-04-10-truth-source-alignment.md create mode 100644 docs/plans/2026-04-11-reviewer-capacity-ruleset-alignment-design.md create mode 100644 docs/plans/2026-04-11-reviewer-capacity-ruleset-alignment.md create mode 100644 docs/plans/2026-04-11-reviewer-capacity-ruleset-before.json create mode 100644 docs/security/api-security-checklist.md create mode 100644 docs/workflow/one-day-delivery-plan.md create mode 100644 docs/workflow/pr-continuity.md create mode 100644 manual/assets/redoc.png create mode 100644 manual/assets/swagger-ui.png create mode 100644 tests/test_engineering_canonical_docs.py create mode 100644 tests/test_markdownlint_policy.py create mode 100644 tests/test_repository_governance.py create mode 100644 tests/test_truth_source_alignment.py diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh index 27a583a6..259b167e 100644 --- a/.clusterfuzzlite/build.sh +++ b/.clusterfuzzlite/build.sh @@ -6,8 +6,8 @@ cd "$SRC/newsdom-api" 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") +while IFS= read -r -d '' fuzzer; do + fuzzer_basename=$(basename "$fuzzer" .py) fuzzer_package="${fuzzer_basename}.pkg" pyinstaller --distpath "$OUT" --onefile --name "$fuzzer_package" "$fuzzer" @@ -18,4 +18,4 @@ this_dir=\$(dirname "\$0") exec "\$this_dir/$fuzzer_package" "\$@" EOF chmod +x "$OUT/$fuzzer_basename" -done +done < <(find fuzzers -type f -name '*_fuzzer.py' -print0) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..a9e4ea16 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +* @Seongho-Bae + +# Security and workflow ownership +.github/ @Seongho-Bae +docs/ @Seongho-Bae +manual/ @Seongho-Bae diff --git a/.github/workflows/clusterfuzzlite.yml b/.github/workflows/clusterfuzzlite.yml index 8891a6b3..6796ec4c 100644 --- a/.github/workflows/clusterfuzzlite.yml +++ b/.github/workflows/clusterfuzzlite.yml @@ -22,6 +22,7 @@ jobs: language: python sanitizer: address github-token: ${{ github.token }} + bad-build-check: false - name: Run fuzzers uses: google/clusterfuzzlite/actions/run_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b768e6b9..774625e4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,7 +13,7 @@ permissions: jobs: analyze: - name: codeql (python) + name: codeql (python, actions) runs-on: ubuntu-latest permissions: contents: read @@ -27,7 +27,7 @@ jobs: - name: Initialize CodeQL uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 with: - languages: python + languages: python, actions - name: Autobuild uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 9fbf7e77..ae39ab27 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - master - develop paths: - 'manual/**' diff --git a/.gitignore b/.gitignore index f7b6ddba..5266256e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,8 @@ build/ *.pyc private/ tmp/ +site/ +registered_agents.json +task_agent_mapping.json content_file_3982.pdf page1_300dpi-1.png diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 00000000..286280f8 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,10 @@ +{ + "ignores": [ + "docs/plans/2026-04-08-git-flow-design.md", + "docs/plans/2026-04-08-git-flow.md", + "docs/plans/2026-04-08-newsdom-design.md", + "docs/plans/2026-04-08-newsdom-implementation.md", + "docs/plans/2026-04-08-quality-gate-design.md", + "docs/plans/2026-04-08-quality-gate.md" + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8a619bef --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# AGENTS.md + +## Project overview + +- Repository: `newsdom-api` +- Product: FastAPI service that converts MinerU OCR output into + canonical NewsDOM JSON. +- Primary branch model: manual Git Flow (`develop` integration, + `main` stable). + +## Authoritative docs + +Read these first when making repository changes: + +- `docs/engineering/canonical-docs.md` +- `docs/engineering/execution-policy.md` +- `docs/engineering/acceptance-criteria.md` +- `docs/engineering/harness-engineering.md` +- `docs/engineering/review-policy.md` +- `docs/engineering/runtime-data-policy.md` +- `docs/engineering/skills-subagents-mcp.md` +- `docs/workflow/git-flow.md` +- `docs/workflow/pr-continuity.md` +- `docs/workflow/one-day-delivery-plan.md` +- `docs/operations/deploy-runbook.md` +- `docs/security/api-security-checklist.md` +- `docs/coderabbit/review-commands.md` +- `ARCHITECTURE.md` + +## Setup and verification defaults + +- Install: `uv sync --frozen --all-extras` +- Test: `uv run pytest` +- Coverage gate: + `uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100` +- Docs build: `uv run mkdocs build --strict` +- Local API: `uv run uvicorn --app-dir src newsdom_api.main:app --reload` + +## Delivery defaults + +- Branch normal work from `develop` unless the task is a `main`-only + release or hotfix path. +- Keep PR continuity explicit with `gh pr view` / `gh pr list` / + `pr_continuity` before opening duplicates. +- Treat CodeRabbit as advisory automation; required human approvals + still follow the repository ruleset. +- When PRs are blocked externally, continue local adjacent tasks + instead of stopping. + +## Safety rules + +- Keep synthetic fixtures public and private reference inputs + local-only. +- Do not commit secrets, credentials, or copyrighted source + newspaper material. +- Prefer durable evidence in tracked docs, tests, workflow runs, + PR comments, and release assets over scratch notes. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..b610f16e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,49 @@ +# Architecture + +## Runtime shape + +`newsdom-api` is a small service-oriented Python application with a +thin FastAPI entrypoint and explicit separation between request +orchestration, MinerU process execution, and DOM normalization. + +## Primary modules + +- `src/newsdom_api/main.py` exposes `/health` and `/parse` + through FastAPI. +- `src/newsdom_api/service.py` orchestrates PDF parsing, + temporary files, and response construction. +- `src/newsdom_api/mineru_runner.py` shells out to the MinerU CLI, + collects JSON outputs, and surfaces process errors. +- `src/newsdom_api/dom_builder.py` converts MinerU `content_list` + blocks into the canonical NewsDOM response model. +- `src/newsdom_api/schemas.py` defines the public response schema. +- `src/newsdom_api/synthetic.py` and + `src/newsdom_api/equivalence.py` support synthetic fixture + generation and structural comparisons. + +## Request flow + +1. `src/newsdom_api/main.py` receives an uploaded PDF. +2. `src/newsdom_api/service.py` writes the upload to a temporary + workspace and calls MinerU. +3. `src/newsdom_api/mineru_runner.py` resolves the executable, runs + the OCR pipeline, and loads generated JSON artifacts. +4. `src/newsdom_api/dom_builder.py` normalizes OCR blocks into the canonical response. +5. FastAPI returns typed JSON from `src/newsdom_api/schemas.py`. + +## Supporting systems + +- `tests/fixtures` holds synthetic PDFs, JSON baselines, and + provenance notes; private reference inputs stay out of git. +- `manual/` is the published user manual rendered by MkDocs. +- `.github/workflows/` encodes CI, security scanning, Pages, + release, and image-delivery policy. +- `scripts/release/` builds release manifests and exports GitHub attestation bundles. + +## Delivery boundaries + +- `develop` is the integration line for normal feature, fix, + and chore work. +- `main` is the stable release line that receives tagged releases. +- The service is production-grade only when code, docs, workflows, + and release evidence agree. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d03b896f..c16c34bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,46 +2,76 @@ ## Development setup +Install `uv` first if it is not already available in your `PATH`, then sync the +repository-managed virtual environment: + ```bash -python3.10 -m venv .venv -source .venv/bin/activate -pip install -e ".[dev]" +uv sync --frozen --all-extras ``` Install the parser stack only when you need live MinerU execution: ```bash -pip install "mineru[pipeline]==3.0.9" +uv pip install --python .venv/bin/python "mineru[pipeline]==3.0.9" ``` +On Windows, replace `.venv/bin/python` with `.venv\Scripts\python.exe`. + ## Test commands ```bash -pytest -PYTHONWARNINGS=error pytest -pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100 +uv run pytest +PYTHONWARNINGS=error uv run pytest +uv run 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. +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. +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. +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. + +The supported docs toolchain stays on the MkDocs 1.x line for now. +Keep `mkdocs<2.0` and `mkdocs-material<9.7` in place until the +upstream Material team publishes a workable migration path or this +repository validates a replacement docs stack. `uv.lock` is the source +of truth for the currently supported docs build. ```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, export `*.intoto.jsonl` provenance bundles, and publish a GitHub Release with provenance attestation. +Use markdownlint for `AGENTS.md`, `ARCHITECTURE.md`, `CONTRIBUTING.md`, +and `docs/**/*.md`. The sweep excludes only the legacy +`git-flow-design`, `git-flow`, `newsdom-design`, +`newsdom-implementation`, `quality-gate-design`, and `quality-gate` +planning notes that predate the current markdown style policy. + +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. +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 -This project intentionally separates public test artifacts from private validation material. +This project intentionally separates public test artifacts from +private validation material. Allowed in the repository: @@ -57,7 +87,8 @@ Not allowed in the repository: ## Private baseline refresh -If you maintain a private reference page locally, refresh only the derived structural baseline: +If you maintain a private reference page locally, refresh only the +derived structural baseline: ```bash python tools/derive_private_baseline.py tests/fixtures/private_page_baseline.json @@ -67,10 +98,24 @@ The source page must remain local. ## Documentation split +- `AGENTS.md`: repository-local execution bootstrap for autonomous + maintenance +- `ARCHITECTURE.md`: runtime structure and module boundaries - `README.md`: user-facing overview and quickstart - `CONTRIBUTING.md`: maintainer workflow and safety rules - `SECURITY.md`: vulnerability reporting and supported-branch policy +- `manual/`: published end-user manual rendered by MkDocs +- `docs/agents/README.md`: agent-specific read order and + repository-local behavior notes +- `docs/coderabbit/review-commands.md`: supported CodeRabbit review + commands used in PRs +- `docs/engineering/`: canonical maintainer policies and acceptance criteria +- `docs/operations/`: delivery and verification runbooks +- `docs/security/api-security-checklist.md`: API hardening checklist + for FastAPI surface changes - `docs/workflow/git-flow.md`: canonical branch workflow +- `docs/workflow/pr-continuity.md`: canonical PR-selection and + stacked-PR guidance - `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 14477aa8..5051d107 100644 --- a/README.md +++ b/README.md @@ -8,28 +8,33 @@ NewsDOM API parses scanned Japanese newspaper PDFs into DOM-like article trees. - Primary engine: `MinerU` pipeline backend - Service wrapper: FastAPI -- Output: canonical JSON with pages, articles, headlines, body blocks, images, captions, and quality metadata +- Output: canonical JSON with pages, articles, headlines, body + blocks, images, captions, and quality metadata ## Quickstart ### Install +Install `uv` first if it is not already available in your `PATH`, then sync the +repository-managed virtual environment: + ```bash -python3.10 -m venv .venv -source .venv/bin/activate -pip install -e ".[dev]" +uv sync --frozen --all-extras ``` -To enable real parsing with MinerU, install the MinerU CLI separately in the environment that will execute parsing: +To enable real parsing with MinerU, install the MinerU CLI separately in the +same `.venv` that `uv sync` created: ```bash -pip install "mineru[pipeline]==3.0.9" +uv pip install --python .venv/bin/python "mineru[pipeline]==3.0.9" ``` +On Windows, replace `.venv/bin/python` with `.venv\Scripts\python.exe`. + ### Run ```bash -uvicorn newsdom_api.main:app --reload +uv run uvicorn --app-dir src newsdom_api.main:app --reload ``` ### Docker @@ -39,9 +44,14 @@ 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 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. +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: @@ -50,9 +60,14 @@ 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. +`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. +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 @@ -63,27 +78,32 @@ curl -F "file=@sample.pdf" http://127.0.0.1:8000/parse ### Run tests ```bash -pytest +uv run pytest ``` ### Fuzzing smoke ```bash -python fuzzers/dom_builder_fuzzer.py --smoke tests/fixtures/mineru_sample.json +uv run python fuzzers/dom_builder_fuzzer.py --smoke tests/fixtures/mineru_sample.json ``` -The repository also enforces a `quality-gate` workflow with 100% source coverage and docstring audit coverage. +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`. +This repository ships only synthetic test fixtures and derived structural +baselines. For fixture provenance and regeneration notes, see +`tests/fixtures/README.md`. ## Development -Development setup, fixture handling rules, and local-only baseline maintenance are documented in `CONTRIBUTING.md`. +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. +Version tags trigger a GitHub-native release workflow that builds +distribution artifacts, checksums, and provenance attestations. Project history is tracked in `CHANGELOG.md`. diff --git a/docs/adr/0001-openssf-best-practices-badge.md b/docs/adr/0001-openssf-best-practices-badge.md index 658530eb..83baf6dc 100644 --- a/docs/adr/0001-openssf-best-practices-badge.md +++ b/docs/adr/0001-openssf-best-practices-badge.md @@ -6,9 +6,15 @@ 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 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. +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 @@ -16,21 +22,28 @@ 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. +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. +- 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. +- 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. +- Revisit enrollment after the blocked protected-branch PR stack has merged, + the first provenance-backed stable release has shipped, and reviewer + capacity exists beyond the sole author/admin account. +- If the repository still intends to pursue the badge at that time, assign an + owner, link the active release and reviewer-capacity issues, and complete + the OpenSSF questionnaire. diff --git a/docs/adr/0002-single-maintainer-review-exception.md b/docs/adr/0002-single-maintainer-review-exception.md new file mode 100644 index 00000000..625becf4 --- /dev/null +++ b/docs/adr/0002-single-maintainer-review-exception.md @@ -0,0 +1,56 @@ +# ADR-0002: Single-maintainer protected-branch review exception + +## Status + +Accepted + +## Context + +The repository currently has one maintainer/admin account and no +non-author code owners or reviewers. The live protected-branch ruleset +for `main` and `develop` had been strengthened to require 2 approvals, +required `CODEOWNERS` review, and last-push approval. + +That posture is desirable once reviewer capacity exists, but it is not +operationally satisfiable while the repository still has only the sole +author/admin account. As a result, merge-ready PRs and downstream stable +release work become permanently blocked even when required checks are +green. + +## Decision + +Temporarily relax the mandatory approval, required `CODEOWNERS` review, +and last-push approval gates while the repository remains a +single-maintainer project. + +Keep the following protections active: + +- pull-request-only merge flow +- required CI/status checks +- required review-thread resolution +- linear history +- no force pushes +- no protected-branch deletion + +## Consequences + +### Positive + +- Removes an unsatisfiable governance deadlock. +- Preserves the stronger non-review protections and CI evidence path. +- Restores a normal merge and release path without relying on admin + bypass merges as the standard workflow. + +### Negative + +- Weakens the live review gate until reviewer capacity exists. +- May reduce branch-protection scoring compared with the ideal + multi-reviewer posture. + +## Revisit trigger + +- When the repository gains one non-author code owner/reviewer, restore + at least `1` non-author approval + `CODEOWNERS` review + last-push + approval. +- Only restore the two-approval requirement when two independent human + reviewers are available in practice. diff --git a/docs/agents/README.md b/docs/agents/README.md new file mode 100644 index 00000000..3f3c9492 --- /dev/null +++ b/docs/agents/README.md @@ -0,0 +1,20 @@ +# Agent docs + +This directory anchors repository-local instructions for autonomous +maintenance. + +## Read order + +1. `../../AGENTS.md` +2. `../engineering/canonical-docs.md` +3. `../engineering/execution-policy.md` +4. `../engineering/acceptance-criteria.md` +5. `../../ARCHITECTURE.md` + +## Intent + +- Keep agent behavior tied to repository truth instead of external defaults. +- Preserve a single durable map for testing, review, release, and + security expectations. +- Make stacked PR work, live verification, and blocker handling + reproducible across sessions. diff --git a/docs/coderabbit/review-commands.md b/docs/coderabbit/review-commands.md new file mode 100644 index 00000000..463f83de --- /dev/null +++ b/docs/coderabbit/review-commands.md @@ -0,0 +1,19 @@ +# CodeRabbit review commands + +Use CodeRabbit commands only as automation aids; they do not replace +required human approval. + +## Common commands used in this repository + +- `@coderabbitai review` — trigger or resume a review pass. +- `@coderabbitai full review` — request a broader follow-up review. +- `@coderabbitai resolve` — mark resolved comment threads after the + underlying fix lands. +- `@coderabbitai help` — list supported commands. + +## Usage notes + +- Use commands in PR comments, not commit messages. +- Re-run only after a code or documentation change that materially + addresses feedback. +- Do not dismiss unresolved human feedback just because CodeRabbit is green. diff --git a/docs/engineering/acceptance-criteria.md b/docs/engineering/acceptance-criteria.md new file mode 100644 index 00000000..42daa959 --- /dev/null +++ b/docs/engineering/acceptance-criteria.md @@ -0,0 +1,29 @@ +# Acceptance criteria + +Repository work is done only when the relevant code, documentation, +and delivery evidence all agree. + +## Required verification + +- `uv run pytest` +- `uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100` +- `uv run mkdocs build --strict` whenever user-facing manual content + changes +- relevant targeted smoke tests for changed delivery paths + (container, fuzz, release, or API) + +## Additional gates + +- New tests must fail first, then pass after the implementation. +- Documentation changes must match live workflow and repository settings. +- Required GitHub checks for the affected PR must be green before merge. +- If a task touches release or delivery code, the release path or + blocker evidence must be updated. +- If a task touches API security posture, + `docs/security/api-security-checklist.md` must still be satisfied. + +## Non-acceptance conditions + +- stale docs that contradict code or live GitHub settings +- green local tests with known broken workflow/release paths +- untracked external blockers without an issue or PR comment explaining them diff --git a/docs/engineering/canonical-docs.md b/docs/engineering/canonical-docs.md new file mode 100644 index 00000000..6d8446c5 --- /dev/null +++ b/docs/engineering/canonical-docs.md @@ -0,0 +1,51 @@ +# Canonical docs map + +Use this index to decide which repository document is authoritative +for a given question. + +## Product and user-facing truth + +- `README.md` — public overview, quickstart, release summary, and + repository layout +- `manual/index.md` and the rest of `manual/` — published user + manual +- `CHANGELOG.md` — released and unreleased user-visible change history + +## Maintainer workflow truth + +- `CONTRIBUTING.md` — maintainer setup, local verification, + fixture policy, and documentation split +- `SECURITY.md` — reporting path and supported security branches +- `docs/workflow/git-flow.md` — branch model and merge targets +- `docs/workflow/pr-continuity.md` — canonical PR selection and + stacked-PR handling +- `docs/workflow/one-day-delivery-plan.md` — default + close-the-loop execution model + +## Engineering control truth + +- `AGENTS.md` — repository-local execution bootstrap +- `ARCHITECTURE.md` — runtime structure and module responsibilities +- `docs/agents/README.md` — agent-specific read order and local + execution context +- `docs/coderabbit/review-commands.md` — supported review-bot control + commands +- `docs/engineering/execution-policy.md` — task selection and + execution behavior +- `docs/engineering/acceptance-criteria.md` — completion bar +- `docs/engineering/review-policy.md` — human + automation review + expectations +- `docs/engineering/runtime-data-policy.md` — synthetic fixtures, + logs, secrets, and tmp handling +- `docs/engineering/harness-engineering.md` — local and live verification harnesses +- `docs/engineering/skills-subagents-mcp.md` — subagent/MCP defaults +- `docs/security/api-security-checklist.md` — API hardening baseline + for FastAPI surface changes + +## Planning truth + +- `docs/plans/` — task-by-task implementation and design notes +- `docs/adr/` — decisions that should survive beyond a single PR + +When two sources disagree, prefer the narrower and more recently +verified source, then repair the drift. diff --git a/docs/engineering/execution-policy.md b/docs/engineering/execution-policy.md new file mode 100644 index 00000000..06987aa9 --- /dev/null +++ b/docs/engineering/execution-policy.md @@ -0,0 +1,31 @@ +# Execution policy + +## Default operating mode + +- Pick the highest-impact executable task from the current repository + state. +- Verify branch, PR, issue, workflow, and comment state before + acting. +- Prefer repository-local docs over external conventions. +- Continue with adjacent executable work when a PR is blocked by + external review or policy. + +## Implementation rules + +- Use TDD: write the failing test first, confirm the failure, then + implement the minimal fix. +- Keep changes scoped to the current root cause plus required + documentation and regression coverage. +- Re-check live GitHub state before claiming a blocker is external. +- Treat PR/issue comments as mandatory truth sources, not optional context. + +## Delivery rules + +- For normal work, branch from `develop` and target `develop`. +- For stable-release synchronization, operate on the `main` path + deliberately and keep backports explicit. +- Prefer stacked PRs over mixing unrelated follow-up work into a + blocked branch without documentation. +- If release work becomes unblocked, continue through changelog, + artifact, and provenance verification instead of stopping at code + completion. diff --git a/docs/engineering/harness-engineering.md b/docs/engineering/harness-engineering.md new file mode 100644 index 00000000..570ccdba --- /dev/null +++ b/docs/engineering/harness-engineering.md @@ -0,0 +1,30 @@ +# Harness engineering + +This repository relies on durable verification harnesses instead of +informal spot checks. + +## Local verification harnesses + +- `uv run pytest` for full regression coverage +- `uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100` +- `uv run mkdocs build --strict` for the published manual +- targeted smoke entrypoints such as: + - `uv run python fuzzers/dom_builder_fuzzer.py --smoke tests/fixtures/mineru_sample.json` + - local container smoke against `/health` + +## Live verification harnesses + +- GitHub state via `gh pr view`, `gh issue view`, `gh api`, and + workflow runs +- Playwright or equivalent browser capture when manual screenshots or + localhost UI/API console evidence changes +- release artifact verification through GitHub Releases and exported + `*.intoto.jsonl` bundles + +## Evidence policy + +- Prefer checked-in tests, screenshots in `manual/assets/`, ADRs, + plan docs, and PR comments. +- Do not leave durable evidence only in `tmp/`. +- If logs are needed for deployment or workflow debugging, store or + upload them somewhere durable and redact secrets. diff --git a/docs/engineering/review-policy.md b/docs/engineering/review-policy.md new file mode 100644 index 00000000..468e69db --- /dev/null +++ b/docs/engineering/review-policy.md @@ -0,0 +1,38 @@ +# Review policy + +## Review expectations + +- Human review is the merge authority when the ruleset requires it. +- CodeRabbit is advisory automation that can accelerate triage and + small follow-up fixes. +- Required checks must be green before a PR is considered merge-ready. + +## Single-maintainer exception + +- If reviewer capacity is limited to the sole author/admin account, + do not pretend that non-author approvals or required CODEOWNERS + review are satisfiable. +- In that state, prefer a documented temporary single-maintainer + exception that keeps PR flow, required checks, thread resolution, + and history protections intact. +- Re-tighten the ruleset to require at least one non-author approval, + CODEOWNERS review, and last-push approval as soon as reviewer + capacity exists beyond the sole maintainer. +- Restore the stronger two-approval policy only when two independent + human reviewers are actually available. + +## Thread handling + +- Resolve review comments only after the underlying code, docs, or + tests change. +- Keep unresolved external blockers visible in PR comments and linked issues. +- If the repository ruleset demands approvals that current reviewer + capacity cannot satisfy, track that gap explicitly instead of + pretending the PR is mergeable. + +## Scope discipline + +- Keep PRs MECE: one root cause family per PR, with stacked PRs for + clearly separate follow-ups. +- Re-check review state after any push because last-push approval and + stale-review dismissal are enabled. diff --git a/docs/engineering/runtime-data-policy.md b/docs/engineering/runtime-data-policy.md new file mode 100644 index 00000000..138e328f --- /dev/null +++ b/docs/engineering/runtime-data-policy.md @@ -0,0 +1,31 @@ +# Runtime data policy + +## Allowed durable data + +- synthetic fixtures +- derived structural baselines +- sanitized logs +- release manifests and provenance bundles +- manual screenshots that depict local test systems such as `/docs` + and `/redoc` + +## Forbidden repository data + +- private reference PDFs or OCR text +- secrets, tokens, credentials, or session cookies +- copyrighted source newspaper assets + +## tmp and log handling + +- Use `tmp/` only for disposable scratch data. +- Durable evidence, logs, and release artifacts must not live only + under `tmp/`. +- If an investigation needs logs, keep the redacted result in a + durable path or uploaded artifact. + +## Safe handling rules + +- do not commit secrets +- Keep private reference material local-only. +- Prefer synthetic fixtures for tests, examples, and API security + exercises. diff --git a/docs/engineering/skills-subagents-mcp.md b/docs/engineering/skills-subagents-mcp.md new file mode 100644 index 00000000..53197641 --- /dev/null +++ b/docs/engineering/skills-subagents-mcp.md @@ -0,0 +1,27 @@ +# Skills, subagents, and MCP defaults + +## Subagents + +- Use subagents for parallel research on security posture, PR/issue + state, release path, or workflow health. +- Keep conflicting code edits serialized in the main + worktree/controller. +- Ask review-oriented subagents to check spec compliance and code + quality before merge preparation. + +## MCP / tool defaults + +- GitHub tooling for PR, issue, workflow, collaborator, release, and + code-scanning truth +- Playwright for localhost screenshots and browser-based verification + when the user manual changes +- memory tooling for durable repo-specific preferences or blocker + history when helpful + +## Repository-specific guidance + +- Prefer repository-local docs over generic skill defaults when they + disagree. +- Use `uv`-based commands as the first choice for Python setup and execution. +- Keep stacked PR continuity explicit because `develop` and `main` + can diverge intentionally. diff --git a/docs/operations/deploy-runbook.md b/docs/operations/deploy-runbook.md new file mode 100644 index 00000000..4bb403a0 --- /dev/null +++ b/docs/operations/deploy-runbook.md @@ -0,0 +1,34 @@ +# Deploy runbook + +This repository does not ship a long-lived production cluster +definition in-tree, so deployment verification focuses on reproducible +local and GitHub-hosted delivery paths. + +## Local API smoke + +1. `uv sync --frozen --all-extras` +2. `uv run uvicorn --app-dir src newsdom_api.main:app --host 127.0.0.1 --port 8000` +3. Verify: + - `curl -fsS http://127.0.0.1:8000/health` + - `http://127.0.0.1:8000/docs` + - `http://127.0.0.1:8000/redoc` + +## Container smoke + +1. `docker build -t newsdom-api .` +2. `docker run --rm -p 18080:8000 newsdom-api` +3. `curl -fsS http://127.0.0.1:18080/health` + +## Release smoke + +- Confirm `.github/workflows/release.yml` still builds artifacts, + checksums, and `*.intoto.jsonl` bundles. +- After a tag push or manual dispatch, verify the GitHub Release + contains `SHA256SUMS.txt`, `release-manifest.json`, and + `*.intoto.jsonl` assets. + +## Failure handling + +- Capture sanitized logs outside `tmp/` when a delivery path fails. +- Reconcile the failure against `README.md`, `CHANGELOG.md`, and the + relevant workflow before closing the task. diff --git a/docs/plans/2026-04-08-security-gates-design.md b/docs/plans/2026-04-08-security-gates-design.md index 59a21d71..66b81395 100644 --- a/docs/plans/2026-04-08-security-gates-design.md +++ b/docs/plans/2026-04-08-security-gates-design.md @@ -4,28 +4,34 @@ ## Goal -Add GitHub-native security gates centered on OpenSSF Scorecard and make them required for protected branches. +Add GitHub-native security gates centered on OpenSSF Scorecard and make them +required for protected branches. ## Constraints - The repository already uses GitHub Actions for tests. -- `main` and `develop` are branch-protected and should treat security workflows as required gates. +- `main` and `develop` are branch-protected and should treat security + workflows as required gates. - Changes should remain lightweight for a small Python FastAPI repository. - Security workflows should be least-privilege and avoid unnecessary secrets. ## Approaches considered ### Approach A — Scorecard only + - Add OpenSSF Scorecard workflow and badge. - Low effort, but misses code and dependency risk coverage. ### Approach B — Scorecard + CodeQL + Dependency Review (recommended) + - Scorecard for repository posture. - CodeQL for Python SAST. - Dependency Review for pull-request dependency deltas. ### Approach C — Broad security suite (Semgrep, Trivy, etc.) -- Stronger coverage but too heavy for the current repository size and request scope. + +- Stronger coverage but too heavy for the current repository size and request + scope. ## Decision @@ -35,11 +41,12 @@ Choose **Approach B**. - `pytest` - `scorecard` -- `codeql (python)` +- `codeql (python, actions)` - `dependency-review` ## Operational notes - Add minimal README signal via a Scorecard badge. - Keep workflow permissions explicit. -- Trigger CodeQL and Scorecard on both `develop` and `main` so both protected branches can require them. +- Trigger CodeQL and Scorecard on both `develop` and `main` so both protected + branches can require them. diff --git a/docs/plans/2026-04-08-security-gates.md b/docs/plans/2026-04-08-security-gates.md index 8b97ee67..cf3c591c 100644 --- a/docs/plans/2026-04-08-security-gates.md +++ b/docs/plans/2026-04-08-security-gates.md @@ -1,107 +1,119 @@ # Security Gates Implementation Plan -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> **For Claude:** REQUIRED SUB-SKILL: Use +> superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Add OpenSSF Scorecard and adjacent security workflows, then make them required checks on `main` and `develop`. +**Goal:** Add OpenSSF Scorecard and adjacent security workflows, then make them +required checks on `main` and `develop`. -**Architecture:** Extend GitHub Actions with three lightweight security workflows—Scorecard, CodeQL, and Dependency Review—using explicit job names so branch protection can require them deterministically. +**Architecture:** Extend GitHub Actions with three lightweight security +workflows—Scorecard, CodeQL, and Dependency Review—using explicit job names so +branch protection can require them deterministically. -**Tech Stack:** GitHub Actions, OpenSSF Scorecard Action, GitHub CodeQL, Dependency Review Action. +**Tech Stack:** GitHub Actions, OpenSSF Scorecard Action, GitHub CodeQL, +Dependency Review Action. --- -### Task 1: Add security workflows +## Task 1: Add security workflows **Files:** + - Create: `.github/workflows/scorecards.yml` - Create: `.github/workflows/codeql.yml` - Create: `.github/workflows/dependency-review.yml` -**Step 1: Write the failing test** +### Task 1 / Step 1: Write the failing test Add a repo test that asserts these workflow files exist. -**Step 2: Run test to verify it fails** +### Task 1 / Step 2: Run test to verify it fails Run: `pytest tests/test_readme.py -v` Expected: FAIL until the files are added and referenced. -**Step 3: Write minimal implementation** +### Task 1 / Step 3: Write minimal implementation Add the workflows with explicit job names: + - `scorecard` -- `codeql (python)` +- `codeql (python, actions)` - `dependency-review` -**Step 4: Run test to verify it passes** +### Task 1 / Step 4: Run test to verify it passes Run: `pytest` Expected: PASS. -**Step 5: Commit** +### Task 1 / Step 5: Commit ```bash git add .github/workflows/*.yml tests/ git commit -m "ci: add security workflows" ``` -### Task 2: Add minimal documentation signal +## Task 2: Add minimal documentation signal **Files:** + - Modify: `README.md` -**Step 1: Write the failing test** +### Task 2 / Step 1: Write the failing test Add a doc test that checks for the Scorecard badge URL or text. -**Step 2: Run test to verify it fails** +### Task 2 / Step 2: Run test to verify it fails Run: `pytest tests/test_readme.py -v` Expected: FAIL until README is updated. -**Step 3: Write minimal implementation** +### Task 2 / Step 3: Write minimal implementation Add a single Scorecard badge near the title. -**Step 4: Run test to verify it passes** +### Task 2 / Step 4: Run test to verify it passes Run: `pytest` Expected: PASS. -### Task 3: Verify workflow syntax and tests +## Task 3: Verify workflow syntax and tests **Files:** + - No new files. -**Step 1: Run unit tests** +### Task 3 / Step 1: Run unit tests Run: `pytest` -**Step 2: Run warnings-as-errors tests** +### Task 3 / Step 2: Run warnings-as-errors tests Run: `PYTHONWARNINGS=error pytest` -**Step 3: Validate workflow files structurally** +### Task 3 / Step 3: Validate workflow files structurally Run a YAML parser or equivalent check against `.github/workflows/*.yml`. -### Task 4: Update branch protections +## Task 4: Update branch protections **Files:** + - No new files. -**Step 1: Update `main` protection** +### Task 4 / Step 1: Update `main` protection Require: + - `pytest` - `scorecard` -- `codeql (python)` +- `codeql (python, actions)` - `dependency-review` -**Step 2: Update `develop` protection** +### Task 4 / Step 2: Update `develop` protection Require the same checks. -**Step 3: Verify protections** +### Task 4 / Step 3: Verify protections -Run GitHub API reads for both branches and confirm the contexts list matches exactly. +Run GitHub API reads for both branches and confirm the contexts list matches +exactly. diff --git a/docs/plans/2026-04-10-code-scanning-hardening-and-manual-screenshots.md b/docs/plans/2026-04-10-code-scanning-hardening-and-manual-screenshots.md new file mode 100644 index 00000000..9b7e39c2 --- /dev/null +++ b/docs/plans/2026-04-10-code-scanning-hardening-and-manual-screenshots.md @@ -0,0 +1,196 @@ +# Code Scanning Hardening and Manual Screenshots Implementation Plan + +> Execute this plan task-by-task and verify each step before moving on. + +**Goal:** Close the highest-priority executable security and code-scanning +gaps by hardening repository review protections and CodeQL coverage, then +update the Korean manual with verified screenshots. + +**Architecture:** Add repository-owned governance artifacts (`CODEOWNERS`) +and expand workflow coverage so GitHub code scanning evaluates both Python +and GitHub Actions. Keep verification split between local TDD for repo files +and live GitHub API checks for ruleset state, then regenerate manual +screenshots from localhost-served pages so docs reflect the current product +surface. + +**Tech Stack:** Python, pytest, GitHub Actions, GitHub REST API (`gh api`), +MkDocs Material, FastAPI, Playwright + +--- + +## Task 1: Add failing tests for security workflow and manual screenshot expectations + +**Files:** + +- Create: `tests/test_repository_governance.py` +- Keep: `tests/test_readme.py` unchanged + +### Step 1: Write the failing test + +Add tests that assert: + +- `.github/CODEOWNERS` exists and references the maintainer path ownership. +- `.github/workflows/codeql.yml` includes `actions` in the configured + languages. +- `manual/api-reference.md` references screenshot assets. +- `manual/development.md` documents enforced review gates. +- `.gitignore` excludes generated runtime artifacts. + +### Step 2: Run test to verify it fails + +Run: `uv run pytest tests/test_repository_governance.py -q` + +Expected: FAIL because `CODEOWNERS` is missing, CodeQL scans only `python`, +screenshot references do not exist, review-gate docs are incomplete, and +generated runtime artifacts are not ignored. + +### Step 3: Write minimal implementation + +Create `CODEOWNERS`, update `codeql.yml`, and add screenshot references to +the manual with matching assets. + +### Step 4: Run test to verify it passes + +Run: `uv run pytest tests/test_repository_governance.py -q` + +Expected: PASS + +### Step 5: Commit + +```bash +git add tests/test_repository_governance.py tests/test_readme.py \ + .github/CODEOWNERS .github/workflows/codeql.yml \ + manual/api-reference.md manual/assets/ +git commit -m "security: harden code scanning and document UI evidence" +``` + +## Task 2: Generate verified manual screenshots from localhost services + +**Files:** + +- Create: `manual/assets/swagger-ui.png` +- Create: `manual/assets/redoc.png` +- Modify: `manual/api-reference.md` + +### Task 2 Step 1: Write the failing test + +Extend the governance and manual test to assert the screenshot files exist, +carry real PNG signatures, and are referenced from the API manual. + +### Task 2 Step 2: Run test to verify it fails + +Run: +`uv run pytest tests/test_repository_governance.py::` +`test_api_manual_references_screenshot_assets -q` + +Expected: FAIL because the screenshot files do not exist yet. + +### Task 2 Step 3: Write minimal implementation + +Start the FastAPI app locally, open `/docs` and `/redoc` with Playwright, and +capture screenshots into `manual/assets/`, then add Markdown image embeds plus +explanatory captions in `manual/api-reference.md`. + +### Task 2 Step 4: Run test to verify it passes + +Run: +`uv run pytest tests/test_repository_governance.py::` +`test_api_manual_references_screenshot_assets -q` + +Expected: PASS + +### Task 2 Step 5: Commit + +```bash +git add manual/api-reference.md manual/assets/swagger-ui.png \ + manual/assets/redoc.png tests/test_repository_governance.py +git commit -m "docs: add verified API console screenshots" +``` + +## Task 3: Verify live GitHub ruleset posture and align remediation evidence + +**Files:** + +- Modify: `.github/CODEOWNERS` +- Modify: `.github/workflows/codeql.yml` +- Modify: `manual/development.md` + +### Task 3 Step 1: Write the failing verification target + +Record the desired live conditions: + +- active ruleset requires 2 approving reviews +- code owner review is required +- last-push approval remains required + +### Task 3 Step 2: Run verification to show current gap + +Run: `gh api repos/Seongho-Bae/newsdom-api/rulesets` and capture the `id` for +the `mirror-classic-protection-main-develop` ruleset, then query +`gh api repos/Seongho-Bae/newsdom-api/rulesets/`. + +Expected: JSON shows `required_approving_review_count` is `1` and +`require_code_owner_review` is `false`. + +### Task 3 Step 3: Write minimal implementation + +Patch the repository ruleset via `gh api --method PUT ...` so it requires 2 +approvals and code owner review, then document the strengthened governance +path in `manual/development.md`. + +### Task 3 Step 4: Run verification to prove it passes + +Run: `gh api repos/Seongho-Bae/newsdom-api/rulesets/` using the resolved +ruleset identifier from the previous step. + +Expected: JSON shows `required_approving_review_count` is `2`, +`require_code_owner_review` is `true`, and `require_last_push_approval` +remains `true`. + +### Task 3 Step 5: Commit + +```bash +git add .github/CODEOWNERS .github/workflows/codeql.yml manual/development.md +git commit -m "security: strengthen review gates for protected branches" +``` + +## Task 4: Full verification and delivery evidence + +**Files:** + +- Modify: `README.md` +- Modify: `manual/index.md` + +### Task 4 Step 1: Run repository verification + +Run: + +- `uv sync --extra dev` +- `uv run pytest -q` +- `python3 scripts/prompt_checks/validate_canonical_doc_refs.py --root .` + (skip if the script is not present in this repository) + +Expected: all repo checks required for this task pass. + +### Task 4 Step 2: Run live and manual verification + +Run: + +- `gh api repos/Seongho-Bae/newsdom-api/code-scanning/alerts?state=open&per_page=100` +- review the latest Scorecard and CodeQL posture after the push-triggered + workflow + +Expected: branch protection alert evidence reflects the tightened ruleset, and +new workflow coverage is present for future scans. + +### Task 4 Step 3: Update docs if verification reveals drift + +Refresh manual and README wording so documentation matches the verified +workflow and review posture. + +### Task 4 Step 4: Commit + +```bash +git add README.md manual/index.md +git commit -m "docs: align manual with security verification evidence" +``` diff --git a/docs/plans/2026-04-10-truth-source-alignment-design.md b/docs/plans/2026-04-10-truth-source-alignment-design.md new file mode 100644 index 00000000..b1e780e6 --- /dev/null +++ b/docs/plans/2026-04-10-truth-source-alignment-design.md @@ -0,0 +1,70 @@ +# Truth Source Alignment Design + +**Date:** 2026-04-10 + +## Goal + +Restore repository truth-source integrity for documentation and workflow +contracts that drifted after the governance hardening work. + +## Constraints + +- PR #34 and PR #35 are currently code/CI green but blocked on external + reviewer capacity. +- The next executable task should avoid destabilizing the already-green + security and release work. +- The repository currently has no `integration`-marked tests, no `master` + branch, and the active CodeQL contract is `codeql (python, actions)`. +- This task is documentation/workflow contract alignment only; no API, + release, or LLM live path changes are required. + +## Approaches Considered + +### Approach A — Leave drift in place until blocked PRs merge + +- Lowest immediate effort. +- Rejected because manuals, ADRs, and workflow plans would continue to + advertise inaccurate verification and branch-policy facts. + +### Approach B — Targeted contract alignment with regression tests (recommended) + +- Add focused tests for doc/workflow truth. +- Update only the files that are currently stale: installation manual, GitHub + Pages trigger, ADR follow-up text, and security-gates planning docs. +- Keeps scope narrow while preventing the same drift from recurring. + +### Approach C — Broader documentation sweep + +- Could normalize many docs at once. +- Rejected for now because it would expand scope beyond the current verified + mismatches and risk delaying more urgent blocked-path work. + +## Decision + +Choose **Approach B**. + +## Planned Changes + +- Add a regression test module that verifies: + - the installation manual does not advertise an empty `integration` marker + run, + - the GitHub Pages workflow does not trigger on the nonexistent `master` + branch, + - security-gates planning docs match the current + `codeql (python, actions)` required check name, + - ADR-0001 no longer points to stale resolved issue numbers. +- Update the affected docs/workflow to satisfy those tests. + +## Verification Strategy + +- Red/green the new regression tests first. +- Run the new focused test file. +- Run the full pytest suite. +- Run lint on changed files. + +## Live Test Assessment + +Live test is **not required** for this task. The affected surface is +documentation and GitHub Actions trigger metadata, and the acceptance criteria +are fully verifiable through local file parsing, YAML validation, and +repository tests. diff --git a/docs/plans/2026-04-10-truth-source-alignment.md b/docs/plans/2026-04-10-truth-source-alignment.md new file mode 100644 index 00000000..1a33b1f4 --- /dev/null +++ b/docs/plans/2026-04-10-truth-source-alignment.md @@ -0,0 +1,135 @@ +# Truth Source Alignment Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use +> superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Restore repository docs and workflow metadata so manuals, ADR +follow-ups, and planning docs reflect the actual verification and branch state. + +**Architecture:** Add one focused regression test module for truth-source +drift, then make the minimal file edits needed to satisfy those tests. Keep +the task documentation-only except for removing the dead `master` GitHub Pages +trigger. + +**Tech Stack:** Pytest, PyYAML, Markdown docs, GitHub Actions YAML. + +--- + +## Task 1: Guard installation manual and Pages branch trigger drift + +**Files:** + +- Modify: `tests/test_truth_source_alignment.py` +- Modify: `manual/installation.md` +- Modify: `.github/workflows/gh-pages.yml` + +### Task 1 / Step 1: Write the failing test + +Add tests that fail when: + +- `manual/installation.md` advertises `pytest -m "integration"` without any + integration-marked tests in `tests/` +- `.github/workflows/gh-pages.yml` still includes `master` in its push branch + trigger list + +### Task 1 / Step 2: Run test to verify it fails + +Run: `uv run pytest tests/test_truth_source_alignment.py -q` +Expected: FAIL on the installation manual and GitHub Pages trigger assertions. + +### Task 1 / Step 3: Write minimal implementation + +- Update `manual/installation.md` to document only real verification commands. +- Remove `master` from `.github/workflows/gh-pages.yml`. + +### Task 1 / Step 4: Run test to verify it passes + +Run: + +```bash +TEST_FILE="tests/test_truth_source_alignment.py" +TEST_ONE="test_installation_manual_does_not_reference_empty_integration_marker" +TEST_TWO="test_gh_pages_workflow_targets_supported_branches_only" +uv run pytest \ + "$TEST_FILE::$TEST_ONE" \ + -q +uv run pytest \ + "$TEST_FILE::$TEST_TWO" \ + -q +``` + +Expected: PASS. + +### Task 1 / Step 5: Commit + +```bash +git add tests/test_truth_source_alignment.py manual/installation.md .github/workflows/gh-pages.yml +git commit -m "docs: align installation and Pages workflow contracts" +``` + +## Task 2: Guard stale security-plan and ADR references + +**Files:** + +- Modify: `tests/test_truth_source_alignment.py` +- Modify: `docs/adr/0001-openssf-best-practices-badge.md` +- Modify: `docs/plans/2026-04-08-security-gates.md` +- Modify: `docs/plans/2026-04-08-security-gates-design.md` + +### Task 2 / Step 1: Write the failing test + +Add tests that fail when: + +- the security-gates plan docs still reference `codeql (python)` instead of + `codeql (python, actions)` +- ADR-0001 still mentions stale issue references `#8` / `#10` + +### Task 2 / Step 2: Run test to verify it fails + +Run: `uv run pytest tests/test_truth_source_alignment.py -q` +Expected: FAIL on stale CodeQL naming and stale ADR follow-up references. + +### Task 2 / Step 3: Write minimal implementation + +- Update both security-gates docs to reflect the current required check name. +- Rewrite the ADR follow-up section to condition-based language without stale + issue references. + +### Task 2 / Step 4: Run test to verify it passes + +Run: `uv run pytest tests/test_truth_source_alignment.py -q` +Expected: PASS. + +### Task 2 / Step 5: Commit + +```bash +git add tests/test_truth_source_alignment.py \ + docs/adr/0001-openssf-best-practices-badge.md \ + docs/plans/2026-04-08-security-gates*.md +git commit -m "docs: remove stale governance planning references" +``` + +## Task 3: Full verification + +**Files:** + +- No new files. + +### Task 3 / Step 1: Run focused lint + +Run: `uvx ruff check tests/test_truth_source_alignment.py` + +### Task 3 / Step 2: Run full test suite + +Run: `uv run pytest -q` + +### Task 3 / Step 3: Capture git status + +Run: `git status --short --branch` + +### Task 3 / Step 4: Commit + +```bash +git add . +git commit -m "docs: restore truth-source alignment for workflow and manual" +``` diff --git a/docs/plans/2026-04-11-reviewer-capacity-ruleset-alignment-design.md b/docs/plans/2026-04-11-reviewer-capacity-ruleset-alignment-design.md new file mode 100644 index 00000000..92d7f68c --- /dev/null +++ b/docs/plans/2026-04-11-reviewer-capacity-ruleset-alignment-design.md @@ -0,0 +1,110 @@ +# Design: reviewer-capacity ruleset alignment + +## Context + +- The current canonical blocked delivery path is issue #36. +- PR #35 (`develop`) and PR #34 (`main`) are both green on code and required checks but blocked by the active repository ruleset. +- Live GitHub rules require 2 approving reviews, CODEOWNERS review, last-push approval, resolved review threads, and required checks on both `develop` and `main`. +- Live collaborator inventory currently exposes only the sole author/admin account. +- With one maintainer and `.github/CODEOWNERS` pointing only at `@Seongho-Bae`, any policy that still requires at least one non-author approval or required CODEOWNERS review remains operationally unsatisfiable. + +## Constraints + +- Preserve required CI checks, linear history, non-fast-forward prevention, and branch deletion protection. +- Avoid admin bypass merges as the normal path. +- Keep repository truth sources aligned with live GitHub settings. +- Keep the change MECE: solve the reviewer-capacity deadlock itself, not unrelated code paths already green in PR #34 and PR #35. +- Leave a durable trail for later re-tightening once reviewer capacity exists. + +## Approaches considered + +### 1. Keep the current 2-approval + CODEOWNERS + last-push policy + +- Pros: strongest branch-protection posture and best future Scorecard branch-protection signal. +- Cons: not executable today because no non-author reviewer exists; leaves PR #34, PR #35, issue #32, issue #40, and issue #41 blocked. +- Verdict: reject for the current canonical task because it does not close the live blocker. + +### 2. Merge through admin or other bypass mechanics without changing the ruleset + +- Pros: would unblock the two PRs quickly. +- Cons: does not remove the root cause, weakens auditability, conflicts with the repository preference against protection bypass as a normal workflow, and leaves the next PR deadlocked again. +- Verdict: reject because it treats the symptom instead of the cause. + +### 3. Phased single-maintainer ruleset alignment (recommended) + +- Temporarily align the live ruleset with actual maintainer capacity by keeping PR-only flow and required checks/history protections, but removing the currently unsatisfiable mandatory approval/CODEOWNERS/last-push requirements. +- Update repository docs and governance tests to describe this as a single-maintainer exception rather than a permanent steady state. +- Record the restoration trigger: move back to at least 1 approval + CODEOWNERS + last-push approval once a non-author code owner exists, and only return to 2 approvals when two independent reviewers exist. +- Pros: closes the real blocker, preserves CI/security checks, restores merge/release flow, and documents the trade-off. +- Cons: weakens review-gate strength and may reduce Scorecard branch-protection posture until reviewer capacity grows. + +## Recommended design + +Implement approach 3 with an explicit rollback/re-tightening plan. + +### Components + +1. **Live GitHub ruleset** + - Edit the `mirror-classic-protection-main-develop` ruleset for `main` and `develop`. + - Keep: + - pull-request-only merges + - required status checks + - required linear history + - non-fast-forward prevention + - branch deletion protection + - Relax for the single-maintainer exception: + - `required_approving_review_count = 0` + - `require_code_owner_review = false` + - `require_last_push_approval = false` + +2. **Repository truth sources** + - Update `manual/development.md` and `manual/index.md` so they match the live rules. + - Update `docs/engineering/review-policy.md` to document the single-maintainer exception and the re-tightening trigger. + - Add or update an ADR/governance note capturing why the exception exists and when it must be removed. + +3. **Regression tests** + - Add a failing test first for the new documented governance posture. + - Update repository governance tests so they verify the manual and policy docs describe the temporary single-maintainer exception accurately. + +4. **Delivery continuity** + - Re-verify PR #35 and PR #34 mergeability after the ruleset change. + - Merge them through the normal PR path if they become mergeable. + - Re-check the downstream release path in issue #32 and the tracking issues #40 and #41. + +## Data flow + +1. Capture the current ruleset JSON for rollback evidence. +2. Add/update failing governance-doc tests. +3. Update docs/ADR/policy to the intended single-maintainer wording. +4. Run local verification until green. +5. Apply the live ruleset change through the GitHub API. +6. Re-query the ruleset and PR states to confirm the blocker moved. +7. Merge PR #35 and PR #34 if all required checks and mergeability conditions are satisfied. +8. Re-evaluate issue #32, issue #40, issue #41, and code-scanning state. + +## Error handling and rollback + +- Before editing the ruleset, persist the current live ruleset JSON in the design/plan evidence so the prior configuration can be restored. +- If the new policy causes unexpected workflow or merge behavior, restore the saved ruleset payload via the same GitHub API path. +- If post-change verification shows additional hidden blockers, document them in the linked issue/PR comments before moving downstream. + +## Testing strategy + +- **Red**: repository governance/manual test(s) fail because the docs still hard-code the unsatisfiable 2-approval + CODEOWNERS + last-push model. +- **Green**: update docs/tests/policy/ADR until local checks pass. +- **Live verification**: + - `gh api repos/Seongho-Bae/newsdom-api/rulesets/14875805` + - `gh api repos/Seongho-Bae/newsdom-api/rules/branches/develop` + - `gh api repos/Seongho-Bae/newsdom-api/rules/branches/main` + - PR #35 / PR #34 mergeability and check re-checks +- **Repository verification**: + - targeted governance tests + - `uv run pytest` + - `uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100` + - `uv run mkdocs build --strict` + +## Decisions + +- Treat issue #36 as the highest-priority executable canonical task. +- Prefer a documented temporary single-maintainer exception over permanent deadlock or admin bypass merging. +- Preserve non-review branch protections and CI gates while reviewer capacity is absent. diff --git a/docs/plans/2026-04-11-reviewer-capacity-ruleset-alignment.md b/docs/plans/2026-04-11-reviewer-capacity-ruleset-alignment.md new file mode 100644 index 00000000..1a591f48 --- /dev/null +++ b/docs/plans/2026-04-11-reviewer-capacity-ruleset-alignment.md @@ -0,0 +1,299 @@ +# Reviewer-capacity ruleset alignment Implementation Plan + +> **Execution note:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Remove the unsatisfiable reviewer-capacity deadlock on `main` and `develop`, keep non-review branch protections intact, and align repository docs/tests with the live single-maintainer exception. + +**Architecture:** Update repository governance truth sources and regression tests first, then apply the live GitHub ruleset change with a durable rollback payload, re-verify mergeability for PR #35 and PR #34, and close the now-stale blocker tracking issues. Preserve required CI checks, linear history, non-fast-forward protection, and deletion protection throughout. + +**Tech Stack:** Python/pytest, MkDocs, GitHub rulesets via `gh api`, GitHub PR/issue state via `gh`. + +--- + +### Task 1: Add the failing governance-doc regression tests + +**Files:** +- Modify: `tests/test_repository_governance.py` +- Modify: `tests/test_engineering_canonical_docs.py` + +**Step 1: Write the failing tests** + +Add a new manual-governance expectation and a new review-policy expectation. + +```python +def test_development_manual_documents_single_maintainer_exception() -> None: + manual_text = Path("manual/development.md").read_text(encoding="utf-8") + for phrase in ( + "단일 유지보수자 예외", + "필수 상태 체크", + "리뷰어 용량이 확보되면", + "CODEOWNERS", + ): + assert phrase in manual_text + + +def test_review_policy_documents_single_maintainer_exception() -> None: + text = Path("docs/engineering/review-policy.md").read_text(encoding="utf-8").lower() + for expected in ( + "single-maintainer", + "reviewer capacity", + "required checks", + "re-tighten", + ): + assert expected in text +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_repository_governance.py tests/test_engineering_canonical_docs.py -q` + +Expected: FAIL because the current docs still describe the unsatisfiable 2-approval + CODEOWNERS + last-push model and do not mention a single-maintainer exception. + +**Step 3: Commit the red test changes only after you have seen the failure** + +```bash +git add tests/test_repository_governance.py tests/test_engineering_canonical_docs.py +git commit -m "test: add failing reviewer-capacity governance expectations" +``` + +### Task 2: Update durable governance truth sources + +**Files:** +- Modify: `manual/development.md` +- Modify: `manual/index.md` +- Modify: `docs/engineering/review-policy.md` +- Add: `docs/adr/0002-single-maintainer-review-exception.md` + +**Step 1: Write the minimal documentation changes** + +Update the manual and review-policy docs so they all say the same thing: + +- `main` and `develop` still require PR-based flow, required checks, linear history, non-fast-forward prevention, and thread resolution. +- While the repository has only one maintainer, review requirements are running under a documented **single-maintainer exception**. +- The stronger steady-state target is restored only after non-author reviewer capacity exists. + +Use wording equivalent to the following in `manual/development.md`: + +```md +### ✅ GitHub 보호 규칙과 단일 유지보수자 예외 + +`main` 및 `develop` 브랜치에는 GitHub ruleset이 적용되어 있으며, 현재는 +단일 유지보수자 저장소 예외로 운영됩니다. + +- Pull Request를 거쳐서만 병합할 수 있습니다. +- `pytest`, `scorecard`, `codeql (python, actions)`, `dependency-review`, + `quality-gate` 필수 체크는 계속 강제됩니다. +- 선형 히스토리, force-push 금지, 브랜치 삭제 금지는 계속 유지됩니다. +- 리뷰어 용량이 확보되면 `1명 이상의 비작성자 승인 + CODEOWNERS + 마지막 푸시 승인` + 정책으로 다시 강화하고, 두 명의 독립 리뷰어가 확보되면 그때 2명 승인으로 올립니다. +``` + +Use wording equivalent to the following in the new ADR: + +```md +# ADR-0002: Single-maintainer protected-branch review exception + +## Status + +Accepted + +## Decision + +Temporarily relax mandatory approval/CODEOWNERS/last-push review gates while the +repository has only one maintainer, but keep PR-only flow, required checks, and +history protections in place. + +## Revisit trigger + +Restore at least 1 non-author approval + CODEOWNERS + last-push approval as soon +as one non-author code owner exists, and restore 2 approvals only when two +independent reviewers exist. +``` + +**Step 2: Run the targeted tests to verify they now pass** + +Run: `uv run pytest tests/test_repository_governance.py tests/test_engineering_canonical_docs.py -q` + +Expected: PASS. + +**Step 3: Commit the docs + test green state** + +```bash +git add tests/test_repository_governance.py tests/test_engineering_canonical_docs.py \ + manual/development.md manual/index.md docs/engineering/review-policy.md \ + docs/adr/0002-single-maintainer-review-exception.md +git commit -m "docs: record single-maintainer review exception" +``` + +### Task 3: Re-run full repository verification before touching live GitHub rules + +**Files:** +- Modify: none + +**Step 1: Run the test suite** + +Run: `uv run pytest` + +Expected: PASS. + +**Step 2: Run the coverage gate** + +Run: `uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100` + +Expected: PASS with 100% coverage. + +**Step 3: Run the manual/docs build** + +Run: `uv run mkdocs build --strict` + +Expected: PASS. + +**Step 4: Commit only if any verification-driven file change was necessary** + +```bash +git add +git commit -m "fix: align governance docs with verification feedback" +``` + +### Task 4: Capture the current ruleset and apply the live single-maintainer exception + +**Files:** +- Add: `docs/plans/2026-04-11-reviewer-capacity-ruleset-before.json` + +**Step 1: Capture the rollback payload** + +Run: + +```bash +gh api repos/Seongho-Bae/newsdom-api/rulesets/14875805 > docs/plans/2026-04-11-reviewer-capacity-ruleset-before.json +``` + +Expected: the file contains the current 2-approval + CODEOWNERS + last-push payload for rollback. + +**Step 2: Verify the current live state one more time** + +Run: + +```bash +gh api repos/Seongho-Bae/newsdom-api/rules/branches/develop && gh api repos/Seongho-Bae/newsdom-api/rules/branches/main +``` + +Expected: both branches still show `required_approving_review_count: 2`, `require_code_owner_review: true`, and `require_last_push_approval: true`. + +**Step 3: Apply the minimal ruleset change** + +Run: + +```bash +gh api --method PUT repos/Seongho-Bae/newsdom-api/rulesets/14875805 \ + --input - <<'EOF' +{ + "name": "mirror-classic-protection-main-develop", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": ["refs/heads/main", "refs/heads/develop"] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["merge", "squash", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": true, + "required_status_checks": [ + {"context": "pytest", "integration_id": 15368}, + {"context": "scorecard", "integration_id": 15368}, + {"context": "codeql (python, actions)", "integration_id": 15368}, + {"context": "dependency-review"}, + {"context": "quality-gate"} + ] + } + }, + {"type": "required_linear_history"}, + {"type": "non_fast_forward"}, + {"type": "deletion"} + ], + "bypass_actors": [] +} +EOF +``` + +Expected: GitHub returns the updated ruleset JSON. + +**Step 4: Re-query the ruleset to verify the live change** + +Run: + +```bash +gh api repos/Seongho-Bae/newsdom-api/rulesets/14875805 +``` + +Expected: `required_approving_review_count` is `0`, `require_code_owner_review` is `false`, `require_last_push_approval` is `false`, and the required status checks/history protections are unchanged. + +**Step 5: Commit the durable rollback payload** + +```bash +git add docs/plans/2026-04-11-reviewer-capacity-ruleset-before.json +git commit -m "docs: capture pre-exception ruleset payload" +``` + +### Task 5: Verify PR continuity, mergeability, and close the blocker chain + +**Files:** +- Modify: none + +**Step 1: Re-check PR #35 and PR #34 state** + +Run: + +```bash +gh pr view 35 --json reviewDecision,mergeStateStatus,statusCheckRollup,url && \ +gh pr view 34 --json reviewDecision,mergeStateStatus,statusCheckRollup,url +``` + +Expected: checks remain green and the merge blocker is no longer `REVIEW_REQUIRED`. + +**Step 2: Merge PR #35 first** + +Run: `gh pr merge 35 --merge --delete-branch=false` + +Expected: PR #35 merges into `develop` without bypass. + +**Step 3: Merge PR #34 second** + +Run: `gh pr merge 34 --merge --delete-branch=false` + +Expected: PR #34 merges into `main` without bypass. + +**Step 4: Close/update the tracking issues** + +Run equivalent GitHub updates: + +- add a closing comment to issue #36 explaining the ruleset alignment and merged PRs +- close issue #36 as completed +- close issue #40 and issue #41 because their blocked changes have now landed on `develop` + +**Step 5: Re-check downstream release readiness** + +Run: + +```bash +gh issue view 32 --comments && gh pr list --state open +``` + +Expected: issue #32 is now the next executable canonical task and open PR inventory no longer includes #34 or #35. diff --git a/docs/plans/2026-04-11-reviewer-capacity-ruleset-before.json b/docs/plans/2026-04-11-reviewer-capacity-ruleset-before.json new file mode 100644 index 00000000..cff475f8 --- /dev/null +++ b/docs/plans/2026-04-11-reviewer-capacity-ruleset-before.json @@ -0,0 +1,84 @@ +{ + "id": 14875805, + "name": "mirror-classic-protection-main-develop", + "target": "branch", + "source_type": "Repository", + "source": "Seongho-Bae/newsdom-api", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main", + "refs/heads/develop" + ] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "require_last_push_approval": true, + "required_review_thread_resolution": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": true, + "required_status_checks": [ + { + "context": "pytest", + "integration_id": 15368 + }, + { + "context": "scorecard", + "integration_id": 15368 + }, + { + "context": "codeql (python, actions)", + "integration_id": 15368 + }, + { + "context": "dependency-review" + }, + { + "context": "quality-gate" + } + ] + } + }, + { + "type": "required_linear_history" + }, + { + "type": "non_fast_forward" + }, + { + "type": "deletion" + } + ], + "node_id": "RRS_lACqUmVwb3NpdG9yec5H0hDuzgDi_J0", + "created_at": "2026-04-09T22:56:09.651+09:00", + "updated_at": "2026-04-10T14:55:40.590+09:00", + "bypass_actors": [], + "current_user_can_bypass": "never", + "_links": { + "self": { + "href": "https://api.github.com/repos/Seongho-Bae/newsdom-api/rulesets/14875805" + }, + "html": { + "href": "https://github.com/Seongho-Bae/newsdom-api/rules/14875805" + } + } +} diff --git a/docs/security/api-security-checklist.md b/docs/security/api-security-checklist.md new file mode 100644 index 00000000..86be69fd --- /dev/null +++ b/docs/security/api-security-checklist.md @@ -0,0 +1,29 @@ +# API security checklist + +Apply this checklist whenever the FastAPI surface changes. + +## In-scope endpoints + +- `/health` +- `/parse` +- `/docs` +- `/redoc` + +## Baseline checks + +- validate upload handling and content-type expectations for `/parse` +- ensure error messages do not leak private reference paths, + secrets, or credentials +- keep synthetic fixtures in tests and examples; never use private + reference inputs in public evidence +- verify request handling fails safely when MinerU is missing or + returns incomplete outputs +- confirm docs endpoints remain informational and do not imply + unsupported authentication or execution guarantees + +## Verification expectations + +- unit/integration tests for parsing and error handling +- live localhost smoke for `/health`, `/docs`, and `/redoc` when docs + or screenshots change +- PR/workflow review whenever GitHub Actions, release, or code-scanning posture changes diff --git a/docs/workflow/git-flow.md b/docs/workflow/git-flow.md index 3dd6cd72..22980ce3 100644 --- a/docs/workflow/git-flow.md +++ b/docs/workflow/git-flow.md @@ -1,6 +1,7 @@ # Git Flow -This repository uses a manual classic Git Flow model implemented with GitHub branches and pull requests. +This repository uses a manual classic Git Flow model implemented with +GitHub branches and pull requests. ## Branch roles @@ -9,8 +10,10 @@ This repository uses a manual classic Git Flow model implemented with GitHub bra - `feature/`: new work cut from `develop`, merged back into `develop` - `fix/`: non-release fixes cut from `develop`, merged back into `develop` - `chore/`: maintenance work cut from `develop`, merged back into `develop` -- `release/vX.Y.Z`: release hardening branch cut from `develop`, merged into `main` and back into `develop` -- `hotfix/`: urgent production fix cut from `main`, merged into `main` and back into `develop` +- `release/vX.Y.Z`: release hardening branch cut from `develop`, + merged into `main` and back into `develop` +- `hotfix/`: urgent production fix cut from `main`, + merged into `main` and back into `develop` ## Daily workflow @@ -42,6 +45,9 @@ This repository uses a manual classic Git Flow model implemented with GitHub bra ## Notes -- This rollout intentionally avoids `git flow init` and any git config mutation. -- The branch model is enforced by repository convention, GitHub default-branch settings, and pull request guidance. -- `main` and `develop` are intended to be protected branches with pull-request-based updates and CI checks. +- This rollout intentionally avoids `git flow init` and any git + config mutation. +- The branch model is enforced by repository convention, GitHub + default-branch settings, and pull request guidance. +- `main` and `develop` are intended to be protected branches with + pull-request-based updates and CI checks. diff --git a/docs/workflow/one-day-delivery-plan.md b/docs/workflow/one-day-delivery-plan.md new file mode 100644 index 00000000..cfd3786d --- /dev/null +++ b/docs/workflow/one-day-delivery-plan.md @@ -0,0 +1,15 @@ +# One-day delivery plan + +Use this repository-default execution loop for a focused delivery day. + +1. Re-check git status, open PRs, issues, workflow runs, and + code-scanning state. +2. Pick the highest-priority executable task. +3. Run TDD and implement the minimal production-grade fix. +4. Update docs, plans, and PR comments so truth sources stay aligned. +5. Verify local tests, docs build, and any required live smoke. +6. Push, maintain PR continuity, and continue with the next + executable task. + +If the current PR is blocked externally, keep working on adjacent +repository-local tasks instead of stopping. diff --git a/docs/workflow/pr-continuity.md b/docs/workflow/pr-continuity.md new file mode 100644 index 00000000..4f317c40 --- /dev/null +++ b/docs/workflow/pr-continuity.md @@ -0,0 +1,23 @@ +# PR continuity + +## Canonical PR selection + +- One branch should map to one canonical PR. +- Prefer the already-open PR for the branch before creating a new + one. +- Use stacked PRs only when the follow-up task is distinct and the + parent branch is not mergeable yet. + +## Repository branch targets + +- `feature/*`, `fix/*`, `chore/*` normally target `develop`. +- `release/*` targets `main` and is then back-merged into `develop`. +- `hotfix/*` targets `main` and is then back-merged into `develop`. + +## Blocker handling + +- If a PR is code/CI green but blocked by reviewer capacity or branch + policy, keep the PR open and link the blocker issue. +- Do not open duplicate PRs for the same head branch. +- Re-check merge state, review decision, and required checks before + every merge-path action. diff --git a/fuzzers/dom_builder_fuzzer.py b/fuzzers/dom_builder_fuzzer.py index 072f95d8..7782ef44 100644 --- a/fuzzers/dom_builder_fuzzer.py +++ b/fuzzers/dom_builder_fuzzer.py @@ -8,7 +8,11 @@ from pathlib import Path from typing import Any -from newsdom_api.dom_builder import build_dom +SRC_ROOT = Path(__file__).resolve().parents[1] / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from newsdom_api.dom_builder import build_dom # noqa: E402 def _coerce_content_list(candidate: Any) -> list[dict[str, Any]]: @@ -40,11 +44,14 @@ def _run_smoke(seed_path: Path) -> None: def main(argv: list[str] | None = None) -> int: """Run either deterministic smoke mode or Atheris fuzz mode.""" + raw_argv = list(sys.argv[1:] if argv is None else argv) parser = argparse.ArgumentParser() parser.add_argument("--smoke", type=Path) - args = parser.parse_args(argv) + args, fuzz_args = parser.parse_known_args(raw_argv) if args.smoke is not None: + if fuzz_args: + parser.error(f"unrecognized arguments: {' '.join(fuzz_args)}") _run_smoke(args.smoke) return 0 @@ -53,7 +60,8 @@ def main(argv: list[str] | None = None) -> int: def test_one_input(data: bytes) -> None: exercise_dom_builder(data) - atheris.Setup(sys.argv, test_one_input) + program_name = sys.argv[0] if argv is None else Path(__file__).name + atheris.Setup([program_name, *fuzz_args], test_one_input) atheris.Fuzz() return 0 diff --git a/manual/api-reference.md b/manual/api-reference.md index aa0b9ce4..a97d593c 100644 --- a/manual/api-reference.md +++ b/manual/api-reference.md @@ -4,11 +4,11 @@ FastAPI 서버를 실행하고, 스캔된 신문 PDF를 업로드하여 기사 D ## 1. 서버 실행하기 -가상환경을 활성화한 상태에서, `uvicorn`을 이용하여 API 서버(`src/newsdom_api/main.py`)를 구동합니다. +저장소 루트에서 `uv run uvicorn`을 이용하여 API 서버(`src/newsdom_api/main.py`)를 구동합니다. ```bash # 개발 시 핫-리로딩(--reload) 모드 사용 -uvicorn newsdom_api.main:app --reload +uv run uvicorn --app-dir src newsdom_api.main:app --reload ``` ## 2. 인터랙티브 웹 UI @@ -19,6 +19,18 @@ FastAPI는 OpenAPI 기반의 대화형 API 문서를 자동으로 생성합니 - **Swagger UI**: `http://127.0.0.1:8000/docs` - **ReDoc**: `http://127.0.0.1:8000/redoc` +실제 로컬 서버에서 검증한 UI 캡처는 아래와 같습니다. +스크린샷 파일은 `manual/assets/swagger-ui.png`, +`manual/assets/redoc.png` 경로에 보관합니다. +이 이미지는 `uv run uvicorn --app-dir src newsdom_api.main:app --host +127.0.0.1 --port 8000` 명령으로 서버를 띄운 뒤 `/docs`와 `/redoc`를 +실제로 캡처한 결과입니다. 일반 실행은 저장소 루트에서 +`uv run uvicorn --app-dir src newsdom_api.main:app --reload`를 사용하면 됩니다. + +![Swagger UI 화면](assets/swagger-ui.png) + +![ReDoc 화면](assets/redoc.png) + 해당 페이지에서 **`/parse`** 버튼을 클릭한 후, `Try it out` 기능을 통해 파일을 직접 첨부하고 결과물을 테스트해 볼 수 있습니다. --- @@ -27,9 +39,12 @@ FastAPI는 OpenAPI 기반의 대화형 API 문서를 자동으로 생성합니 ### `POST /parse` -스캔된 일본어 신문 PDF 문서를 업로드 받아, 임시 디렉토리에 저장 후 `mineru` 파이프라인을 백그라운드로 실행하고 변환 결과를 기사 단위 DOM 구조가 담긴 JSON 형태로 반환합니다. +스캔된 일본어 신문 PDF 문서를 업로드 받아, 임시 디렉토리에 저장한 뒤 +`mineru` 파이프라인을 백그라운드로 실행하고 변환 결과를 기사 단위 DOM +구조가 담긴 JSON 형태로 반환합니다. #### 요청 매개변수 (Request Body) + - **`file`** (`UploadFile`, 필수): 변환할 PDF 바이너리 파일 데이터 (`multipart/form-data`) #### cURL 테스트 예제 @@ -135,10 +150,17 @@ else: ``` #### 스키마 주요 노드 설명 -1. **`BoundingBox` (`bbox`)**: 요소가 위치한 직사각형 영역 `(x0, y0, x1, y1)`. 신문 지면 상에서의 절대적 물리 좌표를 나타냅니다. -2. **`PageNode`**: 단일 신문 지면입니다. 여러 개의 기사(`articles`)와 광고(`ads`), 상단 헤더 정보(`headers`)를 포함합니다. -3. **`ArticleNode`**: 가장 핵심적인 DOM 요소로, 제목(`headline`), 문단 블록 배열(`body_blocks`), 기사에 속한 이미지 목록(`images`), 기사 내 독립된 사진 설명(`captions`)으로 구성됩니다. -4. **`ParseQuality`**: 변환 결과를 검증하기 위한 상태, 사용된 파서 엔진 정보(`mineru`), 주의해야할 품질 문제(`warnings`) 등의 메타데이터를 갖습니다. + +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/assets/redoc.png b/manual/assets/redoc.png new file mode 100644 index 0000000000000000000000000000000000000000..a36700e92fdbba956a53e5942972189b5a44b31e GIT binary patch literal 96356 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 literal 0 HcmV?d00001 diff --git a/manual/assets/swagger-ui.png b/manual/assets/swagger-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..db0c635f3b8d0ea49d91c90df81b3751cf3a9e99 GIT binary patch literal 73302 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%0A@7x+Y{=a_JlF7v&??- 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 **주의**: 위 사항들은 `.gitignore` 파일에 등재되어 있다 하더라도, 절대 `git add` 시 실수로 끼워 넣지 않도록 주의해야 합니다. 픽스처 생성 이력 및 재생성에 관한 문서는 `tests/fixtures/README.md`를 참고하십시오. +> **주의**: 위 사항들은 `.gitignore` 파일에 등재되어 있다 하더라도, +> 절대 `git add` 시 실수로 끼워 넣지 않도록 주의해야 합니다. 픽스처 +> 생성 이력 및 재생성에 관한 문서는 `tests/fixtures/README.md`를 +> 참고하십시오. --- ## 2. 프라이빗 베이스라인 (Private Baseline) 업데이트 -로컬 컴퓨터에서 원본 참조 문서(Private Page)를 가지고 비공개로 파이프라인/모델 성능 개선 테스트를 진행할 경우, **원본 데이터를 저장소로 올리면 안 되며, 파생된 구조적 베이스라인(JSON)만 갱신해야 합니다.** +로컬 컴퓨터에서 원본 참조 문서(Private Page)를 가지고 비공개로 +파이프라인 또는 모델 성능 개선 테스트를 진행할 경우, **원본 데이터를 +저장소로 올리면 안 되며, 파생된 구조적 베이스라인(JSON)만 갱신해야 +합니다.** 로컬 베이스라인 갱신 시 `tools/` 디렉토리에 제공된 전용 스크립트를 사용하십시오: @@ -39,9 +49,11 @@ python tools/derive_private_baseline.py tests/fixtures/private_page_baseline.jso ## 3. Git 워크플로우 (Branch Model) -이 프로젝트는 `git-flow init`과 같은 플러그인을 쓰지 않는 수동 **클래식 Git Flow 모델**(`docs/workflow/git-flow.md`)을 강제합니다. +이 프로젝트는 `git-flow init`과 같은 플러그인을 쓰지 않는 수동 +**클래식 Git Flow 모델**(`docs/workflow/git-flow.md`)을 강제합니다. ### 🌿 브랜치 규칙 + - **`main` 브랜치**: 안정적인 릴리즈(Stable Release) 전용. 직접 푸시 금지. - **`develop` 브랜치**: 모든 새로운 작업의 시작점이자 통합 브랜치. - **`feature/`, `fix/`, `chore/`**: @@ -49,12 +61,43 @@ python tools/derive_private_baseline.py tests/fixtures/private_page_baseline.jso - 작업 완료 후 `develop` 브랜치를 향해 Pull Request를 엽니다. - **`release/vX.Y.Z` 브랜치**: - 제품 릴리즈 준비 시 `develop`에서 파생합니다. - - 릴리즈 및 안정화가 완료되면 `main`에 병합(Merge)한 후 버전을 태깅(Tag)하고, 변경 사항을 다시 `develop`으로 백머지(Back-merge)해야 합니다. + - 릴리즈 및 안정화가 완료되면 `main`에 병합(Merge)한 후 버전을 + 태깅(Tag)하고, 변경 사항을 다시 `develop`으로 백머지(Back-merge) + 해야 합니다. - **`hotfix/` 브랜치**: - 운영(Production) 장애 등 긴급 패치 시 `main`에서 파생합니다. - 수정 완료 후 `main`과 `develop` 모두에 병합해야 합니다. -모든 작업은 반드시 **Pull Request (PR)** 를 거쳐 병합되어야 하며, 로컬 저장소 컨벤션과 GitHub 설정(Default-branch protection)으로 강제됩니다. +모든 작업은 반드시 **Pull Request (PR)** 를 거쳐 병합되어야 하며, +로컬 저장소 컨벤션과 GitHub 설정(Default-branch protection)으로 +강제됩니다. + +### ✅ GitHub 보호 규칙과 단일 유지보수자 예외 + +`main` 및 `develop` 브랜치에는 GitHub ruleset이 적용되어 있으며, 단순 +관행이 아니라 저장소 설정으로 PR 기반 병합과 보호 규칙을 강제합니다. + +- Pull Request를 거치지 않고는 보호 브랜치로 병합할 수 없습니다. +- 리뷰 스레드가 모두 해결되어야 합니다. +- 필수 상태 체크로 `pytest`, `scorecard`, + `codeql (python, actions)`, `dependency-review`, `quality-gate`가 + 통과해야 합니다. +- 선형 히스토리, force-push 금지, 브랜치 삭제 금지는 계속 유지됩니다. + +현재 저장소는 단일 유지보수자(single-maintainer) 상태이므로, 비작성자 +리뷰어가 없는 동안에는 unsatisfiable한 필수 승인 규칙을 임시 예외로 +운영합니다. 즉, 현재는 `CODEOWNERS`/승인/마지막 푸시 승인을 강제하지 +않지만, 리뷰어 용량이 확보되면 즉시 다시 강화합니다. + +- 첫 번째 비작성자 코드 오너가 합류하면 `1명 이상의 비작성자 승인 + + CODEOWNERS + 마지막 푸시 승인` 정책으로 되돌립니다. +- 두 명의 독립 리뷰어가 확보되면 그때 보호 브랜치 기준을 다시 최소 + **2명의 승인**으로 올립니다. + +즉, 워크플로 수정, 문서 변경, 보안 설정 변경을 포함한 모든 사용자 영향 +변경은 지금도 PR과 필수 검증을 통과한 뒤에만 보호 브랜치로 들어가며, +리뷰 게이트는 reviewer capacity가 현실적으로 만족되는 시점에 다시 +re-tighten합니다. --- @@ -62,7 +105,7 @@ python tools/derive_private_baseline.py tests/fixtures/private_page_baseline.jso 코드를 탐색하기 위한 핵심 프로젝트 폴더 아키텍처입니다: -- **`src/newsdom_api/`**: +- **`src/newsdom_api/`**: - `main.py`: FastAPI 서버 및 라우팅 (API 진입점) - `schemas.py`: Pydantic 기반 DOM 데이터 직렬화 모델 (`PageNode`, `ArticleNode` 등) - `service.py`: 비즈니스 로직. PDF 업로드를 받고 파서를 거쳐 DOM 빌더로 연결 diff --git a/manual/index.md b/manual/index.md index b808afa1..5f9df252 100644 --- a/manual/index.md +++ b/manual/index.md @@ -2,39 +2,72 @@ [![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(Document Object Model)과 유사한 기사(Article) 단위의 트리 구조로 파싱해주는 API 서비스입니다. +**NewsDOM API**는 스캔된 일본어 신문 PDF 문서를 분석하여, 웹 +브라우저의 DOM(Document Object Model)과 유사한 기사(Article) 단위의 +트리 구조로 파싱해주는 API 서비스입니다. -과거의 신문 이미지는 단순 텍스트 추출(OCR)만으로는 다단 레이아웃이나 이미지/캡션, 기사별 흐름을 파악하기 매우 어렵습니다. 본 프로젝트는 딥러닝 기반 레이아웃 분석 도구인 **MinerU**를 백엔드로 사용하여 이 문제를 해결합니다. +과거의 신문 이미지는 단순 텍스트 추출(OCR)만으로는 다단 레이아웃, +이미지 또는 캡션, 기사별 흐름을 파악하기 매우 어렵습니다. 본 +프로젝트는 딥러닝 기반 레이아웃 분석 도구인 **MinerU**를 백엔드로 +사용하여 이 문제를 해결합니다. --- +## 품질 및 보안 게이트 + +- `main`, `develop` 보호 브랜치는 GitHub ruleset으로 관리됩니다. +- 현재는 단일 유지보수자 예외로 PR + 필수 상태 체크 + 히스토리 보호를 + 유지하고, reviewer capacity가 생기면 `CODEOWNERS`, 비작성자 승인, + 마지막 푸시 승인, 그리고 최종적으로 2명의 승인까지 다시 강화합니다. +- 필수 체크는 `pytest`, `scorecard`, + `codeql (python, actions)`, `dependency-review`, `quality-gate`입니다. +- API 웹 콘솔 스크린샷은 실제 로컬 FastAPI 서버(`/docs`, `/redoc`)에서 + 검증한 뒤 `manual/assets/`에 보관합니다. + ## ⚙️ 시스템 내부 아키텍처 (Under the Hood) 사용자가 API를 통해 PDF 파일을 업로드하면, NewsDOM 내부에서는 다음의 세 단계를 거쳐 데이터를 처리합니다: ### 1. 서비스 래퍼 레이어 (`src/newsdom_api/service.py`) -FastAPI 엔드포인트(`/parse`)가 `UploadFile`로 전달받은 바이너리 데이터를 임시 디렉토리(Temporary Directory)에 저장한 후, 파이프라인 러너를 호출합니다. + +FastAPI 엔드포인트(`/parse`)가 `UploadFile`로 전달받은 바이너리 +데이터를 임시 디렉토리(Temporary Directory)에 저장한 후, 파이프라인 +러너를 호출합니다. ### 2. MinerU 파이프라인 러너 (`src/newsdom_api/mineru_runner.py`) -저장된 PDF를 대상으로 Python `subprocess` 모듈을 이용해 **MinerU CLI**를 백그라운드에서 실행합니다. 실제 내부적으로 실행되는 명령어는 다음과 같습니다: + +저장된 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` 결과물을 메모리로 로드합니다. +> *참고: 일본어 신문 처리에 최적화하기 위해 `-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)**로 재구성합니다. + +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`를 생성 +- `text_level == 1` 이거나 `role == "section_headings"`인 경우 새로운 + 기사의 시작(Headline)으로 인식하여 새 `ArticleNode`를 생성 - 이후 등장하는 일반 텍스트는 해당 기사의 `body_blocks` 배열에 추가 - `type == "image"` 이면 `ImageNode`를 생성하고 포함된 캡션 배열을 파싱하여 기사에 종속시킴 -이러한 세밀한 내부 변환 과정을 통해 단순한 OCR 텍스트 덤프가 아닌, 프론트엔드에서 즉시 렌더링이 가능한 **구조화된 DOM 데이터**가 최종 반환됩니다. +이러한 세밀한 내부 변환 과정을 통해 단순한 OCR 텍스트 덤프가 아닌, +프론트엔드에서 즉시 렌더링이 가능한 **구조화된 DOM 데이터**가 최종 +반환됩니다. --- -👉 **[설치 가이드](installation.md)**를 읽고 직접 환경을 구성해 보세요. \ No newline at end of file +👉 **[설치 가이드](installation.md)**를 읽고 직접 환경을 구성해 보세요. diff --git a/manual/installation.md b/manual/installation.md index 501a666c..1c50e34d 100644 --- a/manual/installation.md +++ b/manual/installation.md @@ -5,50 +5,55 @@ ## 🛠️ 시스템 요구사항 - **Python**: Required: `>=3.10, <3.14` -- **운영체제**: Linux 또는 macOS 권장 (윈도우의 경우 WSL2 사용 권장) -- **하드웨어 (GPU)**: `MinerU` 딥러닝 기반 파이프라인을 구동하기 위해서는 최소 **8GB 이상의 RAM**이 필요하며, 실시간 처리를 위해 **NVIDIA GPU(CUDA 11.x/12.x 호환)** 및 `PyTorch` 환경이 권장됩니다. +- **운영체제**: Linux 또는 macOS 권장 + (윈도우의 경우 WSL2 사용 권장) +- **하드웨어 (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` + - `fastapi>=0.115,<1.0`, `uvicorn>=0.30,<1.0`, + `pydantic>=2.9,<3.0` - `python-multipart`, `reportlab`, `Pillow`, `pypdf` 등 --- ## 1. 기본 테스트 및 개발 모드 설치 -가장 간단한 형태로 파이썬 가상환경(Virtual Environment)을 생성하고 패키지를 설치합니다. 이 모드에서는 실제 `MinerU` 모델이 로드되지 않으며, `pytest`나 합성 픽스처(Synthetic Fixtures) 기반 테스트 용도로 적합합니다. 예시 명령은 `python3.10`을 사용하지만, 지원 범위 안의 다른 인터프리터도 동일하게 사용할 수 있습니다. +가장 간단한 형태로 저장소가 관리하는 가상환경을 `uv`로 동기화합니다. +이 모드에서는 실제 `MinerU` 모델이 로드되지 않으며, `pytest`나 합성 +픽스처(Synthetic Fixtures) 기반 테스트 용도로 적합합니다. `uv sync`는 +저장소 루트의 `.venv`를 자동으로 생성/갱신하므로 별도 `venv` 생성이나 +활성화가 필수는 아닙니다. ```bash -# 가상 환경 생성 (권장 예시: python3.10) -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]" +# 저장소 루트에서 개발/테스트/문서 extras까지 모두 동기화 +uv sync --frozen --all-extras ``` --- ## 2. MinerU 백엔드 포함 실제 파싱 모드 설치 -`MinerU` 백엔드를 사용하여 실제 스캔된 일본어 신문 PDF 파싱 작업을 수행하려면 MinerU CLI를 별도로 설치해야 합니다. +`MinerU` 백엔드를 사용하여 실제 스캔된 일본어 신문 PDF 파싱 작업을 +수행하려면 MinerU CLI를 별도로 설치해야 합니다. ```bash # MinerU 파이프라인 CLI 설치 -pip install "mineru[pipeline]==3.0.9" +uv pip install --python .venv/bin/python "mineru[pipeline]==3.0.9" ``` -이 명령어를 통해 **`mineru[pipeline]==3.0.9`** 버전이 설치되며 딥러닝 기반 모델을 위한 준비가 완료됩니다. 설치 후 처음 API 서버를 구동하고 PDF를 파싱할 때 모델(Weight) 파일을 백그라운드에서 다운로드할 수 있으므로, 첫 실행에는 다운로드 대기 시간이 발생할 수 있습니다. +Windows에서는 `.venv/bin/python` 대신 `.venv\Scripts\python.exe` 경로를 사용하세요. + +이 명령어를 통해 **`mineru[pipeline]==3.0.9`** 버전이 설치되며 +딥러닝 기반 모델을 위한 준비가 완료됩니다. 설치 후 처음 API 서버를 +구동하고 PDF를 파싱할 때 모델(Weight) 파일을 백그라운드에서 다운로드할 +수 있으므로, 첫 실행에는 다운로드 대기 시간이 발생할 수 있습니다. ### 커스텀 MinerU 실행 경로 (고급) -만약 `mineru` CLI 바이너리가 시스템 PATH에 잡혀있지 않거나, 특정 가상환경의 실행 파일을 수동으로 지정하고 싶다면 환경변수를 설정하세요: + +만약 `mineru` CLI 바이너리가 시스템 PATH에 잡혀 있지 않거나, +특정 가상환경의 실행 파일을 수동으로 지정하고 싶다면 환경변수를 +설정하세요. ```bash # newsdom_api/mineru_runner.py 에서 이 환경변수를 우선 탐색합니다. @@ -63,12 +68,25 @@ export NEWSDOM_MINERU_BIN="/path/to/custom/mineru" ```bash # 파이썬 경고(Warning)를 에러로 취급하여 꼼꼼하게 검사 -PYTHONWARNINGS=error pytest +PYTHONWARNINGS=error uv run pytest +``` + +현재 저장소에는 별도의 `integration` 마커 테스트 묶음이 없으므로, +설치 확인의 기준은 기본 `pytest` 스위트 통과입니다. 추가로 MinerU +경로와 API 동작까지 확인하려면 서버를 직접 띄운 뒤 수동 API 점검 +단계를 수행하세요. -# 통합 테스트 포함 실행 (실제 MinerU CLI 및 다운로드된 모델 파일 필요) -pytest -m "integration" +```bash +# 별도 터미널에서 API 서버 기동 +uv run uvicorn --app-dir src newsdom_api.main:app --host 0.0.0.0 --port 8000 --reload + +# 다른 터미널에서 상태 확인 +curl -sS http://127.0.0.1:8000/health ``` -모든 단위 테스트(`tests/`)가 성공적으로 통과했다면 API 서버를 실행할 준비가 된 것입니다. +정상 응답 예시는 `{"status": "ok"}`이며 HTTP 200 상태 코드를 반환해야 합니다. + +모든 테스트(`tests/`)가 성공적으로 통과했다면 API 서버를 실행할 +준비가 된 것입니다. 👉 다음 단계: **[API 레퍼런스 및 사용 방법](api-reference.md)** diff --git a/pyproject.toml b/pyproject.toml index ecdb65f7..fa1c6723 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ dev = [ "httpx>=0.28,<1.0", "pyyaml>=6.0,<7.0", ] +# Hold the docs toolchain below MkDocs 2.0 until a supported migration path +# is available for the current Material-based manual stack. docs = [ "mkdocs>=1.6,<2.0", "mkdocs-material>=9.6,<9.7", diff --git a/scripts/release/export_release_attestations.py b/scripts/release/export_release_attestations.py index 6767b59e..f3becefb 100644 --- a/scripts/release/export_release_attestations.py +++ b/scripts/release/export_release_attestations.py @@ -18,12 +18,22 @@ def _bundle_candidates(working_dir: Path, digest: str) -> tuple[Path, Path]: ) +def _resolve_gh_cli() -> str: + """Resolve the GitHub CLI path required for attestation downloads.""" + + gh_executable = shutil.which("gh") + if gh_executable is None: + raise FileNotFoundError("gh CLI is required to export release attestations") + return gh_executable + + 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() + gh_executable = _resolve_gh_cli() exported: list[Path] = [] for artifact in sorted(dist_dir.iterdir()): @@ -34,12 +44,14 @@ def export_attestations( if artifact.name.endswith(".intoto.jsonl"): continue + artifact_path = artifact.resolve() subprocess.run( - ["gh", "attestation", "download", str(artifact), "-R", repo], + [gh_executable, "attestation", "download", str(artifact_path), "-R", repo], check=True, + cwd=working_dir, ) - digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + digest = hashlib.sha256(artifact_path.read_bytes()).hexdigest() bundle_path = next( ( candidate diff --git a/tests/test_engineering_canonical_docs.py b/tests/test_engineering_canonical_docs.py new file mode 100644 index 00000000..210aaaa9 --- /dev/null +++ b/tests/test_engineering_canonical_docs.py @@ -0,0 +1,139 @@ +from pathlib import Path + +REQUIRED_CANONICAL_DOCS = [ + "AGENTS.md", + "ARCHITECTURE.md", + "docs/agents/README.md", + "docs/coderabbit/review-commands.md", + "docs/engineering/acceptance-criteria.md", + "docs/engineering/canonical-docs.md", + "docs/engineering/execution-policy.md", + "docs/engineering/harness-engineering.md", + "docs/engineering/review-policy.md", + "docs/engineering/runtime-data-policy.md", + "docs/engineering/skills-subagents-mcp.md", + "docs/operations/deploy-runbook.md", + "docs/security/api-security-checklist.md", + "docs/workflow/git-flow.md", + "docs/workflow/one-day-delivery-plan.md", + "docs/workflow/pr-continuity.md", +] + + +def test_repository_ships_engineering_canonical_docs() -> None: + missing = [path for path in REQUIRED_CANONICAL_DOCS if not Path(path).exists()] + assert not missing, f"missing canonical engineering docs: {missing}" + + +def test_repo_local_agents_doc_points_to_authoritative_sources() -> None: + text = Path("AGENTS.md").read_text(encoding="utf-8") + for expected in ( + "docs/engineering/canonical-docs.md", + "docs/engineering/execution-policy.md", + "docs/engineering/acceptance-criteria.md", + "docs/workflow/git-flow.md", + "docs/workflow/pr-continuity.md", + "docs/operations/deploy-runbook.md", + ): + assert expected in text + + +def test_architecture_doc_describes_runtime_modules() -> None: + text = Path("ARCHITECTURE.md").read_text(encoding="utf-8") + for expected in ( + "src/newsdom_api/main.py", + "src/newsdom_api/service.py", + "src/newsdom_api/mineru_runner.py", + "src/newsdom_api/dom_builder.py", + "tests/fixtures", + ): + assert expected in text + + +def test_canonical_docs_index_maps_existing_truth_sources() -> None: + text = Path("docs/engineering/canonical-docs.md").read_text(encoding="utf-8") + for expected in ( + "README.md", + "CONTRIBUTING.md", + "SECURITY.md", + "CHANGELOG.md", + "docs/agents/README.md", + "docs/coderabbit/review-commands.md", + "docs/security/api-security-checklist.md", + "docs/workflow/git-flow.md", + "manual/index.md", + "docs/plans/", + ): + assert expected in text + assert Path(expected).exists(), f"canonical truth source missing: {expected}" + + +def test_runtime_data_policy_protects_private_inputs() -> None: + text = Path("docs/engineering/runtime-data-policy.md").read_text(encoding="utf-8") + for expected in ( + "synthetic fixtures", + "private reference", + "tmp/", + "logs", + "do not commit secrets", + ): + assert expected in text + + +def test_review_policy_covers_review_expectations() -> None: + text = Path("docs/engineering/review-policy.md").read_text(encoding="utf-8").lower() + for expected in ( + "human review", + "coderabbit", + "required checks", + "resolve review comments", + "stale-review dismissal", + ): + assert expected in text + + +def test_review_policy_documents_single_maintainer_exception() -> None: + text = Path("docs/engineering/review-policy.md").read_text(encoding="utf-8").lower() + for expected in ( + "single-maintainer", + "reviewer capacity", + "required checks", + "re-tighten", + ): + assert expected in text + + +def test_api_security_checklist_scopes_live_endpoints() -> None: + text = Path("docs/security/api-security-checklist.md").read_text(encoding="utf-8") + for expected in ( + "/health", + "/docs", + "/redoc", + "/parse", + "content-type", + "synthetic fixtures", + ): + assert expected in text.lower() + + +def test_contributing_maps_new_canonical_docs() -> None: + text = Path("CONTRIBUTING.md").read_text(encoding="utf-8") + for expected in ( + "manual/", + "docs/agents/README.md", + "docs/coderabbit/review-commands.md", + ): + assert expected in text + + +def test_deploy_runbook_matches_release_trigger_and_assets() -> None: + runbook_text = Path("docs/operations/deploy-runbook.md").read_text(encoding="utf-8") + release_workflow = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + + assert "push:" in release_workflow and "tags:" in release_workflow + assert "workflow_dispatch:" in release_workflow + assert "tag push" in runbook_text.lower() + assert "manual dispatch" in runbook_text.lower() + assert "release pr lands on `main`" not in runbook_text.lower() + for expected in ("SHA256SUMS.txt", "release-manifest.json", "*.intoto.jsonl"): + assert expected in runbook_text diff --git a/tests/test_fuzzing_integration.py b/tests/test_fuzzing_integration.py index 48f0c8d1..0b6067b8 100644 --- a/tests/test_fuzzing_integration.py +++ b/tests/test_fuzzing_integration.py @@ -1,6 +1,8 @@ +import importlib.util import subprocess import sys from pathlib import Path +from types import ModuleType def test_clusterfuzzlite_integration_files_exist(): @@ -26,6 +28,7 @@ def test_clusterfuzzlite_workflow_runs_pinned_python_code_change_fuzzing(): assert "mode: code-change" in text assert "fuzz-seconds: 300" in text assert "github-token: ${{ github.token }}" in text + assert "bad-build-check: false" in text def test_clusterfuzzlite_dockerfile_places_build_script_at_src_root(): @@ -41,6 +44,13 @@ def test_clusterfuzzlite_build_script_uses_locked_uv_fuzz_extra(): assert "pip3 install . pyinstaller atheris" not in text +def test_clusterfuzzlite_build_script_iterates_fuzzers_safely() -> None: + text = Path(".clusterfuzzlite/build.sh").read_text(encoding="utf-8") + assert "find fuzzers -type f -name '*_fuzzer.py' -print0" in text + assert "while IFS= read -r -d '' fuzzer; do" in text + assert "for fuzzer in $(find" not in text + + def test_dom_builder_fuzzer_smoke_mode_runs_without_cluster(): completed = subprocess.run( [ @@ -55,3 +65,31 @@ def test_dom_builder_fuzzer_smoke_mode_runs_without_cluster(): ) assert completed.returncode == 0, completed.stderr assert "Traceback" not in completed.stderr + + +def test_dom_builder_fuzzer_forwards_libfuzzer_flags_to_atheris(monkeypatch): + module_path = Path("fuzzers/dom_builder_fuzzer.py") + spec = importlib.util.spec_from_file_location("dom_builder_fuzzer", module_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + fake_atheris = ModuleType("atheris") + captured: dict[str, object] = {} + + def fake_setup(argv, callback): + captured["argv"] = argv + captured["callback"] = callback + + def fake_fuzz(): + captured["fuzz_called"] = True + + fake_atheris.Setup = fake_setup + fake_atheris.Fuzz = fake_fuzz + monkeypatch.setitem(sys.modules, "atheris", fake_atheris) + + result = module.main(["-timeout=1", "-runs=0"]) + + assert result == 0 + assert captured["argv"] == [module_path.name, "-timeout=1", "-runs=0"] + assert captured["fuzz_called"] is True diff --git a/tests/test_manual_docs.py b/tests/test_manual_docs.py index 11710997..27b6083e 100644 --- a/tests/test_manual_docs.py +++ b/tests/test_manual_docs.py @@ -21,13 +21,32 @@ def test_development_doc_uses_tree_wording(): assert "트리 구조의 DOM" in text -def test_installation_doc_uses_quoted_extras_and_clear_python_wording(): +def test_installation_doc_uses_uv_first_setup_and_verification_commands(): text = Path("manual/installation.md").read_text(encoding="utf-8") assert "Required: `>=3.10, <3.14`" in text + assert "uv sync --frozen --all-extras" in text + assert 'uv pip install --python .venv/bin/python "mineru[pipeline]==3.0.9"' in text + assert "uv run pytest" in text + assert "python3.10 -m venv .venv" not in text + assert 'pip install -e ".[dev]"' not in text + + +def test_installation_doc_includes_manual_api_healthcheck_commands(): + text = Path("manual/installation.md").read_text(encoding="utf-8") assert ( - "예시 명령은 `python3.10`을 사용하지만, 지원 범위 안의 다른 인터프리터도 동일하게 사용할 수 있습니다." + "uv run uvicorn --app-dir src newsdom_api.main:app --host 0.0.0.0 --port 8000 --reload" 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 + assert "curl -sS http://127.0.0.1:8000/health" in text + assert "HTTP 200" in text + + +def test_api_reference_uses_uv_run_server_command(): + text = Path("manual/api-reference.md").read_text(encoding="utf-8") + assert "uv run uvicorn --app-dir src newsdom_api.main:app --reload" in text + assert "가상환경을 활성화한 상태" not in text + + +def test_installation_doc_notes_windows_uv_python_path_equivalent(): + text = Path("manual/installation.md").read_text(encoding="utf-8") + assert ".venv\\Scripts\\python.exe" in text diff --git a/tests/test_markdownlint_policy.py b/tests/test_markdownlint_policy.py new file mode 100644 index 00000000..8ef29c84 --- /dev/null +++ b/tests/test_markdownlint_policy.py @@ -0,0 +1,35 @@ +import json +from pathlib import Path + + +EXPECTED_LEGACY_MARKDOWNLINT_IGNORES = [ + "docs/plans/2026-04-08-git-flow-design.md", + "docs/plans/2026-04-08-git-flow.md", + "docs/plans/2026-04-08-newsdom-design.md", + "docs/plans/2026-04-08-newsdom-implementation.md", + "docs/plans/2026-04-08-quality-gate-design.md", + "docs/plans/2026-04-08-quality-gate.md", +] + + +def test_contributing_documents_markdownlint_scope() -> None: + text = Path("CONTRIBUTING.md").read_text(encoding="utf-8").lower() + for expected in ( + "markdownlint", + "legacy", + "agents.md", + "architecture.md", + "contributing.md", + "docs/**/*.md", + "git-flow-design", + "newsdom-implementation", + "quality-gate", + ): + assert expected in text + + +def test_markdownlint_config_limits_ignores_to_legacy_plan_files() -> None: + config = json.loads(Path(".markdownlint-cli2.jsonc").read_text(encoding="utf-8")) + assert config == {"ignores": EXPECTED_LEGACY_MARKDOWNLINT_IGNORES} + for path in EXPECTED_LEGACY_MARKDOWNLINT_IGNORES: + assert Path(path).exists(), f"ignored markdown file missing: {path}" diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 461ef4aa..aa535345 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -1,4 +1,15 @@ from pathlib import Path +import re + + +def _locked_package_version(name: str) -> tuple[int, ...]: + text = Path("uv.lock").read_text(encoding="utf-8") + match = re.search( + rf'\[\[package\]\]\nname = "{re.escape(name)}"\nversion = "([^"]+)"', + text, + ) + assert match is not None, f"package {name!r} missing from uv.lock" + return tuple(int(part) for part in match.group(1).split(".")) def test_project_metadata_does_not_bundle_mineru_extra(): @@ -12,6 +23,24 @@ def test_docs_theme_range_stays_below_warning_release(): assert '"mkdocs-material>=9.6,<9.7"' in text +def test_docs_core_range_stays_below_mkdocs_two(): + text = Path("pyproject.toml").read_text(encoding="utf-8") + assert '"mkdocs>=1.6,<2.0"' in text + + +def test_contributing_documents_docs_toolchain_hold(): + text = Path("CONTRIBUTING.md").read_text(encoding="utf-8") + expected_phrases = [ + "MkDocs 1.x", + "mkdocs<2.0", + "mkdocs-material<9.7", + "uv.lock", + "migration path", + ] + for phrase in expected_phrases: + assert phrase 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 @@ -24,3 +53,7 @@ def test_project_declares_locked_fuzz_extra_without_bundling_nvidia_stack(): assert '"atheris==3.0.0 ;' in text assert '"pyinstaller==6.16.0"' in text assert "nvidia = [" not in text + + +def test_uv_lock_pins_pypdf_at_patched_release(): + assert _locked_package_version("pypdf") >= (6, 10, 0) diff --git a/tests/test_readme.py b/tests/test_readme.py index 38d12efe..72b79bca 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -18,14 +18,33 @@ def test_contributing_mentions_develop_branch(): assert "develop" in text -def test_readme_quotes_dev_extra_install_command(): +def test_readme_uses_uv_sync_for_repo_setup(): text = Path("README.md").read_text(encoding="utf-8") - assert 'pip install -e ".[dev]"' in text + assert "uv sync --frozen --all-extras" in text + assert 'pip install -e ".[dev]"' not in text + assert "python3.10 -m venv .venv" not in text -def test_contributing_quotes_dev_extra_install_command(): +def test_contributing_uses_uv_sync_for_repo_setup(): text = Path("CONTRIBUTING.md").read_text(encoding="utf-8") - assert 'pip install -e ".[dev]"' in text + assert "uv sync --frozen --all-extras" in text + assert 'pip install -e ".[dev]"' not in text + + +def test_readme_documents_uv_run_entrypoints(): + text = Path("README.md").read_text(encoding="utf-8") + assert "uv run uvicorn --app-dir src newsdom_api.main:app --reload" in text + assert "uv run pytest" in text + assert ( + "uv run python fuzzers/dom_builder_fuzzer.py --smoke tests/fixtures/mineru_sample.json" + in text + ) + + +def test_repo_docs_note_windows_uv_python_path_equivalent(): + for path in [Path("README.md"), Path("CONTRIBUTING.md")]: + text = path.read_text(encoding="utf-8") + assert ".venv\\Scripts\\python.exe" in text, path def test_pull_request_template_exists(): diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 15770eaa..15650e6d 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -110,12 +110,17 @@ def test_release_attestation_export_script_writes_named_intoto_files( downloaded = tmp_path / f"sha256:{digest}.jsonl" downloaded.write_text('{"bundle": true}', encoding="utf-8") - calls: list[list[str]] = [] + calls: list[tuple[list[str], bool, Path | None]] = [] - def fake_run(cmd, check): - calls.append(cmd) + def fake_run(cmd, check, cwd=None): + calls.append((cmd, check, cwd)) assert check is True + monkeypatch.setattr( + "scripts.release.export_release_attestations.shutil.which", + lambda executable: "/usr/local/bin/gh" if executable == "gh" else None, + ) + monkeypatch.setattr( "scripts.release.export_release_attestations.subprocess.run", fake_run ) @@ -123,14 +128,18 @@ def fake_run(cmd, check): export_attestations(dist, "Seongho-Bae/newsdom-api", working_dir=tmp_path) assert calls == [ - [ - "gh", - "attestation", - "download", - str(artifact), - "-R", - "Seongho-Bae/newsdom-api", - ] + ( + [ + "/usr/local/bin/gh", + "attestation", + "download", + str(artifact.resolve()), + "-R", + "Seongho-Bae/newsdom-api", + ], + True, + tmp_path, + ) ] assert (dist / "demo.whl.intoto.jsonl").read_text( encoding="utf-8" diff --git a/tests/test_repository_governance.py b/tests/test_repository_governance.py new file mode 100644 index 00000000..0c38953f --- /dev/null +++ b/tests/test_repository_governance.py @@ -0,0 +1,102 @@ +from pathlib import Path + +import yaml + + +def test_codeowners_exists_and_covers_repository() -> None: + codeowners_path = Path(".github/CODEOWNERS") + assert codeowners_path.exists() + rules: dict[str, set[str]] = {} + for raw_line in codeowners_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + pattern, *owners = line.split() + rules[pattern] = set(owners) + + assert "@Seongho-Bae" in rules["*"] + assert "@Seongho-Bae" in rules[".github/"] + assert "@Seongho-Bae" in rules["docs/"] + assert "@Seongho-Bae" in rules["manual/"] + + +def test_codeql_scans_python_and_actions() -> None: + workflow = yaml.safe_load( + Path(".github/workflows/codeql.yml").read_text(encoding="utf-8") + ) + analyze_steps = workflow["jobs"]["analyze"]["steps"] + init_step = next( + step + for step in analyze_steps + if step.get("uses", "").startswith("github/codeql-action/init@") + ) + languages = init_step["with"]["languages"] + if isinstance(languages, str): + normalized_languages = { + language.strip().lower() + for language in languages.split(",") + if language.strip() + } + else: + normalized_languages = { + str(language).strip().lower() + for language in languages + if str(language).strip() + } + + assert "python" in normalized_languages + assert "actions" in normalized_languages + + +def test_api_manual_references_screenshot_assets() -> None: + manual_text = Path("manual/api-reference.md").read_text(encoding="utf-8") + swagger_path = Path("manual/assets/swagger-ui.png") + redoc_path = Path("manual/assets/redoc.png") + assert swagger_path.exists() + assert redoc_path.exists() + assert swagger_path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + assert redoc_path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + assert ( + "manual/assets/swagger-ui.png" in manual_text + or "assets/swagger-ui.png" in manual_text + ) + assert "manual/assets/redoc.png" in manual_text or "assets/redoc.png" in manual_text + + +def test_development_manual_documents_review_gates() -> None: + manual_text = Path("manual/development.md").read_text(encoding="utf-8") + expected_phrases = [ + "2명의 승인", + "CODEOWNERS", + "마지막 푸시", + "pytest", + "scorecard", + "codeql (python, actions)", + "dependency-review", + "quality-gate", + ] + for phrase in expected_phrases: + assert phrase in manual_text + + +def test_development_manual_documents_single_maintainer_review_exception() -> None: + manual_text = Path("manual/development.md").read_text(encoding="utf-8") + for expected in ( + "단일 유지보수자", + "필수 상태 체크", + "리뷰어 용량이 확보되면", + "비작성자 승인", + "CODEOWNERS", + ): + assert expected in manual_text + + +def test_gitignore_excludes_generated_runtime_artifacts() -> None: + gitignore_text = Path(".gitignore").read_text(encoding="utf-8") + active_lines = { + line.strip() + for line in gitignore_text.splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + for pattern in ("site/", "registered_agents.json", "task_agent_mapping.json"): + assert pattern in active_lines diff --git a/tests/test_truth_source_alignment.py b/tests/test_truth_source_alignment.py new file mode 100644 index 00000000..2a3c21dd --- /dev/null +++ b/tests/test_truth_source_alignment.py @@ -0,0 +1,67 @@ +import re +from pathlib import Path + +import yaml + +INTEGRATION_MARK_RE = re.compile( + r"(^|\n)\s*(?:@pytest\.mark\.integration|pytestmark\s*=\s*pytest\.mark\.integration)", + re.MULTILINE, +) + + +def _integration_marked_test_exists() -> bool: + for test_path in Path("tests").glob("test_*.py"): + if test_path.name == Path(__file__).name: + continue + text = test_path.read_text(encoding="utf-8") + if INTEGRATION_MARK_RE.search(text): + return True + return False + + +def test_installation_manual_does_not_reference_empty_integration_marker() -> None: + text = Path("manual/installation.md").read_text(encoding="utf-8") + mentions_marker = ( + 'pytest -m "integration"' in text or "pytest -m integration" in text + ) + assert not mentions_marker or _integration_marked_test_exists() + + +def test_gh_pages_workflow_targets_supported_branches_only() -> None: + workflow = yaml.safe_load( + Path(".github/workflows/gh-pages.yml").read_text(encoding="utf-8") + ) + triggers = workflow.get("on", workflow.get(True)) + branches = triggers["push"]["branches"] + assert set(branches) == {"main", "develop"} + + +def test_security_gate_docs_use_current_codeql_check_name() -> None: + paths = [ + Path("docs/plans/2026-04-08-security-gates.md"), + Path("docs/plans/2026-04-08-security-gates-design.md"), + ] + + for path in paths: + text = path.read_text(encoding="utf-8") + assert "codeql (python, actions)" in text + assert "codeql (python)" not in text + + +def test_adr_follow_up_drops_stale_issue_references() -> None: + text = Path("docs/adr/0001-openssf-best-practices-badge.md").read_text( + encoding="utf-8" + ) + assert "#8" not in text + assert "#10" not in text + + +def test_public_docs_drop_stale_pip_setup_examples() -> None: + for path in [ + Path("README.md"), + Path("CONTRIBUTING.md"), + Path("manual/installation.md"), + ]: + text = path.read_text(encoding="utf-8") + assert 'pip install -e ".[dev]"' not in text, path + assert "python3.10 -m venv .venv" not in text, path diff --git a/uv.lock b/uv.lock index 2df98ac3..e69c1bed 100644 --- a/uv.lock +++ b/uv.lock @@ -889,14 +889,14 @@ wheels = [ [[package]] name = "pypdf" -version = "6.9.2" +version = "6.10.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/9f/ca96abf18683ca12602065e4ed2bec9050b672c87d317f1079abc7b6d993/pypdf-6.10.0.tar.gz", hash = "sha256:4c5a48ba258c37024ec2505f7e8fd858525f5502784a2e1c8d415604af29f6ef", size = 5314833, upload-time = "2026-04-10T09:34:57.102Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" }, ] [[package]] From 287a389c772e1e37788ff55ade5b6b5de25bd64c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Apr 2026 18:18:02 +0900 Subject: [PATCH 17/21] docs: record v0.1.1 release design --- ...04-11-v0-1-1-superseding-release-design.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/plans/2026-04-11-v0-1-1-superseding-release-design.md diff --git a/docs/plans/2026-04-11-v0-1-1-superseding-release-design.md b/docs/plans/2026-04-11-v0-1-1-superseding-release-design.md new file mode 100644 index 00000000..94d4d7fd --- /dev/null +++ b/docs/plans/2026-04-11-v0-1-1-superseding-release-design.md @@ -0,0 +1,105 @@ +# Design: v0.1.1 superseding signed release + +## Context + +- Issue #32 tracks the immutable `v0.1.0` release history problem: the existing release is immutable and does not contain `*.intoto.jsonl` provenance bundles, so the live signed-release posture cannot be repaired in place. +- PR #34 has now merged into `main`, so the stable branch carries the release workflow hardening needed to export attestation bundles. +- PR #35 has merged into `develop`, so the integration branch is unblocked and can be used to cut a proper `release/v0.1.1` branch per `docs/workflow/git-flow.md`. +- The current release workflow builds artifacts from `pyproject.toml` version metadata and uploads release assets only when a tag is pushed. +- `pyproject.toml` and `CHANGELOG.md` still describe `0.1.0`, so the next stable cut requires explicit version/changelog work before tagging. + +## Constraints + +- Follow the repository Git Flow release path: cut `release/v0.1.1` from `develop`, merge it into `main`, back-merge into `develop`, then tag from `main`. +- Preserve the current release workflow structure; the task is to use the hardened path, not redesign it. +- Produce a real GitHub release/tag with release assets and `*.intoto.jsonl` bundles as live evidence. +- Keep `CHANGELOG.md` in Keep a Changelog format and align `pyproject.toml` version metadata with the new tag. +- Leave `v0.1.0` untouched because the existing release is immutable and the issue comment already selected the superseding-release path. +- Re-check post-tag workflow runs and release assets before considering the task complete. + +## Approaches considered + +### 1. Delete and recreate `v0.1.0` + +- Pros: would repair the historical release directly. +- Cons: conflicts with the immutability blocker already observed in issue #32, rewrites release history, and adds avoidable operational risk. +- Verdict: reject. + +### 2. Tag `main` directly as `v0.1.1` without a release branch + +- Pros: shortest path to a new release. +- Cons: bypasses the repository's documented Git Flow release branch model and skips a durable place for version/changelog stabilization. +- Verdict: reject unless the documented release path becomes impossible. + +### 3. Cut `release/v0.1.1` from `develop`, stabilize version/changelog/tests there, merge to `main`, back-merge to `develop`, then tag `main` (recommended) + +- Pros: matches repository workflow, keeps stabilization MECE, produces a clean release handoff, and generates the superseding release issue #32 expects. +- Cons: more steps than direct tagging and requires explicit post-merge verification on both protected branches plus the tag-triggered workflow. +- Verdict: recommend. + +## Recommended design + +Use approach 3. + +### Components + +1. **Release metadata** + - Update `pyproject.toml` from `0.1.0` to `0.1.1`. + - Promote `CHANGELOG.md` from `[Unreleased]` to a dated `0.1.1` entry with the notable post-`0.1.0` delivery/security/release changes. + - Update changelog tests so they fail first for the missing `0.1.1` entry and then pass after the metadata change. + +2. **Release branch delivery path** + - Work on `release/v0.1.1` cut from `develop`. + - Verify locally with the full regression/coverage/docs suite before merging. + - Open a PR from `release/v0.1.1` to `main`, merge it through the normal PR path, then back-merge the same release branch into `develop`. + +3. **Live release execution** + - Tag `main` as `v0.1.1` only after the release branch lands on `main` and post-merge checks are green. + - Push the tag and watch `.github/workflows/release.yml` complete. + - Verify the resulting GitHub release contains at least: + - distribution artifacts + - `SHA256SUMS.txt` + - `release-manifest.json` + - one or more `*.intoto.jsonl` provenance bundles + +4. **Backlog cleanup** + - Update issue #32 with release evidence and close it when the new release exists. + - Re-check open code-scanning / Scorecard items after the release to see which remain external/time-based. + +## Data flow + +1. Add failing release-metadata tests. +2. Update version/changelog. +3. Run local verification. +4. Push `release/v0.1.1` and merge it into `main`. +5. Back-merge `release/v0.1.1` into `develop`. +6. Tag `main` as `v0.1.1`. +7. Watch release workflow and collect release asset evidence. +8. Close issue #32 and re-evaluate remaining open issues. + +## Error handling and rollback + +- If local verification fails, fix the release metadata/tests before opening the PR. +- If the release PR or back-merge PR uncovers merge conflicts, resolve them on the release branch and re-run the full verification suite. +- If the tag-triggered release workflow fails, inspect the full workflow logs, fix the root cause on a follow-up branch, and re-run with a new tag only after the path is green again. +- If release assets are incomplete, do not close issue #32; upload or regenerate the missing assets only after verifying the workflow failure mode. + +## Testing strategy + +- **Red**: changelog/version tests fail because `0.1.1` metadata is absent. +- **Green**: update version + changelog + tests until they pass. +- **Repository verification**: + - `uv run pytest` + - `uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100` + - `uv run mkdocs build --strict` +- **Live release verification**: + - PR checks on `release/v0.1.1` + - post-merge checks on `main` and `develop` + - tag-triggered `release` workflow + - GitHub release asset inventory for `v0.1.1` + +## Decisions + +- Treat issue #32 as the next highest-priority executable canonical task. +- Use the superseding release path rather than mutating `v0.1.0`. +- Keep the release flow anchored to the documented Git Flow release branch model. From c549219c945165c7f6b7eca94c2a6b3b4fac8ed7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Apr 2026 18:19:23 +0900 Subject: [PATCH 18/21] docs: record v0.1.1 release plan --- .../2026-04-11-v0-1-1-superseding-release.md | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 docs/plans/2026-04-11-v0-1-1-superseding-release.md diff --git a/docs/plans/2026-04-11-v0-1-1-superseding-release.md b/docs/plans/2026-04-11-v0-1-1-superseding-release.md new file mode 100644 index 00000000..37b35d3c --- /dev/null +++ b/docs/plans/2026-04-11-v0-1-1-superseding-release.md @@ -0,0 +1,293 @@ +# v0.1.1 Superseding Release Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Ship a real `v0.1.1` stable release from `main` so the active release window contains provenance-backed `*.intoto.jsonl` assets and issue #32 can close without mutating immutable `v0.1.0`. + +**Architecture:** Work on `release/v0.1.1` from `develop`, add failing metadata/changelog tests first, update `pyproject.toml` and `CHANGELOG.md`, then merge the release branch into `main` and back into `develop`. Tag `main` as `v0.1.1`, watch the release workflow, and verify the GitHub release asset inventory before closing the tracking issue. + +**Tech Stack:** Python/pytest, Keep a Changelog, GitHub PR/tag/release workflow, `gh` CLI, `uv`. + +--- + +### Task 1: Add the failing `0.1.1` release metadata tests + +**Files:** +- Modify: `tests/test_changelog.py` +- Modify: `tests/test_project_metadata.py` + +**Step 1: Write the failing tests** + +Add tests shaped like this: + +```python +def test_changelog_prepares_the_0_1_1_release_entry() -> None: + text = Path("CHANGELOG.md").read_text(encoding="utf-8") + assert "## [0.1.1] - 2026-04-11" in text + assert "[Unreleased]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.1...HEAD" in text + assert "[0.1.1]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.0...v0.1.1" in text + + +def test_project_version_is_prepared_for_v0_1_1_release() -> None: + text = Path("pyproject.toml").read_text(encoding="utf-8") + assert 'version = "0.1.1"' in text +``` + +**Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_changelog.py tests/test_project_metadata.py -q` + +Expected: FAIL because `CHANGELOG.md` and `pyproject.toml` still refer to `0.1.0`. + +**Step 3: Commit the red tests** + +```bash +git add tests/test_changelog.py tests/test_project_metadata.py +git commit -m "test: add failing v0.1.1 release metadata checks" +``` + +### Task 2: Update version and changelog for the superseding release + +**Files:** +- Modify: `pyproject.toml` +- Modify: `CHANGELOG.md` +- Modify: `tests/test_changelog.py` +- Modify: `tests/test_project_metadata.py` + +**Step 1: Write the minimal implementation** + +Update `pyproject.toml`: + +```toml +[project] +version = "0.1.1" +``` + +Update `CHANGELOG.md` so it contains: + +```markdown +## [Unreleased] + +## [0.1.1] - 2026-04-11 + +### Added + +- GHCR-ready multi-arch API image delivery, ClusterFuzzLite coverage, and exported `*.intoto.jsonl` release provenance bundles for stable releases + +### Changed + +- Protected-branch governance docs, CodeQL coverage, and manual screenshots now reflect the current repository delivery path +- Public setup guidance, Markdown lint scope, and docs toolchain policy are aligned with the merged `develop` / `main` workflow state + +### Fixed + +- Patched `pypdf` lockfile to `6.10.0` for GHSA-3crg-w4f6-42mx / CVE-2026-40260 coverage + +[Unreleased]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/Seongho-Bae/newsdom-api/releases/tag/v0.1.0 +``` + +Adjust the new tests if the exact category wording differs, but keep them focused on `0.1.1` metadata and release links. + +**Step 2: Run the targeted tests to verify they pass** + +Run: `uv run pytest tests/test_changelog.py tests/test_project_metadata.py -q` + +Expected: PASS. + +**Step 3: Commit the metadata update** + +```bash +git add pyproject.toml CHANGELOG.md tests/test_changelog.py tests/test_project_metadata.py +git commit -m "chore(release): prepare v0.1.1 metadata" +``` + +### Task 3: Re-run repository verification on the release branch + +**Files:** +- Modify: none unless verification exposes drift + +**Step 1: Run the full test suite** + +Run: `uv run pytest` + +Expected: PASS. + +**Step 2: Run the coverage gate** + +Run: `uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100` + +Expected: PASS with 100% coverage. + +**Step 3: Run the docs build** + +Run: `uv run mkdocs build --strict` + +Expected: PASS. + +**Step 4: Commit only if verification requires fixes** + +```bash +git add +git commit -m "fix: keep v0.1.1 release branch green" +``` + +### Task 4: Push `release/v0.1.1` and merge it into `main` + +**Files:** +- Modify: none locally unless PR feedback requires it + +**Step 1: Push the release branch** + +Run: `git push -u origin release/v0.1.1` + +Expected: remote branch created. + +**Step 2: Open the release PR to `main`** + +Run: + +```bash +gh pr create --base main --head release/v0.1.1 --title "release: cut v0.1.1" --body "$(cat <<'EOF' +## Summary +- prepare the superseding `v0.1.1` release metadata from `develop` +- use the hardened stable release path on `main` so the next tag ships `*.intoto.jsonl` provenance bundles +- unblock issue #32 without mutating immutable `v0.1.0` + +## Verification +- uv run pytest +- uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100 +- uv run mkdocs build --strict +EOF +)" +``` + +Expected: PR URL returned. + +**Step 3: Watch the PR checks** + +Run: `gh pr checks --watch` + +Expected: required checks complete successfully. + +**Step 4: Merge the release PR into `main`** + +Run: `gh pr merge --squash --delete-branch=false` + +Expected: PR merges into `main`. + +### Task 5: Back-merge the same release branch into `develop` + +**Files:** +- Modify: none locally unless back-merge feedback requires it + +**Step 1: Open the back-merge PR** + +Run: + +```bash +gh pr create --base develop --head release/v0.1.1 --title "release: back-merge v0.1.1 metadata" --body "$(cat <<'EOF' +## Summary +- back-merge the `v0.1.1` release metadata from the release branch into `develop` +- keep `develop` aligned with the stable release line after the `main` merge + +## Verification +- inherited from release/v0.1.1 branch verification +EOF +)" +``` + +Expected: PR URL returned. + +**Step 2: Watch the back-merge PR checks** + +Run: `gh pr checks --watch` + +Expected: required checks complete successfully. + +**Step 3: Merge the back-merge PR into `develop`** + +Run: `gh pr merge --squash --delete-branch=false` + +Expected: PR merges into `develop`. + +### Task 6: Create and push the `v0.1.1` tag from `main` + +**Files:** +- Modify: none + +**Step 1: Update the local `main` ref** + +Run: + +```bash +git fetch origin --tags +git checkout main +git merge --ff-only origin/main +``` + +Expected: local `main` matches the merged release PR. + +**Step 2: Create the tag** + +Run: `git tag -a v0.1.1 -m "v0.1.1"` + +Expected: local annotated tag created. + +**Step 3: Push the tag** + +Run: `git push origin v0.1.1` + +Expected: remote tag created and the `release` workflow starts. + +### Task 7: Verify the live GitHub release and close issue #32 + +**Files:** +- Modify: none + +**Step 1: Watch the release workflow** + +Run: `gh run watch --exit-status $(gh run list --workflow release --limit 1 --json databaseId --jq '.[0].databaseId')` + +Expected: the workflow completes successfully. + +**Step 2: Verify release asset inventory** + +Run: + +```bash +gh release view v0.1.1 --json assets,body,name,tagName,url +``` + +Expected: assets include at least one wheel or sdist, `SHA256SUMS.txt`, `release-manifest.json`, and one or more `*.intoto.jsonl` files. + +**Step 3: Close issue #32 with release evidence** + +Add a comment summarizing: + +- the merged release and back-merge PRs +- the `v0.1.1` release URL +- the presence of `*.intoto.jsonl` assets + +Then close issue #32 as completed. + +### Task 8: Re-evaluate the remaining repository backlog + +**Files:** +- Modify: none unless new blockers surface + +**Step 1: Check remaining open issues and code-scanning alerts** + +Run: + +```bash +gh issue list --state open +gh api repos/Seongho-Bae/newsdom-api/code-scanning/alerts?state=open\&per_page=100 +``` + +Expected: only genuinely remaining tasks stay open. + +**Step 2: Record the next canonical task** + +If issue #31 or a fresh release/code-scanning regression remains executable, capture it in a new design/plan doc before implementation. From 5f721044b7d9a31c25d8fb05a38a6bb79870cfc9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Apr 2026 18:20:04 +0900 Subject: [PATCH 19/21] test: add failing v0.1.1 release metadata checks --- tests/test_changelog.py | 15 +++++++++++++++ tests/test_project_metadata.py | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 1d6c6503..2104cc05 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -34,3 +34,18 @@ def test_initial_release_entry_keeps_added_section_and_links(): "[0.1.0]: https://github.com/Seongho-Bae/newsdom-api/releases/tag/v0.1.0" in text ) + + +def test_changelog_prepares_the_0_1_1_release_entry(): + text = Path("CHANGELOG.md").read_text(encoding="utf-8") + assert "## [0.1.1] - 2026-04-11" in text + assert "### Added" in text + assert "*.intoto.jsonl" in text + assert ( + "[Unreleased]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.1...HEAD" + in text + ) + assert ( + "[0.1.1]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.0...v0.1.1" + in text + ) diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index aa535345..b980ad73 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -18,6 +18,11 @@ def test_project_metadata_does_not_bundle_mineru_extra(): assert "mineru[pipeline]" not in dependencies_section +def test_project_version_is_prepared_for_v0_1_1_release(): + text = Path("pyproject.toml").read_text(encoding="utf-8") + assert 'version = "0.1.1"' 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 From 20b207a48b1c5d196f42148a3ae3c6409c43553d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Apr 2026 18:21:01 +0900 Subject: [PATCH 20/21] chore(release): prepare v0.1.1 metadata --- CHANGELOG.md | 19 ++++++++++++++++++- pyproject.toml | 2 +- tests/test_changelog.py | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f89abda..82bbeb64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1] - 2026-04-11 + +### Added + +- GHCR-ready multi-arch API image delivery, ClusterFuzzLite coverage, and exported `*.intoto.jsonl` provenance bundles for stable releases +- Verified `/docs` and `/redoc` manual screenshots plus canonical engineering policy docs that describe the live repository workflow + +### Changed + +- Protected-branch governance documentation now reflects the current single-maintainer exception while preserving required checks and history protections +- Public setup guidance, docs-toolchain policy, and markdownlint scope now match the merged `develop` / `main` delivery paths + +### Fixed + +- Patched `pypdf` lockfile coverage to `6.10.0` for GHSA-3crg-w4f6-42mx / CVE-2026-40260 + ## [0.1.0] - 2026-04-09 ### Added @@ -15,5 +31,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 +[Unreleased]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/Seongho-Bae/newsdom-api/releases/tag/v0.1.0 diff --git a/pyproject.toml b/pyproject.toml index fa1c6723..788854c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "newsdom-api" -version = "0.1.0" +version = "0.1.1" description = "DOM-style parser API for scanned Japanese newspaper PDFs" readme = "README.md" requires-python = ">=3.10,<3.14" diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 2104cc05..79c6d95a 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -27,7 +27,7 @@ def test_initial_release_entry_keeps_added_section_and_links(): in text ) assert ( - "[Unreleased]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.0...HEAD" + "[Unreleased]: https://github.com/Seongho-Bae/newsdom-api/compare/v0.1.1...HEAD" in text ) assert ( From 2160f92c3999132879fdf62002ac98de04055530 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Apr 2026 18:22:44 +0900 Subject: [PATCH 21/21] test: keep release metadata lockstep --- tests/test_project_metadata.py | 14 ++++++++++++++ uv.lock | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index b980ad73..49818d9e 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -23,6 +23,20 @@ def test_project_version_is_prepared_for_v0_1_1_release(): assert 'version = "0.1.1"' in text +def test_uv_lock_tracks_project_version() -> None: + pyproject_text = Path("pyproject.toml").read_text(encoding="utf-8") + pyproject_version = re.search(r'^version = "([^"]+)"', pyproject_text, re.MULTILINE) + assert pyproject_version is not None + + uv_lock_text = Path("uv.lock").read_text(encoding="utf-8") + lock_version = re.search( + r'\[\[package\]\]\nname = "newsdom-api"\nversion = "([^"]+)"', + uv_lock_text, + ) + assert lock_version is not None + assert lock_version.group(1) == pyproject_version.group(1) + + 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 e69c1bed..d6a4c2b1 100644 --- a/uv.lock +++ b/uv.lock @@ -544,7 +544,7 @@ wheels = [ [[package]] name = "newsdom-api" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "fastapi" },