diff --git a/docs/profile-template.md b/docs/profile-template.md new file mode 100644 index 0000000..e363456 --- /dev/null +++ b/docs/profile-template.md @@ -0,0 +1,22 @@ +# Candidate Profile + +## Summary +One short paragraph describing your target profile. + +## Target Roles +- Data Engineer + +## Strengths +- Python +- SQL + +## Skills To Emphasize +- GCP +- BigQuery + +## Constraints +- CDI only +- France only + +## Notes +- Anything the CV parser might miss but future ranking should understand. diff --git a/pyproject.toml b/pyproject.toml index 4673032..aa026ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,28 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + [project] name = "job-research" version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" -dependencies = [] +dependencies = [ + "pydantic>=2.7,<3", + "pypdf>=5.0,<6", + "pyyaml>=6.0,<7", + "typer>=0.12,<1", +] + +[dependency-groups] +dev = ["pytest>=8.2,<9"] + +[project.scripts] +job-research = "job_research.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/job_research/__init__.py b/src/job_research/__init__.py new file mode 100644 index 0000000..a05eb9a --- /dev/null +++ b/src/job_research/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/src/job_research/cli.py b/src/job_research/cli.py new file mode 100644 index 0000000..7de8e05 --- /dev/null +++ b/src/job_research/cli.py @@ -0,0 +1,67 @@ +from pathlib import Path + +import typer + +from job_research.profile.cv_extractor import extract_cv_signals, extract_pdf_text +from job_research.profile.merge import build_candidate_profile_output +from job_research.profile.profile_parser import parse_profile_markdown +from job_research.storage import save_candidate_profile_yaml + +app = typer.Typer(help="Build one canonical candidate profile YAML") + + +@app.callback() +def main_command() -> None: + pass + +@app.command("build-profile") +def build_profile( + cv: Path = typer.Option( + ..., + "--cv", + exists=True, + dir_okay=False, + readable=True, + help="Path to the CV PDF or UTF-8 text file.", + ), + profile: Path = typer.Option( + ..., + "--profile", + exists=True, + dir_okay=False, + readable=True, + help="Path to the light-template markdown profile.", + ), + out: Path = typer.Option( + Path("data/candidate-profile.yaml"), + "--out", + dir_okay=False, + help="Path to write the canonical YAML profile.", + ), +) -> None: + """Build candidate-profile.yaml from CV and markdown profile.""" + + cv_text = extract_pdf_text(cv) if cv.suffix.lower() == ".pdf" else cv.read_text(encoding="utf-8") + if not cv_text.strip(): + raise ValueError("No readable text found in CV input") + + authored_profile = parse_profile_markdown(profile.read_text(encoding="utf-8")) + cv_signals = extract_cv_signals(cv_text) + candidate_profile = build_candidate_profile_output(cv_signals, authored_profile) + + save_candidate_profile_yaml(out, candidate_profile) + + typer.echo(f"candidate profile written to {out}") + warning_count = len(candidate_profile.warnings) + if warning_count: + typer.echo(f"Warnings included: {warning_count}") + else: + typer.echo("No warnings included.") + + +def main() -> None: + app() + + +if __name__ == "__main__": + main() diff --git a/src/job_research/models.py b/src/job_research/models.py new file mode 100644 index 0000000..88cc08a --- /dev/null +++ b/src/job_research/models.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel, Field + + +class ExperienceEntry(BaseModel): + company: str + title: str + start: str | None = None + end: str | None = None + highlights: list[str] = Field(default_factory=list) + + +class EducationEntry(BaseModel): + institution: str + credential: str + start: str | None = None + end: str | None = None + + +class WarningItem(BaseModel): + field: str + message: str + + +class CandidateProfileOutput(BaseModel): + name: str | None = None + summary: str | None = None + target_roles: list[str] = Field(default_factory=list) + strengths: list[str] = Field(default_factory=list) + skills_to_emphasize: list[str] = Field(default_factory=list) + constraints: list[str] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + location: str | None = None + languages: list[str] = Field(default_factory=list) + skills: list[str] = Field(default_factory=list) + experience_entries: list[ExperienceEntry] = Field(default_factory=list) + education_entries: list[EducationEntry] = Field(default_factory=list) + warnings: list[WarningItem] = Field(default_factory=list) diff --git a/src/job_research/profile/__init__.py b/src/job_research/profile/__init__.py new file mode 100644 index 0000000..e3742ea --- /dev/null +++ b/src/job_research/profile/__init__.py @@ -0,0 +1 @@ +__all__ = ["profile_parser"] diff --git a/src/job_research/profile/cv_extractor.py b/src/job_research/profile/cv_extractor.py new file mode 100644 index 0000000..1ccf55b --- /dev/null +++ b/src/job_research/profile/cv_extractor.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from pypdf import PdfReader + + +EXPERIENCE_LINE_CONNECTORS = (" at ", " chez ", " au ", " à ") + +EXPERIENCE_TITLE_STOPWORDS = { + "a", + "an", + "and", + "as", + "at", + "after", + "before", + "by", + "for", + "from", + "in", + "into", + "of", + "on", + "or", + "the", + "to", + "with", + "within", + "without", + "while", + "during", + "since", + "because", +} + +EXPERIENCE_TITLE_ACTION_STARTS = { + "built", + "created", + "delivered", + "designed", + "developed", + "deployed", + "implemented", + "improved", + "managed", + "migrated", + "maintained", + "worked", +} + +EXPERIENCE_TITLE_LABELS = { + "education", + "experience", + "formation", + "languages", + "langues", + "location", + "profile", + "skills", + "compétences", + "competences", + "summary", +} + +EXPERIENCE_PROSE_MARKERS = { + "after", + "before", + "because", + "during", + "from", + "joined", + "joining", + "left", + "leaving", + "in", + "moved", + "moving", + "relocated", + "relocating", + "since", + "studying", + "then", + "to", + "toward", + "towards", + "transferring", + "transitioning", + "until", + "while", + "working", + "work", + "worked", +} + +EXPERIENCE_PROSE_COMPANY_PATTERNS = ( + re.compile(r"\bau\s+sein\s+de\b", re.IGNORECASE), + re.compile(r"\b(before|after|during|while|since|because)\b", re.IGNORECASE), + re.compile(r"\b(joined|joining|moved|moving|relocated|relocating|worked|working)\b", re.IGNORECASE), + re.compile( + r"\bin\s+(?:paris|london|lyon|france|berlin|amsterdam|madrid|rome|marseille|bordeaux|toulouse|nantes|lille|grenoble|strasbourg|nice|rennes|montpellier|remote)\b", + re.IGNORECASE, + ), +) + +EDUCATION_TITLE_KEYWORDS = { + "bachelor", + "degree", + "diploma", + "diplome d'ingenieur", + "diplome d'ingénieur", + "diplôme d'ingénieur", + "diplôme d'ingenieur", + "engineering school", + "bac+5", + "licence", + "bsc", + "master", + "msc", +} + +EDUCATION_INSTITUTION_MARKERS = { + "academy", + "college", + "centrale", + "ecole", + "école", + "ens", + "epita", + "epitech", + "hec", + "imt", + "institute", + "institut", + "insa", + "polytech", + "school", + "university", + "universite", + "université", +} + +EDUCATION_ENTRY_SEPARATORS = (" at ", ", ", " - ", " – ", " — ") + +LEADING_BULLET_MARKERS = {"-", "*", "+", "•", "‣", "∙", "●", "◦", "–", "—"} + +YEARS_OF_EXPERIENCE_PATTERNS = ( + re.compile(r"^years of experience\s*:\s*(\d+)\s*$", re.IGNORECASE), + re.compile(r"^ann[ée]es d['’]exp[ée]rience\s*:\s*(\d+)\s*$", re.IGNORECASE), +) + + +def extract_pdf_text(path: Path) -> str: + reader = PdfReader(str(path)) + page_texts = [ + text.strip() + for text in (page.extract_text() or "" for page in reader.pages) + if text.strip() + ] + + if not page_texts: + raise ValueError("No extractable text found in PDF CV") + + return "\n".join(page_texts) + + +def extract_cv_signals(text: str) -> dict[str, object]: + lines = [_normalize_line(line) for line in text.splitlines()] + non_empty_lines = [line for line in lines if line] + + name = non_empty_lines[0] if non_empty_lines else None + location = None + languages: list[str] = [] + skills: list[str] = [] + experience_entries: list[dict[str, str]] = [] + education_entries: list[dict[str, str]] = [] + years_of_experience: int | None = None + in_education_section = False + pending_education_credential: str | None = None + + for line in non_empty_lines[1:]: + lowered = line.lower() + + years_of_experience_line = _parse_years_of_experience(line) + if years_of_experience_line is not None: + years_of_experience = years_of_experience_line + continue + + if lowered.startswith(("education:", "formation:")): + in_education_section = True + pending_education_credential = None + remainder = line.split(":", 1)[1].strip() + if remainder: + education_entry = _parse_education_entry(remainder) + if education_entry: + education_entries.append(education_entry) + elif _looks_like_education_credential(remainder): + pending_education_credential = remainder + continue + + if in_education_section: + if pending_education_credential and _looks_like_institution(line): + education_entries.append( + { + "credential": pending_education_credential, + "institution": line, + } + ) + pending_education_credential = None + continue + + education_entry = _parse_education_entry(line) + if education_entry: + education_entries.append(education_entry) + pending_education_credential = None + continue + + if _looks_like_education_credential(line): + pending_education_credential = line + continue + + pending_education_credential = None + in_education_section = False + + if lowered.startswith("location:"): + location = line.split(":", 1)[1].strip() or None + continue + if lowered.startswith(("languages:", "langues:")): + languages = _parse_csv_field(line) + continue + if lowered.startswith(("skills:", "compétences:")): + skills = _parse_csv_field(line) + continue + education_entry = _parse_education_entry(line) + if education_entry: + education_entries.append(education_entry) + continue + experience_entry = _parse_experience_entry(line) + if experience_entry: + experience_entries.append(experience_entry) + + payload = { + "name": name, + "location": location, + "languages": languages, + "skills": skills, + "experience_entries": experience_entries, + "education_entries": education_entries, + } + + if years_of_experience is not None: + payload["years_of_experience"] = years_of_experience + + return payload + + +def _parse_csv_field(line: str) -> list[str]: + _, value = line.split(":", 1) + return [item.strip() for item in value.split(",") if item.strip()] + + +def _normalize_line(line: str) -> str: + stripped = line.strip() + if not stripped: + return "" + + if stripped[0] in LEADING_BULLET_MARKERS: + return stripped[1:].lstrip() + + return stripped + + +def _parse_years_of_experience(line: str) -> int | None: + normalized = line.replace("’", "'") + + for pattern in YEARS_OF_EXPERIENCE_PATTERNS: + match = pattern.match(normalized) + if match: + return int(match.group(1)) + + return None + + +def _parse_education_entry(line: str) -> dict[str, str] | None: + for separator in EDUCATION_ENTRY_SEPARATORS: + if separator not in line: + continue + + left, right = (part.strip() for part in line.split(separator, 1)) + if not left or not right: + continue + + if _looks_like_education_credential(left) and _looks_like_institution(right): + return {"credential": left, "institution": right} + + if _looks_like_education_credential(right) and _looks_like_institution(left): + return {"credential": right, "institution": left} + + return None + + +def _looks_like_education_credential(text: str) -> bool: + lowered = text.lower().replace(".", "") + return any( + re.search(rf"\b{re.escape(keyword)}\b", lowered) + for keyword in EDUCATION_TITLE_KEYWORDS + ) + + +def _looks_like_institution(text: str) -> bool: + lowered = text.lower() + return any(marker in lowered for marker in EDUCATION_INSTITUTION_MARKERS) + + +def _parse_experience_entry(line: str) -> dict[str, str] | None: + if " au sein " in line.lower(): + return None + + for connector in EXPERIENCE_LINE_CONNECTORS: + if line.count(connector) != 1: + continue + + title, company = (part.strip() for part in line.split(connector, 1)) + if not title or not company: + continue + + if not _looks_like_experience_title(title): + continue + + if _looks_like_prose_company(company): + continue + + return { + "title": title, + "company": company, + } + + return None + + +def _looks_like_experience_line(line: str) -> bool: + return _parse_experience_entry(line) is not None + + +def _looks_like_experience_title(title: str) -> bool: + title_words = [word.strip(".,;:!?()[]{}") for word in title.split()] + if not title_words: + return False + + first_word = title_words[0] + normalized_title = " ".join(title_words).casefold() + if normalized_title in EXPERIENCE_TITLE_LABELS: + return False + + if first_word.casefold() in EXPERIENCE_TITLE_STOPWORDS: + return False + + if first_word.casefold() in EXPERIENCE_TITLE_ACTION_STARTS: + return False + + if len(title_words) == 1 and len(first_word) < 2: + return False + + return not any( + word.casefold() in EXPERIENCE_PROSE_MARKERS for word in title_words if word + ) + + +def _looks_like_prose_company(company: str) -> bool: + return any(pattern.search(company) for pattern in EXPERIENCE_PROSE_COMPANY_PATTERNS) diff --git a/src/job_research/profile/merge.py b/src/job_research/profile/merge.py new file mode 100644 index 0000000..263fbac --- /dev/null +++ b/src/job_research/profile/merge.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import re + +from job_research.models import ( + CandidateProfileOutput, + EducationEntry, + ExperienceEntry, + WarningItem, +) +from job_research.profile.profile_parser import AuthoredProfile + + +EXPERIENCE_NOTE_MARKERS = ( + "experience", + "expérience", + "années d'expérience", + "annees d'experience", +) + + +MISSING_CV_FACT_WARNINGS = ( + ("name", "No candidate name was extracted from the CV."), + ("experience_entries", "No experience entries were extracted from the CV."), + ("skills", "No skills were extracted from the CV."), + ("education_entries", "No education entries were extracted from the CV."), +) + + +def build_candidate_profile_output( + cv_signals: dict[str, object], authored: AuthoredProfile +) -> CandidateProfileOutput: + warnings: list[WarningItem] = [] + + _append_years_of_experience_warning(cv_signals, authored.notes, warnings) + _append_missing_cv_fact_warnings(cv_signals, warnings) + + merged_skills: list[str] = [] + for skill in [ + *(cv_signals.get("skills") or []), + *authored.strengths, + *authored.skills_to_emphasize, + ]: + if skill not in merged_skills: + merged_skills.append(skill) + + experience_entries = cv_signals.get("experience_entries") or [] + education_entries = cv_signals.get("education_entries") or [] + + return CandidateProfileOutput( + name=cv_signals.get("name"), + summary=authored.summary, + target_roles=authored.target_roles, + strengths=authored.strengths, + skills_to_emphasize=authored.skills_to_emphasize, + constraints=authored.constraints, + notes=authored.notes, + location=cv_signals.get("location"), + languages=cv_signals.get("languages") or [], + skills=merged_skills, + experience_entries=[ExperienceEntry.model_validate(item) for item in experience_entries], + education_entries=[EducationEntry.model_validate(item) for item in education_entries], + warnings=warnings, + ) + + +def _append_years_of_experience_warning( + cv_signals: dict[str, object], notes: list[str], warnings: list[WarningItem] +) -> None: + years_of_experience = cv_signals.get("years_of_experience") + if years_of_experience is None: + return + + for note in notes: + note_years_of_experience = _note_years_of_experience(note) + if note_years_of_experience is None or note_years_of_experience == years_of_experience: + continue + + warnings.append( + WarningItem( + field="years_of_experience", + message=( + "CV-derived years of experience does not match a markdown note. " + "Review manually." + ), + ) + ) + + return + + +def _append_missing_cv_fact_warnings( + cv_signals: dict[str, object], warnings: list[WarningItem] +) -> None: + for field, message in MISSING_CV_FACT_WARNINGS: + if cv_signals.get(field): + continue + + warnings.append(WarningItem(field=field, message=message)) + + +def _note_years_of_experience(note: str) -> int | None: + normalized = note.casefold().replace("’", "'") + if not any(marker in normalized for marker in EXPERIENCE_NOTE_MARKERS): + return None + + matches = re.findall(r"\b(\d{1,2})\b", normalized) + if len(matches) != 1: + return None + + return int(matches[0]) diff --git a/src/job_research/profile/profile_parser.py b/src/job_research/profile/profile_parser.py new file mode 100644 index 0000000..1e4ee35 --- /dev/null +++ b/src/job_research/profile/profile_parser.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field + + +LIST_MARKERS = ("- ", "* ", "+ ") + + +@dataclass +class AuthoredProfile: + summary: str | None = None + target_roles: list[str] = field(default_factory=list) + strengths: list[str] = field(default_factory=list) + skills_to_emphasize: list[str] = field(default_factory=list) + constraints: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +REQUIRED_SECTIONS = { + "summary", + "target roles", + "strengths", + "skills to emphasize", + "constraints", + "notes", +} + + +def parse_profile_markdown(markdown: str) -> AuthoredProfile: + sections: dict[str, list[str]] = defaultdict(list) + current_section: str | None = None + + for raw_line in markdown.splitlines(): + line = raw_line.strip() + if line.startswith("## "): + current_section = line[3:].strip().lower() + continue + if not line or current_section is None: + continue + sections[current_section].append(line) + + missing = REQUIRED_SECTIONS - set(sections) + if missing: + missing_text = ", ".join(sorted(missing)) + raise ValueError(f"Missing required markdown sections: {missing_text}") + + return AuthoredProfile( + summary=" ".join(sections["summary"]), + target_roles=_parse_list_section("target roles", sections["target roles"]), + strengths=_parse_list_section("strengths", sections["strengths"]), + skills_to_emphasize=_parse_list_section( + "skills to emphasize", sections["skills to emphasize"] + ), + constraints=_parse_list_section("constraints", sections["constraints"]), + notes=_parse_notes_section(sections["notes"]), + ) + + +def _parse_list_section(section_name: str, lines: list[str]) -> list[str]: + items: list[str] = [] + + for line in lines: + item = _strip_list_marker(line) + if item is None: + raise ValueError(f"Unsupported content in section '{section_name}': {line}") + items.append(item) + + return items + + +def _parse_notes_section(lines: list[str]) -> list[str]: + notes: list[str] = [] + + for line in lines: + item = _strip_list_marker(line) + notes.append(item if item is not None else line) + + return notes + + +def _strip_list_marker(line: str) -> str | None: + for marker in LIST_MARKERS: + if line.startswith(marker): + return line[len(marker):].strip() + + return None diff --git a/src/job_research/storage.py b/src/job_research/storage.py new file mode 100644 index 0000000..9e06d9a --- /dev/null +++ b/src/job_research/storage.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import yaml + +from job_research.models import CandidateProfileOutput + + +def save_candidate_profile_yaml(path: Path, profile: CandidateProfileOutput) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = profile.model_dump(mode="json") + path.write_text(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True), encoding="utf-8") + + +def load_yaml(path: Path) -> dict[str, Any]: + payload = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(payload, Mapping): + raise ValueError("candidate-profile YAML root must be a mapping") + + return dict(payload) diff --git a/tests/profile/test_cv_extractor.py b/tests/profile/test_cv_extractor.py new file mode 100644 index 0000000..7a9bc44 --- /dev/null +++ b/tests/profile/test_cv_extractor.py @@ -0,0 +1,414 @@ +from textwrap import dedent +from pathlib import Path + +import pytest + +from job_research.profile.cv_extractor import extract_cv_signals +from job_research.profile.cv_extractor import extract_pdf_text + + +def test_extract_cv_signals_reads_basic_fields_from_text() -> None: + text = dedent( + """ + Tonio + Location: France + Languages: French, English + Skills: Python, SQL, Terraform, GCP, BigQuery + Data Engineer at Company A + Analytics Engineer at Company B + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["name"] == "Tonio" + assert extracted["location"] == "France" + assert extracted["languages"] == ["French", "English"] + assert extracted["skills"] == ["Python", "SQL", "Terraform", "GCP", "BigQuery"] + assert extracted["experience_entries"][0]["title"] == "Data Engineer" + assert len(extracted["experience_entries"]) == 2 + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("Years of experience: 2", 2), + ("Années d'expérience : 3", 3), + ], +) +def test_extract_cv_signals_extracts_years_of_experience_from_explicit_line( + line: str, expected: int +) -> None: + text = dedent( + f""" + Tonio + Location: France + {line} + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["years_of_experience"] == expected + + +def test_extract_cv_signals_allows_single_word_titles() -> None: + text = dedent( + """ + Tonio + Location: France + Consultant at Accenture + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [ + {"title": "Consultant", "company": "Accenture"} + ] + + +def test_extract_cv_signals_allows_lowercase_company_names() -> None: + text = dedent( + """ + Tonio + Location: France + Data Engineer at leboncoin + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [ + {"title": "Data Engineer", "company": "leboncoin"} + ] + + +def test_extract_cv_signals_ignores_prose_after_company_name() -> None: + text = dedent( + """ + Tonio + Location: France + Senior engineer at Microsoft before moving to Paris. + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [] + + +def test_extract_cv_signals_normalizes_bullet_prefixed_fields_and_experience() -> None: + text = dedent( + """ + Tonio + - Location: France + - Data Engineer at Company A + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["location"] == "France" + assert extracted["experience_entries"] == [ + {"title": "Data Engineer", "company": "Company A"} + ] + + +def test_extract_cv_signals_normalizes_en_dash_bullet_prefixed_fields_and_experience() -> None: + text = dedent( + """ + Tonio + – Location: France + — Data Engineer at Company A + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["location"] == "France" + assert extracted["experience_entries"] == [ + {"title": "Data Engineer", "company": "Company A"} + ] + + +def test_extract_cv_signals_recognizes_french_field_labels() -> None: + text = dedent( + """ + Tonio + Formation: + M.Sc. in Data Engineering at EPITA + Langues: French, English + Compétences: Python, SQL + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["languages"] == ["French", "English"] + assert extracted["skills"] == ["Python", "SQL"] + assert extracted["education_entries"] == [ + {"credential": "M.Sc. in Data Engineering", "institution": "EPITA"} + ] + + +def test_extract_cv_signals_parses_french_experience_connectors() -> None: + text = dedent( + """ + Tonio + Location: France + Ingénieur chez Dassault Systèmes + Développeur au CNRS + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [ + {"title": "Ingénieur", "company": "Dassault Systèmes"}, + {"title": "Développeur", "company": "CNRS"}, + ] + + +def test_extract_cv_signals_parses_clear_titles_with_french_and_english_connectors() -> None: + text = dedent( + """ + Tonio + Location: France + Ingénieur à Thales + Ingénieur Data chez BNP Paribas + Consultant BI chez Accenture + Head of Data at Qonto + Product Owner at Qonto + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [ + {"title": "Ingénieur", "company": "Thales"}, + {"title": "Ingénieur Data", "company": "BNP Paribas"}, + {"title": "Consultant BI", "company": "Accenture"}, + {"title": "Head of Data", "company": "Qonto"}, + {"title": "Product Owner", "company": "Qonto"}, + ] + + +def test_extract_cv_signals_accepts_lowercase_short_and_real_company_titles() -> None: + text = dedent( + """ + Tonio + Location: France + data engineer at Company A + iOS Engineer at Company A + 3D Artist at Studio + R Developer at Company A + Data Engineer at Made in Design + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [ + {"title": "data engineer", "company": "Company A"}, + {"title": "iOS Engineer", "company": "Company A"}, + {"title": "3D Artist", "company": "Studio"}, + {"title": "R Developer", "company": "Company A"}, + {"title": "Data Engineer", "company": "Made in Design"}, + ] + + +def test_extract_cv_signals_ignores_french_prose_continuations() -> None: + text = dedent( + """ + Tonio + Location: France + Ingénieur au sein de BNP Paribas + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [] + + +def test_extract_cv_signals_rejects_label_like_lines_without_colons() -> None: + text = dedent( + """ + Tonio + Location at Paris + Summary at a glance + Profile at LinkedIn + Education at EPITA + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [] + + +def test_extract_cv_signals_rejects_narrative_bullet_experience_lines() -> None: + text = dedent( + """ + Tonio + Location: France + Implemented data pipelines at Airbnb + Designed dashboards at Company A + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [] + + +def test_extract_cv_signals_ignores_in_paris_prose_tail() -> None: + text = dedent( + """ + Tonio + Location: France + Data Engineer at Microsoft in Paris + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [] + + +def test_extract_cv_signals_extracts_education_entries_after_heading() -> None: + text = dedent( + """ + Tonio + Location: France + Education: + - Master of Science in Data Science at University of Paris + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["education_entries"] == [ + { + "credential": "Master of Science in Data Science", + "institution": "University of Paris", + } + ] + + +def test_extract_cv_signals_extracts_common_french_education_entries() -> None: + text = dedent( + """ + Tonio + Location: France + Education: + Diplôme d'ingénieur + CentraleSupélec + MSc + University of Paris + BSc + University of Oxford + Bac+5 + École Polytechnique + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["education_entries"] == [ + { + "credential": "Diplôme d'ingénieur", + "institution": "CentraleSupélec", + }, + {"credential": "MSc", "institution": "University of Paris"}, + {"credential": "BSc", "institution": "University of Oxford"}, + {"credential": "Bac+5", "institution": "École Polytechnique"}, + ] + + +def test_extract_cv_signals_extracts_dotted_degree_variants() -> None: + text = dedent( + """ + Tonio + Location: France + Education: + M.Sc. in Data Engineering at EPITA + B.Sc. in Computer Science at University X + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["education_entries"] == [ + {"credential": "M.Sc. in Data Engineering", "institution": "EPITA"}, + { + "credential": "B.Sc. in Computer Science", + "institution": "University X", + }, + ] + + +def test_extract_cv_signals_ignores_before_moving_prose_tail() -> None: + text = dedent( + """ + Tonio + Location: France + Senior Engineer at Microsoft Before moving to Paris. + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [] + + +def test_extract_pdf_text_skips_blank_pages(monkeypatch) -> None: + class FakePage: + def __init__(self, text: str | None) -> None: + self._text = text + + def extract_text(self) -> str | None: + return self._text + + class FakePdfReader: + def __init__(self, path: str) -> None: + self.path = path + self.pages = [ + FakePage(None), + FakePage("Tonio"), + FakePage(""), + FakePage("Data Engineer"), + ] + + seen_paths: list[str] = [] + + def fake_pdf_reader(path: str) -> FakePdfReader: + seen_paths.append(path) + return FakePdfReader(path) + + monkeypatch.setattr("job_research.profile.cv_extractor.PdfReader", fake_pdf_reader) + + extracted = extract_pdf_text(Path("cv.pdf")) + + assert extracted == "Tonio\nData Engineer" + assert seen_paths == ["cv.pdf"] + + +def test_extract_pdf_text_rejects_textless_pdfs(monkeypatch) -> None: + class FakePage: + def extract_text(self) -> str | None: + return " " + + class FakePdfReader: + def __init__(self, path: str) -> None: + self.path = path + self.pages = [FakePage(), FakePage()] + + def fake_pdf_reader(path: str) -> FakePdfReader: + return FakePdfReader(path) + + monkeypatch.setattr("job_research.profile.cv_extractor.PdfReader", fake_pdf_reader) + + with pytest.raises(ValueError, match="No extractable text"): + extract_pdf_text(Path("cv.pdf")) diff --git a/tests/profile/test_merge.py b/tests/profile/test_merge.py new file mode 100644 index 0000000..1aeecbc --- /dev/null +++ b/tests/profile/test_merge.py @@ -0,0 +1,74 @@ +from job_research.profile.merge import build_candidate_profile_output +from job_research.profile.profile_parser import AuthoredProfile + + +def test_build_candidate_profile_output_writes_warning_when_facts_conflict() -> None: + cv_signals = { + "name": "Tonio", + "location": "France", + "languages": ["French", "English"], + "skills": ["Python", "SQL"], + "experience_entries": [{"title": "Data Engineer", "company": "A"}], + "education_entries": [], + "years_of_experience": 2, + } + authored = AuthoredProfile( + summary="Junior data engineer focused on GCP.", + target_roles=["Data Engineer"], + strengths=["Python"], + skills_to_emphasize=["BigQuery", "GCP"], + constraints=["CDI only"], + notes=["Years of experience feels closer to 3."], + ) + + output = build_candidate_profile_output(cv_signals, authored) + + assert output.summary == "Junior data engineer focused on GCP." + assert output.constraints == ["CDI only"] + assert any(item.field == "years_of_experience" for item in output.warnings) + + +def test_build_candidate_profile_output_writes_warning_for_french_experience_note() -> None: + cv_signals = { + "name": "Tonio", + "location": "France", + "languages": ["French", "English"], + "skills": ["Python", "SQL"], + "experience_entries": [{"title": "Data Engineer", "company": "A"}], + "education_entries": [], + "years_of_experience": 2, + } + authored = AuthoredProfile( + summary="Junior data engineer focused on GCP.", + target_roles=["Data Engineer"], + strengths=["Python"], + skills_to_emphasize=["BigQuery", "GCP"], + constraints=["CDI only"], + notes=["Années d'expérience semble plus proche de 3."], + ) + + output = build_candidate_profile_output(cv_signals, authored) + + assert any(item.field == "years_of_experience" for item in output.warnings) + + +def test_build_candidate_profile_output_warns_on_missing_core_cv_facts() -> None: + cv_signals = { + "location": "France", + "languages": ["French", "English"], + "skills": [], + "experience_entries": [], + "education_entries": [], + } + authored = AuthoredProfile( + summary="Junior data engineer focused on GCP." + ) + + output = build_candidate_profile_output(cv_signals, authored) + + assert [item.field for item in output.warnings] == [ + "name", + "experience_entries", + "skills", + "education_entries", + ] diff --git a/tests/profile/test_profile_parser.py b/tests/profile/test_profile_parser.py new file mode 100644 index 0000000..8df1c82 --- /dev/null +++ b/tests/profile/test_profile_parser.py @@ -0,0 +1,74 @@ +import pytest + +from textwrap import dedent + +from job_research.profile.profile_parser import parse_profile_markdown + + +def test_parse_profile_markdown_reads_light_required_template() -> None: + markdown = dedent( + """ + # Candidate Profile + + ## Summary + Junior data engineer focused on Python and GCP. + + ## Target Roles + * Data Engineer + + Analytics Engineer + + ## Strengths + - Python + - SQL + + ## Skills To Emphasize + - BigQuery + - Terraform + + ## Constraints + - CDI only + - France only + + ## Notes + Slight preference for French listings. + """ + ).strip() + + profile = parse_profile_markdown(markdown) + + assert profile.summary == "Junior data engineer focused on Python and GCP." + assert profile.target_roles == ["Data Engineer", "Analytics Engineer"] + assert profile.strengths == ["Python", "SQL"] + assert profile.skills_to_emphasize == ["BigQuery", "Terraform"] + assert profile.constraints == ["CDI only", "France only"] + assert profile.notes == ["Slight preference for French listings."] + + +def test_parse_profile_markdown_rejects_unsupported_list_content() -> None: + markdown = dedent( + """ + # Candidate Profile + + ## Summary + Junior data engineer focused on Python and GCP. + + ## Target Roles + - Data Engineer + Analytics Engineer + + ## Strengths + - Python + + ## Skills To Emphasize + - BigQuery + + ## Constraints + - CDI only + + ## Notes + - Slight preference for French listings. + """ + ).strip() + + with pytest.raises(ValueError, match="Unsupported content in section 'target roles'"): + parse_profile_markdown(markdown) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d5a2388 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,191 @@ +from subprocess import run +from textwrap import dedent + +from job_research.storage import load_yaml + + +def test_installed_cli_help_exposes_build_profile_subcommand() -> None: + result = run(["uv", "run", "job-research", "--help"], capture_output=True, text=True, check=False) + + assert result.returncode == 0 + assert "build-profile" in result.stdout + assert "Build candidate-profile.yaml from CV and markdown profile." in result.stdout + + +def test_installed_cli_subcommand_help_works() -> None: + result = run(["uv", "run", "job-research", "build-profile", "--help"], capture_output=True, text=True, check=False) + + assert result.returncode == 0 + assert "Usage: job-research build-profile" in result.stdout + + +def test_build_profile_writes_yaml_from_cv_and_profile(tmp_path) -> None: + cv = tmp_path / "cv.txt" + cv.write_text( + dedent( + """ + Tonio Example + Location: France + Languages: French, English + Skills: Python, SQL, Terraform + Years of experience: 3 + Data Engineer at Acme + Education: Master of Science at Example University + """ + ).strip(), + encoding="utf-8", + ) + profile = tmp_path / "profile.md" + profile.write_text( + dedent( + """ + # Candidate Profile + + ## Summary + Junior data engineer focused on Python and GCP. + + ## Target Roles + - Data Engineer + + ## Strengths + - Python + - SQL + + ## Skills To Emphasize + - GCP + - BigQuery + + ## Constraints + - CDI only + - France only + + ## Notes + - Slight preference for French listings. + - Years of experience feels closer to 4. + """ + ).strip(), + encoding="utf-8", + ) + out = tmp_path / "candidate-profile.yaml" + + result = run( + ["uv", "run", "job-research", "build-profile", "--cv", str(cv), "--profile", str(profile), "--out", str(out)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert f"candidate profile written to {out}" in result.stdout + assert "Warnings included: 1" in result.stdout + + payload = load_yaml(out) + + assert payload["name"] == "Tonio Example" + assert payload["summary"] == "Junior data engineer focused on Python and GCP." + assert payload["target_roles"] == ["Data Engineer"] + assert payload["skills"] == ["Python", "SQL", "Terraform", "GCP", "BigQuery"] + assert any(item["field"] == "years_of_experience" for item in payload["warnings"]) + + +def test_build_profile_reports_when_no_warnings_are_included(tmp_path) -> None: + cv = tmp_path / "cv.txt" + cv.write_text( + dedent( + """ + Tonio Example + Location: France + Languages: French, English + Skills: Python, SQL + Data Engineer at Acme + Education: Master of Science at Example University + """ + ).strip(), + encoding="utf-8", + ) + profile = tmp_path / "profile.md" + profile.write_text( + dedent( + """ + # Candidate Profile + + ## Summary + Junior data engineer focused on Python and GCP. + + ## Target Roles + - Data Engineer + + ## Strengths + - Python + - SQL + + ## Skills To Emphasize + - GCP + - BigQuery + + ## Constraints + - CDI only + - France only + + ## Notes + - Slight preference for French listings. + """ + ).strip(), + encoding="utf-8", + ) + out = tmp_path / "candidate-profile.yaml" + + result = run( + ["uv", "run", "job-research", "build-profile", "--cv", str(cv), "--profile", str(profile), "--out", str(out)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert f"candidate profile written to {out}" in result.stdout + assert "No warnings included." in result.stdout + + +def test_build_profile_rejects_empty_cv_text_before_writing(tmp_path) -> None: + cv = tmp_path / "cv.txt" + cv.write_text(" \n", encoding="utf-8") + profile = tmp_path / "profile.md" + profile.write_text( + dedent( + """ + # Candidate Profile + + ## Summary + Junior data engineer. + + ## Target Roles + - Data Engineer + + ## Strengths + - Python + + ## Skills To Emphasize + - BigQuery + + ## Constraints + - CDI only + + ## Notes + - Slight preference for French listings. + """ + ).strip(), + encoding="utf-8", + ) + out = tmp_path / "candidate-profile.yaml" + + result = run( + ["uv", "run", "job-research", "build-profile", "--cv", str(cv), "--profile", str(profile), "--out", str(out)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert not out.exists() + assert "No readable text found in CV input" in result.stderr diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..8b11b8e --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,39 @@ +import pytest + +from job_research.models import CandidateProfileOutput, ExperienceEntry, WarningItem +from job_research.storage import save_candidate_profile_yaml, load_yaml + + +def test_save_candidate_profile_yaml_round_trips_readable_output(tmp_path) -> None: + profile = CandidateProfileOutput( + name="Tonio", + summary="Junior data engineer focused on Python and GCP.", + target_roles=["Data Engineer"], + strengths=["Python", "SQL"], + skills_to_emphasize=["GCP", "BigQuery"], + constraints=["CDI only", "France only"], + notes=["Slight preference for French listings."], + location="France", + languages=["French", "English"], + skills=["Python", "SQL", "Terraform", "GCP", "BigQuery"], + experience_entries=[ExperienceEntry(company="A", title="Data Engineer")], + education_entries=[], + warnings=[WarningItem(field="years_of_experience", message="CV and profile disagree.")], + ) + + out = tmp_path / "candidate-profile.yaml" + save_candidate_profile_yaml(out, profile) + + payload = load_yaml(out) + + assert payload["name"] == "Tonio" + assert payload["constraints"] == ["CDI only", "France only"] + assert payload["warnings"][0]["field"] == "years_of_experience" + + +def test_load_yaml_rejects_non_mapping_root(tmp_path) -> None: + path = tmp_path / "candidate-profile.yaml" + path.write_text("[]", encoding="utf-8") + + with pytest.raises(ValueError, match="mapping"): + load_yaml(path) diff --git a/uv.lock b/uv.lock index 21ca9cd..5015469 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,303 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[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 = "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 = "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 = "job-research" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "pypdf" }, + { name = "pyyaml" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.7,<3" }, + { name = "pypdf", specifier = ">=5.0,<6" }, + { name = "pyyaml", specifier = ">=6.0,<7" }, + { name = "typer", specifier = ">=0.12,<1" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.2,<9" }] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[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.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[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 = "pypdf" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/3a/584b97a228950ed85aec97c811c68473d9b8d149e6a8c155668287cf1a28/pypdf-5.9.0.tar.gz", hash = "sha256:30f67a614d558e495e1fbb157ba58c1de91ffc1718f5e0dfeb82a029233890a1", size = 5035118, upload-time = "2025-07-27T14:04:52.364Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/d9/6cff57c80a6963e7dd183bf09e9f21604a77716644b1e580e97b259f7612/pypdf-5.9.0-py3-none-any.whl", hash = "sha256:be10a4c54202f46d9daceaa8788be07aa8cd5ea8c25c529c50dd509206382c35", size = 313193, upload-time = "2025-07-27T14:04:50.53Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +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 = "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/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/a5/756f2e6bc81a7dd79aa3c625dd01b74cabc4516628cace2caaec09ca6ff2/typer-0.26.2.tar.gz", hash = "sha256:9b4f19e08fcc9427a822d1ef467b1fe76737a2f65c7926bdeba2337d73569b68", size = 198991, upload-time = "2026-05-27T10:41:39.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/a5/6ffd702beda8798b2b82ff70805ed4a66d963557e43a5d1823ab456251a4/typer-0.26.2-py3-none-any.whl", hash = "sha256:39beff72ffbb31978a5b545f677d57edb97c6f980f433b38556deb0af25f094d", size = 123123, upload-time = "2026-05-27T10:41:40.504Z" }, +] + +[[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" }, +]