From 13877b4a678cec3a619c14b2846659628893a192 Mon Sep 17 00:00:00 2001 From: j h Date: Thu, 1 Oct 2026 21:57:39 +0900 Subject: [PATCH] initial commit --- README.md | 164 +++++++++++++++ input/.gitkeep | 0 output/.gitkeep | 0 pyproject.toml | 20 ++ src/kotoba_to_anki/__init__.py | 3 + src/kotoba_to_anki/anki_package.py | 101 ++++++++++ src/kotoba_to_anki/config.py | 70 +++++++ src/kotoba_to_anki/extractor.py | 299 ++++++++++++++++++++++++++++ src/kotoba_to_anki/logging_setup.py | 51 +++++ src/kotoba_to_anki/main.py | 144 ++++++++++++++ uv.lock | 162 +++++++++++++++ 11 files changed, 1014 insertions(+) create mode 100644 README.md create mode 100644 input/.gitkeep create mode 100644 output/.gitkeep create mode 100644 pyproject.toml create mode 100644 src/kotoba_to_anki/__init__.py create mode 100644 src/kotoba_to_anki/anki_package.py create mode 100644 src/kotoba_to_anki/config.py create mode 100644 src/kotoba_to_anki/extractor.py create mode 100644 src/kotoba_to_anki/logging_setup.py create mode 100644 src/kotoba_to_anki/main.py create mode 100644 uv.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..0406be1 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# kotoba-to-anki + +Extract "KOTOBA" Japanese vocabulary slides (image + Japanese word + English +translation) from a folder of PDF slide decks into a single CSV that's ready +to import into Anki. + +## How it works + +Each PDF's first page is treated as a title/cover slide and skipped. Every +other page contains one or two vocabulary items, each made up of: + +- one illustration/photo, +- a Japanese headword (sometimes with a smaller furigana reading next to it), +- an English translation, +- occasionally a small grammar tag such as `N`, `V`, or `adverb`. + +The script finds every embedded image on a page, splits the page into +left/right (or single, full-width) zones around those images, and assigns +each piece of text to the nearest zone based on its position. Grammar tags +are filtered out using a list in `src/kotoba_to_anki/config.py` (`POS_TAGS`) +before the remaining text is classified as Japanese or English by character +range. The larger of two Japanese text fragments becomes the headword; the +smaller becomes its furigana reading, shown in parentheses. + +This is a geometry-based (not fixed-order) approach, so it's robust to PDFs +where the tag/column layout order shifts from slide to slide -- as long as +each vocabulary item still has exactly one image nearby. + +## Setup + +Requires Python 3.10+. Using [uv](https://docs.astral.sh/uv/) (recommended): + +```bash +cd kotoba_to_anki +uv venv +uv pip install -e . +``` + +Or with a plain venv + pip: + +```bash +cd kotoba_to_anki +python3 -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +## Usage + +1. Drop your PDFs into `input/` (subfolders are fine, all `*.pdf` files are + found recursively). +2. Run the tool: + + ```bash + uv run kotoba-to-anki + # or, without the installed console script: + uv run python -m kotoba_to_anki.main + ``` + +3. Results: + - `output/kotoba_anki_deck.apkg` -- a self-contained Anki package with + every note (including its image) bundled inside. This is the + recommended way to get the deck into Anki -- see "Importing the .apkg" + below. + - `output/kotoba_anki_deck.csv` -- one row per vocabulary item, columns + `Media, Japanese, English`. The `Media` column contains an + `` tag pointing at the matching file in `output/media/`. + Kept as a plain-text/spreadsheet-friendly alternative -- see + "Importing the CSV instead" below for how to actually get the media + into Anki when using this route. + - `output/media/` -- every extracted image, named + `_p_.` (e.g. `Step2Unit575859_kotoha_p2_1.png`). + - `kotoba_to_anki.log` -- an INFO-level log in the directory you ran the + command from. It is **appended to** on every run, so history builds up + across sessions -- useful for tracking down which page of which PDF + broke on a given run. + + Use `--no-csv` or `--no-apkg` to skip either output, and `--deck-name` / + `--apkg-name` / `--csv-name` to rename them. + +All PDFs found in `input/` are combined into that single CSV/media output +(many PDFs -> one deck), matching a typical "one Anki deck per unit batch" +workflow. + +### Options + +``` +--input-dir DIR Directory of source PDFs (default: input) +--output-dir DIR Directory for the CSV + apkg + media/ folder (default: output) +--csv-name NAME Output CSV filename (default: kotoba_anki_deck.csv) +--apkg-name NAME Output .apkg filename (default: kotoba_anki_deck.apkg) +--deck-name NAME Deck name inside the .apkg (default: KOTOBA Vocabulary) +--no-csv Skip writing the CSV +--no-apkg Skip writing the .apkg +--log-name NAME Log filename, written to the current directory (default: kotoba_to_anki.log) +``` + +## Importing the .apkg (recommended) + +This is the easiest path and the one most people should use: + +1. In Anki, *File > Import*, choose `output/kotoba_anki_deck.apkg`. +2. That's it -- the note type ("KOTOBA Vocabulary"), the deck, every note, + and every image are all bundled inside the file. Anki extracts the + images into `collection.media` and creates the notes automatically; no + manual file copying, no path/field mapping step. +3. The default card shows the image + Japanese term on the front and the + English translation on the back. Feel free to edit the note type's card + template afterward in Anki (*Tools > Manage Note Types*) -- as long as + you don't change `config.ANKI_MODEL_ID`/`ANKI_DECK_ID` in a later run, + re-importing after regenerating the deck will merge into the same note + type/deck rather than creating duplicates (each note's identity is + derived from its field content, so unchanged entries are recognized as + the same note and only new/changed ones are added or updated). + +## Importing the CSV instead + +Use this only if you specifically want a plain CSV (e.g. to inspect/edit in +a spreadsheet before importing, or to feed a different tool). The CSV's +`Media` column contains bare `` tags -- Anki fields +store raw HTML, and `` is resolved by Anki against a single +**flat** `collection.media` folder (no subfolders), never as a filesystem +path relative to the CSV. That lookup only succeeds if you've put the +matching file into that folder yourself first: + +1. Copy every file from `output/media/` into your Anki profile's + `collection.media` folder (Anki: *Tools > Check Media...* will tell you + where that folder is if you're not sure; e.g. on macOS it's + `~/Library/Application Support/Anki2//collection.media`, on + Windows `%APPDATA%\Anki2\\collection.media`, on Linux + `~/.local/share/Anki2//collection.media`). +2. In Anki, *File > Import*, choose `output/kotoba_anki_deck.csv`. +3. Map the three columns to fields on a note type that has an image field + and accepts HTML (e.g. Basic, with the Media column mapped to the Front + or a dedicated "Picture" field). Since the first row is a header + (`Media,Japanese,English`), tell the importer to skip/ignore the first + line, or delete it from the CSV before importing if your Anki version + doesn't offer that option. +4. Do step 1 *before* you review the new cards -- if the image isn't in + `collection.media` yet, the card just shows a broken-image icon instead + of an error. + +## Debugging a broken run + +If a PDF's layout doesn't match expectations (e.g. a brand new grammar tag, +or a slide with an unusual number of images), the script logs a `WARNING` +line rather than crashing, and continues to the next page/PDF. Check +`kotoba_to_anki.log` for lines like: + +``` +... p4 slot 1: 1 extra English line(s) ignored near x=310: ['Adj'] (if these are a new grammar tag, add it to config.POS_TAGS) +``` + +That means a piece of text wasn't recognized as a known tag and got treated +as a stray English line. Add the new tag to `POS_TAGS` in +`src/kotoba_to_anki/config.py` and re-run. + +Other useful log signals: + +- `no embedded images found, skipping page` -- a page had no images at all + (nothing to build a card from). +- `missing Japanese text` / `missing English text` -- an item's image was + found but its paired text wasn't; the row is still written with the + missing field left blank so you can spot and fix it manually. diff --git a/input/.gitkeep b/input/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/output/.gitkeep b/output/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..60411c6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "kotoba-to-anki" +version = "0.1.0" +description = "Extract KOTOBA Japanese vocabulary flashcards (image + Japanese + English) from PDF slide decks into a single Anki-importable CSV." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "pymupdf>=1.24.0", + "genanki>=0.13.0", +] + +[project.scripts] +kotoba-to-anki = "kotoba_to_anki.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/kotoba_to_anki"] diff --git a/src/kotoba_to_anki/__init__.py b/src/kotoba_to_anki/__init__.py new file mode 100644 index 0000000..ceb9327 --- /dev/null +++ b/src/kotoba_to_anki/__init__.py @@ -0,0 +1,3 @@ +"""kotoba_to_anki: extract KOTOBA Japanese vocabulary slides from PDFs into an Anki-ready CSV.""" + +__version__ = "0.1.0" diff --git a/src/kotoba_to_anki/anki_package.py b/src/kotoba_to_anki/anki_package.py new file mode 100644 index 0000000..99a616f --- /dev/null +++ b/src/kotoba_to_anki/anki_package.py @@ -0,0 +1,101 @@ +"""Build a native Anki package (.apkg) from extracted vocabulary entries. + +Unlike the CSV output, a .apkg bundles its media files inside the package +itself. Anki copies them into the profile's collection.media folder +automatically on import, so there is no manual "find my collection.media +folder and copy files into it" step, and no ambiguity about how the image +reference in a field resolves to a file on disk. +""" +from __future__ import annotations + +import logging +from pathlib import Path + +import genanki + +from . import config +from .extractor import VocabEntry + +logger = logging.getLogger("kotoba_to_anki") + +_CSS = """ +.card { + font-family: "Helvetica Neue", Arial, sans-serif; + text-align: center; + color: #222; + background-color: white; +} +.media img { + max-width: 320px; + max-height: 320px; + margin-bottom: 12px; +} +.jp { + font-size: 32px; + margin-top: 8px; +} +.en { + font-size: 26px; + margin-top: 8px; + color: #2a6b2a; +} +""" + + +def _build_model() -> genanki.Model: + return genanki.Model( + config.ANKI_MODEL_ID, + "KOTOBA Vocabulary", + fields=[ + {"name": "Media"}, + {"name": "Japanese"}, + {"name": "English"}, + ], + templates=[ + { + "name": "Picture + Japanese -> English", + "qfmt": '
{{Media}}
\n', + "afmt": '{{FrontSide}}\n
\n
{{English}}
', + } + ], + css=_CSS, + ) + + +def write_apkg( + entries: list[VocabEntry], + media_dir: Path, + apkg_path: Path, + deck_name: str = config.DEFAULT_DECK_NAME, +) -> int: + """Write all entries to a single .apkg file. Returns the number of notes written.""" + model = _build_model() + deck = genanki.Deck(config.ANKI_DECK_ID, deck_name) + + media_files: list[str] = [] + written = 0 + + for entry in entries: + image_path = media_dir / entry.image_filename + if not image_path.exists(): + logger.warning( + "Skipping apkg note for %r: media file not found at %s", + entry.japanese or entry.english, + image_path, + ) + continue + + note = genanki.Note( + model=model, + fields=[f'', entry.japanese, entry.english], + ) + deck.add_note(note) + media_files.append(str(image_path)) + written += 1 + + package = genanki.Package(deck) + package.media_files = media_files + package.write_to_file(apkg_path) + + logger.info("Wrote %d note(s) to Anki package %s (deck %r)", written, apkg_path, deck_name) + return written diff --git a/src/kotoba_to_anki/config.py b/src/kotoba_to_anki/config.py new file mode 100644 index 0000000..7ddc4f0 --- /dev/null +++ b/src/kotoba_to_anki/config.py @@ -0,0 +1,70 @@ +"""Configuration constants for the KOTOBA -> Anki extraction pipeline. + +Tweak these values if a new PDF unit uses a layout convention (e.g. a new +grammar tag) that the extractor doesn't recognize yet. Check the log file +after a run -- unrecognized tags usually show up as "extra English line(s) +ignored" warnings, which is the signal to add a new entry to POS_TAGS below. +""" +from __future__ import annotations + +from pathlib import Path + +# --------------------------------------------------------------------------- +# Directories / filenames (all overridable via CLI flags, see main.py --help) +# --------------------------------------------------------------------------- +DEFAULT_INPUT_DIR = Path("input") +DEFAULT_OUTPUT_DIR = Path("output") +DEFAULT_MEDIA_SUBDIR = "media" +DEFAULT_CSV_NAME = "kotoba_anki_deck.csv" +DEFAULT_APKG_NAME = "kotoba_anki_deck.apkg" +DEFAULT_DECK_NAME = "KOTOBA Vocabulary" +DEFAULT_LOG_NAME = "kotoba_to_anki.log" + +# Fixed genanki model/deck IDs. These MUST stay constant across runs: Anki +# uses them (plus each note's guid, which genanki derives from its field +# content) to recognize "this is the same note type / deck" on re-import, +# so re-running the pipeline updates/merges instead of duplicating cards. +# Do not change these unless you want re-imports to create a brand new, +# unrelated note type and deck. +ANKI_MODEL_ID = 1607392319934 +ANKI_DECK_ID = 1607392319935 + +# Number of leading pages to skip in every PDF (title/cover page(s)). +TITLE_PAGES_TO_SKIP = 1 + +# --------------------------------------------------------------------------- +# Part-of-speech / grammar tag labels that appear as small standalone +# annotations on a slide (e.g. "N", "V", "adverb"). These are discarded +# before the remaining text on the slide is classified as Japanese or +# English. Matching is case-insensitive against the FULL stripped text of a +# line (punctuation/whitespace removed), so "N", "n", "N " and "(N)" all +# match the same "n" entry below. +POS_TAGS = { + "n", "v", "adj", "adj-i", "adj-na", "adj-no", "i-adj", "na-adj", + "adv", "adverb", + "exp", "expression", "phrase", + "conj", "conjunction", + "prep", "preposition", + "pron", "pronoun", + "part", "particle", + "interj", "interjection", + "noun", "verb", "adjective", + "counter", "suffix", "prefix", + "aux", "auxiliary", + "num", "numeral", +} + +# --------------------------------------------------------------------------- +# Unicode ranges used to detect Japanese text (hiragana, katakana, kanji, +# and common Japanese/fullwidth punctuation). +# --------------------------------------------------------------------------- +JAPANESE_RANGES = ( + (0x3040, 0x309F), # Hiragana + (0x30A0, 0x30FF), # Katakana + (0x4E00, 0x9FFF), # CJK Unified Ideographs (Kanji) + (0x3000, 0x303F), # CJK punctuation (incl. the full-width brackets/dot) + (0xFF00, 0xFFEF), # Fullwidth forms +) + +# Output CSV column order (matches: media / Japanese / English request). +CSV_HEADERS = ["Media", "Japanese", "English"] diff --git a/src/kotoba_to_anki/extractor.py b/src/kotoba_to_anki/extractor.py new file mode 100644 index 0000000..45d0b22 --- /dev/null +++ b/src/kotoba_to_anki/extractor.py @@ -0,0 +1,299 @@ +"""Core PDF -> vocabulary-entry extraction logic. + +Layout assumption (observed across KOTOBA vocabulary slides): + +- Page 1 of every PDF is a title/cover page and is skipped. +- Each remaining page contains 1-2 "vocabulary items" laid out side by side + (occasionally just one, centered / full width). +- Each item = one embedded image + one Japanese text line (optionally with a + smaller furigana reading line near it) + one English translation line. +- Some slides also show a small grammar/part-of-speech tag ("N", "V", + "adverb", ...) which is not part of any vocabulary item and must be + discarded (see config.POS_TAGS). + +Slide designs vary quite a bit (highlight colors, tag position, one vs two +columns, text stacking order), so extraction is done geometrically rather +than by assuming a fixed text order: + +1. Every embedded image on the page is located (with its bounding box). +2. The page width is split into horizontal "slots", one per image, using the + midpoints between neighboring image centers as slot boundaries. This + works for any number of images (1, 2, or more) per page. +3. Every text line on the page is assigned to the slot whose image center is + horizontally closest. +4. Within a slot, text lines are classified as Japanese or English by + character ranges. Grammar tags are dropped beforehand. +5. The largest Japanese text in a slot is treated as the headword; a second, + smaller Japanese text is treated as its furigana reading. The + bottom-most English line is treated as the translation. +""" +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import pymupdf + +from . import config + +logger = logging.getLogger("kotoba_to_anki") + +_TAG_STRIP_RE = re.compile(r"[\s.:()\[\]・、。]+") + + +@dataclass +class VocabEntry: + japanese: str + english: str + image_filename: str + + +@dataclass +class _Line: + text: str + x0: float + y0: float + x1: float + y1: float + size: float + + @property + def x_center(self) -> float: + return (self.x0 + self.x1) / 2 + + +@dataclass +class _ImageSlot: + xref: int + x0: float + y0: float + x1: float + y1: float + slot_x0: float = 0.0 + slot_x1: float = 0.0 + japanese_lines: list[_Line] = field(default_factory=list) + english_lines: list[_Line] = field(default_factory=list) + + @property + def x_center(self) -> float: + return (self.x0 + self.x1) / 2 + + +def _is_japanese_char(ch: str) -> bool: + code = ord(ch) + return any(lo <= code <= hi for lo, hi in config.JAPANESE_RANGES) + + +def _contains_japanese(text: str) -> bool: + return any(_is_japanese_char(ch) for ch in text) + + +def _is_pos_tag(text: str) -> bool: + cleaned = _TAG_STRIP_RE.sub("", text).strip().lower() + return cleaned in config.POS_TAGS + + +def _extract_lines(page: "pymupdf.Page") -> list[_Line]: + """Flatten the page's text into line-level records with bounding boxes.""" + lines: list[_Line] = [] + page_dict = page.get_text("dict") + for block in page_dict.get("blocks", []): + for line in block.get("lines", []): + spans = line.get("spans", []) + if not spans: + continue + text = "".join(span.get("text", "") for span in spans).strip() + if not text: + continue + x0 = min(s["bbox"][0] for s in spans) + y0 = min(s["bbox"][1] for s in spans) + x1 = max(s["bbox"][2] for s in spans) + y1 = max(s["bbox"][3] for s in spans) + size = max(s.get("size", 0.0) for s in spans) + lines.append(_Line(text=text, x0=x0, y0=y0, x1=x1, y1=y1, size=size)) + return lines + + +def _build_slots(page: "pymupdf.Page") -> list[_ImageSlot]: + """One slot per embedded image, with slot x-boundaries splitting the page.""" + infos = [info for info in page.get_image_info(xrefs=True) if info.get("xref")] + if not infos: + return [] + + slots = [ + _ImageSlot( + xref=info["xref"], + x0=info["bbox"][0], + y0=info["bbox"][1], + x1=info["bbox"][2], + y1=info["bbox"][3], + ) + for info in infos + ] + slots.sort(key=lambda s: s.x_center) + + page_width = page.rect.width + for i, slot in enumerate(slots): + if i == 0: + left_bound = 0.0 + else: + left_bound = (slots[i - 1].x_center + slot.x_center) / 2 + if i == len(slots) - 1: + right_bound = page_width + else: + right_bound = (slot.x_center + slots[i + 1].x_center) / 2 + slot.slot_x0 = left_bound + slot.slot_x1 = right_bound + return slots + + +def _assign_lines_to_slots( + lines: list[_Line], slots: list[_ImageSlot], pdf_name: str, page_number: int +) -> None: + for line in lines: + if _is_pos_tag(line.text): + logger.debug("%s p%s: dropping tag-like line %r", pdf_name, page_number, line.text) + continue + + best_slot = min(slots, key=lambda s: abs(line.x_center - s.x_center)) + + if _contains_japanese(line.text): + best_slot.japanese_lines.append(line) + else: + best_slot.english_lines.append(line) + + +def _resolve_japanese(slot: _ImageSlot, pdf_name: str, page_number: int) -> Optional[str]: + if not slot.japanese_lines: + return None + ordered = sorted(slot.japanese_lines, key=lambda l: l.size, reverse=True) + headword = ordered[0] + reading = ordered[1] if len(ordered) > 1 else None + if len(ordered) > 2: + logger.warning( + "%s p%s: %d extra Japanese line(s) ignored near x=%.0f: %s", + pdf_name, + page_number, + len(ordered) - 2, + slot.x_center, + [l.text for l in ordered[2:]], + ) + if reading: + return f"{headword.text} ({reading.text})" + return headword.text + + +def _resolve_english(slot: _ImageSlot, pdf_name: str, page_number: int) -> Optional[str]: + if not slot.english_lines: + return None + ordered = sorted(slot.english_lines, key=lambda l: l.y0) + translation = ordered[-1] + if len(ordered) > 1: + logger.warning( + "%s p%s: %d extra English line(s) ignored near x=%.0f: %s " + "(if these are a new grammar tag, add it to config.POS_TAGS)", + pdf_name, + page_number, + len(ordered) - 1, + slot.x_center, + [l.text for l in ordered[:-1]], + ) + return translation.text + + +def _save_image(doc: "pymupdf.Document", xref: int, media_dir: Path, base_name: str) -> Optional[str]: + try: + image = doc.extract_image(xref) + except Exception: + logger.exception("Failed to extract image xref=%s (%s)", xref, base_name) + return None + ext = image.get("ext", "png") + filename = f"{base_name}.{ext}" + out_path = media_dir / filename + try: + out_path.write_bytes(image["image"]) + except Exception: + logger.exception("Failed to write image file %s", out_path) + return None + return filename + + +def extract_pdf(pdf_path: Path, media_dir: Path) -> list[VocabEntry]: + """Extract all vocabulary entries from a single PDF (skipping its title page).""" + entries: list[VocabEntry] = [] + pdf_name = pdf_path.name + + try: + doc = pymupdf.open(pdf_path) + except Exception: + logger.exception("Could not open PDF: %s", pdf_path) + return entries + + logger.info("Processing %s (%d pages)", pdf_name, doc.page_count) + + for page_index in range(config.TITLE_PAGES_TO_SKIP, doc.page_count): + page_number = page_index + 1 + page = doc.load_page(page_index) + + try: + slots = _build_slots(page) + if not slots: + logger.warning("%s p%s: no embedded images found, skipping page", pdf_name, page_number) + continue + + lines = _extract_lines(page) + _assign_lines_to_slots(lines, slots, pdf_name, page_number) + + for slot_index, slot in enumerate(slots, start=1): + japanese = _resolve_japanese(slot, pdf_name, page_number) + english = _resolve_english(slot, pdf_name, page_number) + + if not japanese and not english: + logger.warning( + "%s p%s slot %d: no text found near image xref=%s, skipping item", + pdf_name, + page_number, + slot_index, + slot.xref, + ) + continue + if not japanese: + logger.warning( + "%s p%s slot %d: missing Japanese text (english=%r)", + pdf_name, page_number, slot_index, english, + ) + if not english: + logger.warning( + "%s p%s slot %d: missing English text (japanese=%r)", + pdf_name, page_number, slot_index, japanese, + ) + + base_name = f"{pdf_path.stem}_p{page_number}_{slot_index}" + filename = _save_image(doc, slot.xref, media_dir, base_name) + if not filename: + logger.warning( + "%s p%s slot %d: skipping item, image could not be saved", + pdf_name, page_number, slot_index, + ) + continue + + entry = VocabEntry( + japanese=japanese or "", + english=english or "", + image_filename=filename, + ) + entries.append(entry) + logger.info( + "%s p%s slot %d: extracted entry JP=%r EN=%r image=%s", + pdf_name, page_number, slot_index, entry.japanese, entry.english, filename, + ) + except Exception: + logger.exception("%s p%s: unexpected error, skipping page", pdf_name, page_number) + continue + + doc.close() + logger.info("Finished %s: %d entries extracted", pdf_name, len(entries)) + return entries diff --git a/src/kotoba_to_anki/logging_setup.py b/src/kotoba_to_anki/logging_setup.py new file mode 100644 index 0000000..ff68ba8 --- /dev/null +++ b/src/kotoba_to_anki/logging_setup.py @@ -0,0 +1,51 @@ +"""Logging configuration. + +Logs at INFO level (and above) to both the console and a log file in the +base directory (the directory the script is run from). The log file is +opened in append mode so history builds up across multiple runs, which is +useful for debugging intermittent PDF layout issues over time. +""" +from __future__ import annotations + +import logging +from pathlib import Path + +_LOGGER_NAME = "kotoba_to_anki" + + +def setup_logging(base_dir: Path, log_name: str) -> logging.Logger: + """Configure and return the package logger. + + Safe to call multiple times (e.g. in tests) -- handlers are only + attached once per process. + """ + base_dir.mkdir(parents=True, exist_ok=True) + log_path = base_dir / log_name + + logger = logging.getLogger(_LOGGER_NAME) + logger.setLevel(logging.INFO) + logger.propagate = False + + if logger.handlers: + return logger + + fmt = logging.Formatter( + fmt="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + file_handler = logging.FileHandler(log_path, mode="a", encoding="utf-8") + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(fmt) + + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(fmt) + + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + logger.info("=" * 78) + logger.info("Logging initialized -> %s (append mode)", log_path) + + return logger diff --git a/src/kotoba_to_anki/main.py b/src/kotoba_to_anki/main.py new file mode 100644 index 0000000..476dd02 --- /dev/null +++ b/src/kotoba_to_anki/main.py @@ -0,0 +1,144 @@ +"""CLI entry point: convert a directory of KOTOBA vocabulary PDFs into a +single Anki-importable CSV (many PDFs -> one CSV + one media folder). +""" +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +from . import config +from .anki_package import write_apkg +from .extractor import extract_pdf +from .logging_setup import setup_logging + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Extract KOTOBA vocabulary (image + Japanese + English) from every " + "PDF in --input-dir into a single Anki-importable CSV in --output-dir." + ) + ) + parser.add_argument( + "--input-dir", + type=Path, + default=config.DEFAULT_INPUT_DIR, + help="Directory containing the source PDFs, searched recursively. Default: %(default)s", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=config.DEFAULT_OUTPUT_DIR, + help="Directory to write the CSV and media/ folder into. Default: %(default)s", + ) + parser.add_argument( + "--csv-name", + default=config.DEFAULT_CSV_NAME, + help="Filename for the combined output CSV. Default: %(default)s", + ) + parser.add_argument( + "--apkg-name", + default=config.DEFAULT_APKG_NAME, + help="Filename for the combined output Anki package (.apkg). Default: %(default)s", + ) + parser.add_argument( + "--deck-name", + default=config.DEFAULT_DECK_NAME, + help="Deck name to use inside the generated .apkg. Default: %(default)s", + ) + parser.add_argument( + "--no-csv", + action="store_true", + help="Skip writing the CSV output.", + ) + parser.add_argument( + "--no-apkg", + action="store_true", + help="Skip writing the .apkg output.", + ) + parser.add_argument( + "--log-name", + default=config.DEFAULT_LOG_NAME, + help="Filename for the log file, written to the current working directory " + "and appended to on every run. Default: %(default)s", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + base_dir = Path.cwd() + logger = setup_logging(base_dir, args.log_name) + + input_dir: Path = args.input_dir + output_dir: Path = args.output_dir + media_dir = output_dir / config.DEFAULT_MEDIA_SUBDIR + csv_path = output_dir / args.csv_name + apkg_path = output_dir / args.apkg_name + + if not input_dir.exists(): + logger.error("Input directory does not exist: %s", input_dir) + return 1 + + pdf_paths = sorted(input_dir.rglob("*.pdf")) + if not pdf_paths: + logger.warning("No PDFs found in %s", input_dir) + return 0 + + output_dir.mkdir(parents=True, exist_ok=True) + media_dir.mkdir(parents=True, exist_ok=True) + + logger.info("Run started: %d PDF(s) found in %s", len(pdf_paths), input_dir) + + all_entries = [] + pdf_success = 0 + pdf_failed = 0 + + for pdf_path in pdf_paths: + try: + entries = extract_pdf(pdf_path, media_dir) + except Exception: + logger.exception( + "Fatal error while processing %s, continuing with remaining PDFs", pdf_path + ) + pdf_failed += 1 + continue + + if entries: + pdf_success += 1 + else: + logger.warning("%s produced no vocabulary entries", pdf_path.name) + + all_entries.extend(entries) + + if not args.no_csv: + with csv_path.open("w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(config.CSV_HEADERS) + writer.writerows( + [f'', e.japanese, e.english] for e in all_entries + ) + logger.info("Wrote %d row(s) to CSV %s", len(all_entries), csv_path) + else: + logger.info("Skipping CSV output (--no-csv)") + + if not args.no_apkg: + write_apkg(all_entries, media_dir, apkg_path, deck_name=args.deck_name) + else: + logger.info("Skipping .apkg output (--no-apkg)") + + logger.info( + "Run finished: %d PDF(s) OK, %d PDF(s) failed, %d total vocabulary entries", + pdf_success, + pdf_failed, + len(all_entries), + ) + logger.info("Media files written to %s", media_dir) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3332c4a --- /dev/null +++ b/uv.lock @@ -0,0 +1,162 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "cached-property" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/4b/3d870836119dbe9a5e3c9a61af8cc1a8b69d75aea564572e385882d5aefb/cached_property-2.0.1.tar.gz", hash = "sha256:484d617105e3ee0e4f1f58725e72a8ef9e93deee462222dbd51cd91230897641", size = 10574, upload-time = "2024-10-25T15:43:55.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/0e/7d8225aab3bc1a0f5811f8e1b557aa034ac04bdf641925b30d3caf586b28/cached_property-2.0.1-py3-none-any.whl", hash = "sha256:f617d70ab1100b7bcf6e42228f9ddcb78c676ffa167278d9f730d1c2fba69ccb", size = 7428, upload-time = "2024-10-25T15:43:54.711Z" }, +] + +[[package]] +name = "chevron" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/1f/ca74b65b19798895d63a6e92874162f44233467c9e7c1ed8afd19016ebe9/chevron-0.14.0.tar.gz", hash = "sha256:87613aafdf6d77b6a90ff073165a61ae5086e21ad49057aa0e53681601800ebf", size = 11440, upload-time = "2021-01-02T22:47:59.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/93/342cc62a70ab727e093ed98e02a725d85b746345f05d2b5e5034649f4ec8/chevron-0.14.0-py3-none-any.whl", hash = "sha256:fbf996a709f8da2e745ef763f482ce2d311aa817d287593a5b990d6d6e4f0443", size = 11595, upload-time = "2021-01-02T22:47:57.847Z" }, +] + +[[package]] +name = "frozendict" +version = "2.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/bd/920b1c5ff1df427a5fc3fd4c2f13b0b0e720c3d57fafd80557094c1fefe0/frozendict-2.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bd37c087a538944652363cfd77fb7abe8100cc1f48afea0b88b38bf0f469c3d2", size = 59848, upload-time = "2025-11-11T22:37:10.964Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/e3e186925b1d84f816d458be4e2ea785bbeba15fd2e9e85c5ae7e7a90421/frozendict-2.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b96f224a5431889f04b2bc99c0e9abe285679464273ead83d7d7f2a15907d35", size = 38164, upload-time = "2025-11-11T22:37:12.622Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/af931d88c51ee2fcbf8c817557dcb975133a188f1b44bfa82caa940beeab/frozendict-2.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c1781f28c4bbb177644b3cb6d5cf7da59be374b02d91cdde68d1d5ef32e046b", size = 38341, upload-time = "2025-11-11T22:37:13.611Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/c1fd4f736758cf93939cc3b7c8399fe1db0c121881431d41fcdbae344343/frozendict-2.4.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8a06f6c3d3b8d487226fdde93f621e04a54faecc5bf5d9b16497b8f9ead0ac3e", size = 112882, upload-time = "2025-11-11T22:37:15.098Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/304294f7cd099582a98d63e7a9cec34a9905d07f7628b42fc3f9c9a9bc94/frozendict-2.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b809d1c861436a75b2b015dbfd94f6154fa4e7cb0a70e389df1d5f6246b21d1e", size = 120482, upload-time = "2025-11-11T22:37:16.182Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/689212ea4124fcbd097c0ac02c2c6a4e345ccc132d9104d054ff6b43ab64/frozendict-2.4.7-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75eefdf257a84ea73d553eb80d0abbff0af4c9df62529e4600fd3f96ff17eeb3", size = 113527, upload-time = "2025-11-11T22:37:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9b/38a762f4e76903efd4340454cac2820f583929457822111ef6a00ff1a3f4/frozendict-2.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4d2b27d8156922c9739dd2ff4f3934716e17cfd1cf6fb61aa17af7d378555e9", size = 130068, upload-time = "2025-11-11T22:37:18.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/41/9751e9ec1a2e810e8f961aea4f8958953157478daff6b868277ab7c5ef8c/frozendict-2.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ebd953c41408acfb8041ff9e6c3519c09988fb7e007df7ab6b56e229029d788", size = 126184, upload-time = "2025-11-11T22:37:19.789Z" }, + { url = "https://files.pythonhosted.org/packages/71/be/b179b5f200cb0f52debeccc63b786cabcc408c4542f47c4245f978ad36e3/frozendict-2.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c64d34b802912ee6d107936e970b90750385a1fdfd38d310098b2918ba4cbf2", size = 120168, upload-time = "2025-11-11T22:37:20.929Z" }, + { url = "https://files.pythonhosted.org/packages/25/c2/1536bc363dbce414e6b632f496aa8219c0db459a99eeafa02eba380e4cfa/frozendict-2.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:294a7d7d51dd979021a8691b46aedf9bd4a594ce3ed33a4bdf0a712d6929d712", size = 114997, upload-time = "2025-11-11T22:37:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/29/63/3e9efb490c00a0bf3c7bbf72fc73c90c4a6ebe30595e0fc44f59182b2ae7/frozendict-2.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f65d1b90e9ddc791ea82ef91a9ae0ab27ef6c0cfa88fadfa0e5ca5a22f8fa22f", size = 117292, upload-time = "2025-11-11T22:37:22.978Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/d25b1e94f9b0e64025d5cadc77b9b857737ebffd8963ee91de7c5a06415a/frozendict-2.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:82d5272d08451bcef6fb6235a0a04cf1816b6b6815cec76be5ace1de17e0c1a4", size = 110656, upload-time = "2025-11-11T22:38:37.652Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5d/0e7e3294e18bf41d38dbc9ee82539be607c8d26e763ae12d9e41f03f2dae/frozendict-2.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5943c3f683d3f32036f6ca975e920e383d85add1857eee547742de9c1f283716", size = 113225, upload-time = "2025-11-11T22:38:38.631Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fb/b72c9b261ac7a7803528aa63bba776face8ad8d39cc4ca4825ddaa7777a9/frozendict-2.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:88c6bea948da03087035bb9ca9625305d70e084aa33f11e17048cb7dda4ca293", size = 126713, upload-time = "2025-11-11T22:38:39.588Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/e13af40bd9ef27b5c9ba10b0e31b03acac9468236b878dab030c75102a47/frozendict-2.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ffd1a9f9babec9119712e76a39397d8aa0d72ef8c4ccad917c6175d7e7f81b74", size = 114166, upload-time = "2025-11-11T22:38:41.073Z" }, + { url = "https://files.pythonhosted.org/packages/40/2b/435583b11f5332cd3eb479d0a67a87bc9247c8b094169b07bd8f0777fc48/frozendict-2.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0ff6f57854cc8aa8b30947ec005f9246d96e795a78b21441614e85d39b708822", size = 121542, upload-time = "2025-11-11T22:38:42.199Z" }, + { url = "https://files.pythonhosted.org/packages/38/25/097f3c0dc916d7c76f782cb65544e683ff3940a0ed997fc32efdb0989c45/frozendict-2.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d774df483c12d6cba896eb9a1337bbc5ad3f564eb18cfaaee3e95fb4402f2a86", size = 118610, upload-time = "2025-11-11T22:38:43.339Z" }, + { url = "https://files.pythonhosted.org/packages/61/d1/6964158524484d7f3410386ff27cbc8f33ef06f8d9ee0e188348efb9a139/frozendict-2.4.7-cp310-cp310-win32.whl", hash = "sha256:a10d38fa300f6bef230fae1fdb4bc98706b78c8a3a2f3140fde748469ef3cfe8", size = 34547, upload-time = "2025-11-11T22:38:44.327Z" }, + { url = "https://files.pythonhosted.org/packages/94/27/c22d614332c61ace4406542787edafaf7df533c6f02d1de8979d35492587/frozendict-2.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:dd518f300e5eb6a8827bee380f2e1a31c01dc0af069b13abdecd4e5769bd8a97", size = 37693, upload-time = "2025-11-11T22:38:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d8/9d6604357b1816586612e0e89bab6d8a9c029e95e199862dc99ce8ae2ed5/frozendict-2.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:3842cfc2d69df5b9978f2e881b7678a282dbdd6846b11b5159f910bc633cbe4f", size = 35563, upload-time = "2025-11-11T22:38:46.642Z" }, + { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" }, +] + +[[package]] +name = "genanki" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cached-property" }, + { name = "chevron" }, + { name = "frozendict" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/07/4459ffd44e8abfd52743f915ab6c0d4c227ee9da3f7f649c930146a93438/genanki-0.13.1.tar.gz", hash = "sha256:84d090423a8879520465bfe9784083edacb8d35e2ba511fa5a1bdef01d8f71ed", size = 23340, upload-time = "2023-11-12T18:25:16.802Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/7e/6c74ea7aaf2a36fd7df281267fade72e1f06ed1e44315bd77af2c6f82800/genanki-0.13.1-py3-none-any.whl", hash = "sha256:65b59434008588a1213b940474d1aca8cca83243af6fc0e26200b560efe4d9e3", size = 16177, upload-time = "2023-11-12T18:25:15.201Z" }, +] + +[[package]] +name = "kotoba-to-anki" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "genanki" }, + { name = "pymupdf" }, +] + +[package.metadata] +requires-dist = [ + { name = "genanki", specifier = ">=0.13.0" }, + { name = "pymupdf", specifier = ">=1.24.0" }, +] + +[[package]] +name = "pymupdf" +version = "1.28.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/fb/b6761fa2d5266f2cdb24c3b91f4023070ab7848381417678e7a289a1d52a/pymupdf-1.28.2.tar.gz", hash = "sha256:5e0be7908a715aa20333caddd73f1d6f01e4cd0c26e869fa2dd0b7f344da2249", size = 87903557, upload-time = "2026-08-06T21:43:23.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/51/550c9a75c4ff3245cb4ecb7bb95cbe2ab7374230b8e2b7a1f7259444150b/pymupdf-1.28.2-cp310-abi3-macosx_10_15_x86_64.whl", hash = "sha256:5fc315b425ff1f7afdd1ea2f348205cb19b806767daae7ce4d64115799c2bae1", size = 24645079, upload-time = "2026-08-06T21:37:25.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/3591f781b417b382a8487a2356e927acfe858b1043bab0ec47f6805bb109/pymupdf-1.28.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7113846b35dbf0a033f088e4f4fb543dabeb4b0b12c112966a1ca1ee2d5eacae", size = 23875605, upload-time = "2026-08-06T21:37:40.369Z" }, + { url = "https://files.pythonhosted.org/packages/d2/86/4a68f080b71b46802178346af46486e1697508e760855ff5f3b218a6dff7/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3050a233dde1211efe89ada74e2add6238436434159f46097a1423aad2842545", size = 25095554, upload-time = "2026-08-06T21:37:58.485Z" }, + { url = "https://files.pythonhosted.org/packages/c7/06/dace3e27af26690cb20bead80dbac42941b0841eb689b8aabbd67dde16f0/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:397d6715c1f0df7548a92d0afd8ce370fc48fa47aeefac16be2bc04a16a8227f", size = 25762500, upload-time = "2026-08-06T21:38:17.438Z" }, + { url = "https://files.pythonhosted.org/packages/e5/61/4146dfa1d8172a1ce8d59f0eed94896ddefb8deb2274534d0522fbb8abf5/pymupdf-1.28.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f89fb2d86d07d643a269f17a093105057e20c79c1d06c103b53600067b6d2b01", size = 25986309, upload-time = "2026-08-06T21:38:35.472Z" }, + { url = "https://files.pythonhosted.org/packages/52/60/1fb6e64676f7500ebe89054b9e5bbbe14d3101c92d5f1a40ac9a35227673/pymupdf-1.28.2-cp310-abi3-win32.whl", hash = "sha256:530ef543a3885b3b81cb72a854e7c5a625a9233201221132bb6c31698c6a2bdb", size = 18525353, upload-time = "2026-08-06T21:38:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/4a/61/d563bbccba262f9dd6d2d35ccb72593648184d886188efb12d9ce8f34dd6/pymupdf-1.28.2-cp310-abi3-win_amd64.whl", hash = "sha256:ebd244918798502d7b4504c90410d1711a4d7675a32584ca30f1bab419ecbffe", size = 19826532, upload-time = "2026-08-06T21:39:00.213Z" }, + { url = "https://files.pythonhosted.org/packages/e2/93/08f404a1f0155fe24137cf2d3aabd3e2b4b08c62053ed89c60f2611be3e9/pymupdf-1.28.2-cp310-abi3-win_arm64.whl", hash = "sha256:ffe91a24edc75c80da2a4b62f50fc0f54632d34fc8fe4cbc48e5c7ff07cf8fb4", size = 19759252, upload-time = "2026-08-06T21:39:12.937Z" }, + { url = "https://files.pythonhosted.org/packages/58/8c/d897dcd32a25b58186c968b15ce4324ca029e9d96460de12325314e390be/pymupdf-1.28.2-cp313-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2e1b574c0fd2cb238021033fd3c0f9c4388816638df064e4bfb56d9d81736dc8", size = 18399403, upload-time = "2026-08-06T21:39:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f1/de34a1c53fe2bf8c6e71db84b0ced782d408970c9810d2b456a2ae96814c/pymupdf-1.28.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:fd481ed48bef56305c41fb7e05a055c03345c899c7b101dad086258b438f8168", size = 25802333, upload-time = "2026-08-06T21:39:41.426Z" }, +] + +[[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" }, + { 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" }, +] -- 2.49.1