From 22404fbd6b9efdbb282d8664f8203b01c8dc3982 Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 17:45:18 -0400 Subject: [PATCH 01/13] cli: add __main__ guard so `python -m cmos.cli` actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `if __name__ == "__main__": sys.exit(main())` at the bottom of cli.py, `python -m cmos.cli format ` imports the module but never invokes main(), so the process silently exits 0 with empty stdout — indistinguishable from a successful run that produced no output. Discovered during real-draft testing on 2026-04-11. Adds a regression test that subprocesses the CLI with --help and asserts on stdout content. argparse --help exits 0 in both broken and fixed states; stdout content is the only discriminator. Both invocation paths now work: - uv run cmos format (pyproject script entry) - uv run python -m cmos.cli format (module invocation) --- src/cmos/cli.py | 4 ++++ tests/test_cli.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/cmos/cli.py b/src/cmos/cli.py index 3559304..beebc77 100644 --- a/src/cmos/cli.py +++ b/src/cmos/cli.py @@ -84,3 +84,7 @@ def main(argv: list[str] | None = None) -> int: sys.stdout.write(reformat_draft(text, concurrency=args.concurrency)) return 0 return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 725ae7a..55baf5d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,8 @@ formatter so no API calls happen; the goal is to pin the glue logic, not the LLM behavior. """ +import subprocess +import sys from pathlib import Path import pytest @@ -68,6 +70,37 @@ def test_reformat_draft_bibliography_heading_preserved(): assert "## Bibliography" in output +def test_python_dash_m_invocation_actually_runs_main(): + """Regression test: `python -m cmos.cli` must actually invoke main(). + + Without an `if __name__ == "__main__"` guard at the bottom of cli.py, + `python -m cmos.cli` imports the module body but never calls main(), + so the process silently exits 0 with empty stdout. That looks + indistinguishable from a successful run that produced no output — + the worst kind of bug, since callers assume the pipeline ran. This + test forces the guard to exist by invoking the CLI as a subprocess + with --help and asserting argparse actually fired. + """ + result = subprocess.run( + [sys.executable, "-m", "cmos.cli", "--help"], + capture_output=True, + text=True, + timeout=10, + ) + # argparse --help exits 0 whether or not main() ran, so the + # discriminator is the stdout content. Without main(), stdout is empty. + assert result.returncode == 0, ( + f"expected exit 0, got {result.returncode}; stderr={result.stderr!r}" + ) + assert result.stdout, ( + "stdout was empty — `python -m cmos.cli` likely silently exited " + "without invoking main(). Check that cli.py has an " + '`if __name__ == "__main__": sys.exit(main())` guard at the bottom.' + ) + assert "format" in result.stdout + assert "usage" in result.stdout.lower() + + def test_reformat_draft_preserves_order_under_concurrency(): # With concurrent execution the formatter is called on all entries in # parallel; the CLI must reassemble them in input order regardless of -- 2.40.1 From 2a8c72ae2298f52b41215574482544df0a32723d Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 17:46:00 -0400 Subject: [PATCH 02/13] gitignore: protect rough_drafts/*.txt from accidental commits The user's drafts in rough_drafts/ are real in-progress academic work. Project discipline (per memory) is to keep them untracked, but a single accidental `git add -A` could leak unpublished scholarship into git history. Adding the pattern to .gitignore makes the protection structural rather than discipline-based. Scoped to .txt only (the docx->txt converted form the project actually consumes); does not affect sample.md, .gitkeep, or any other extensions that might land in rough_drafts/ later. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 71230a5..65734ba 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ logs/* .env .env.* !.env.example + +# rough drafts: real in-progress academic work, do not commit +rough_drafts/*.txt -- 2.40.1 From 28ca3ac928a8c8e626e33bbe26991325df63a613 Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 18:20:50 -0400 Subject: [PATCH 03/13] v2 chunk 1: scaffold note formatter (Path B parallel artifact) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begin v2 (in-text citation note form) as a parallel artifact to the v1 bibliography formatter, per the Path B architectural decision: no shared mutable state, no shared prompt content, no v1 changes. New artifacts: - src/cmos/parser.py: add find_notes() / NoteDefinition / NotesParseResult as siblings to split_bibliography(). Targets pandoc-style markdown footnote definitions [^marker]: text. Single-line only; multi-line continuation deferred. - src/cmos/note_formatter.py: new module mirroring formatter.py. 17-rule SYSTEM_PROMPT_NOTES for CMOS 18 first-occurrence note form. Reuses cmos.runtime_validator.validate() unchanged — its structural checks all apply to note form too. Caller injection, retry loop, and model selection mirror v1. - tests/test_parser.py: 7 new tests for find_notes(). - tests/test_note_formatter.py: 11 new tests mirroring v1 test discipline (no API calls, fake-caller injection, retry semantics, prompt smoke checks). - exemplars/notes/: new subdirectory with 3 hand-synthesized first-occurrence note exemplars (book, journal article, chapter in edited book). Uses expected_note as the field name (not v1's expected_bibliography). harness/score.py:load_exemplars uses a non-recursive glob, so the v1 canary loader does not see these — Path B isolation is automatic. v1 untouched. v1 canary still loads exactly 17 exemplars. Full test suite: 96 v1 + 18 new v2 = 114 passing. Smoke-tested all 3 v2 exemplars against real GPT-5 (not fakes), 2 runs each. book_first_yu and journal_first_kwon: 4/4 byte-perfect on the first try. chapter_first_doyle: 0/2, surfacing two known prompt gaps for the next iteration: 1. Publisher abbreviation not expanded ("U of Chicago Press" preserved instead of "University of Chicago Press"). v1's formatter.py rule 14 is missing from the v2 prompt. 2. "ed." pluralized to "eds." for multiple editors. CMOS NB uses "ed." invariantly regardless of editor count. Both gaps are addressable prompt edits, not architectural problems — exactly the kind of finding the dev loop is designed to surface. Out of scope (deferred to chunk 2 and later): CLI extension, shortened-form generation, document reassembly, harness scoring loop integration, v2 linter rules, real-draft testing. --- exemplars/notes/book_first_yu.toml | 34 +++ exemplars/notes/chapter_first_doyle.toml | 32 +++ exemplars/notes/journal_first_kwon.toml | 34 +++ src/cmos/note_formatter.py | 250 +++++++++++++++++++++++ src/cmos/parser.py | 63 +++++- tests/test_note_formatter.py | 117 +++++++++++ tests/test_parser.py | 87 +++++++- 7 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 exemplars/notes/book_first_yu.toml create mode 100644 exemplars/notes/chapter_first_doyle.toml create mode 100644 exemplars/notes/journal_first_kwon.toml create mode 100644 src/cmos/note_formatter.py create mode 100644 tests/test_note_formatter.py diff --git a/exemplars/notes/book_first_yu.toml b/exemplars/notes/book_first_yu.toml new file mode 100644 index 0000000..c55f123 --- /dev/null +++ b/exemplars/notes/book_first_yu.toml @@ -0,0 +1,34 @@ +# First-occurrence full note for a single-author book. +# +# Mirrors the v1 exemplar `book_single_author_yu.toml` (same source, same +# author, same publisher) but expects CMOS 18 NOTE form rather than +# bibliography form. Cited passage is on a fictional specific page (45) +# to exercise the rule that note form takes a specific page, not a +# range. +# +# Exercises: +# - Author in NORMAL order ("Charles Yu", not "Yu, Charles") +# - Comma separation between elements (vs periods in bibliography) +# - Publisher in PARENTHESES with year +# - No place of publication (CMOS 14.30, 18th ed.) +# - Specific page number +# - Italicized book title +# - Terminal period at end of note +# +# Field naming note: this exemplar uses `expected_note` (not v1's +# `expected_bibliography`). The v2 scoring harness loader is not yet +# wired up; chunk 1 of v2 only smoke-tests these via direct tomllib +# loading. The field name change is forward-compatible. +source = "Real published book; this is a fabricated specific page citation for testing note form" +type = "note_book" +tags = ["note", "book", "single-author", "first-occurrence"] +canary = true +messy_input = "yu, charles. interior chinatown. New York: Pantheon Books, 2020. p. 45." +expected_note = "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + +[canonical] +author = "Charles Yu" +title = "Interior Chinatown" +publisher = "Pantheon Books" +year = 2020 +page = 45 diff --git a/exemplars/notes/chapter_first_doyle.toml b/exemplars/notes/chapter_first_doyle.toml new file mode 100644 index 0000000..9f5e21f --- /dev/null +++ b/exemplars/notes/chapter_first_doyle.toml @@ -0,0 +1,32 @@ +# First-occurrence full note for a chapter in an edited book. +# +# Mirrors the v1 exemplar `book_chapter_doyle.toml` (same chapter, same +# editors, same publisher, same year) but expects CMOS 18 NOTE form +# rather than bibliography form. The cited passage is on a fictional +# specific page (117) within the chapter's full range. +# +# Exercises: +# - Author in NORMAL order ("Kathleen Doyle") +# - Chapter title in straight quotes with closing comma INSIDE the +# quotes ("The Queen Mary Psalter,") +# - Lowercase "in" before the italicized book title (CMOS note form) +# - "ed." abbreviation, NOT "Edited by" +# - Editors in NORMAL order ("P. J. M. Marks and Stephen Parkin") +# - Publisher in PARENTHESES with year +# - Specific page citation +# - Terminal period +source = "Real published chapter; specific cited page is fabricated for note-form testing" +type = "note_chapter" +tags = ["note", "chapter", "edited-volume", "first-occurrence", "two-editors"] +canary = true +messy_input = "Kathleen Doyle, 'the queen mary psalter,' in The Book by Design: The Remarkable Story of the World's Greatest Invention, P. J. M. Marks and Stephen Parkin eds., Chicago: U of Chicago Press, 2023, pp. 114-120, cited p. 117." +expected_note = "Kathleen Doyle, \"The Queen Mary Psalter,\" in *The Book by Design: The Remarkable Story of the World's Greatest Invention*, ed. P. J. M. Marks and Stephen Parkin (University of Chicago Press, 2023), 117." + +[canonical] +author = "Kathleen Doyle" +chapter_title = "The Queen Mary Psalter" +book_title = "The Book by Design: The Remarkable Story of the World's Greatest Invention" +editors = "ed. P. J. M. Marks and Stephen Parkin" +publisher = "University of Chicago Press" +year = 2023 +page = 117 diff --git a/exemplars/notes/journal_first_kwon.toml b/exemplars/notes/journal_first_kwon.toml new file mode 100644 index 0000000..6c907ac --- /dev/null +++ b/exemplars/notes/journal_first_kwon.toml @@ -0,0 +1,34 @@ +# First-occurrence full note for a journal article with DOI. +# +# Mirrors the v1 exemplar `journal_with_doi_kwon.toml` (same article, +# same author, same DOI) but expects CMOS 18 NOTE form rather than +# bibliography form. The cited passage is on a fictional specific +# page within the article's full range — the bibliography form has +# `1818–59`, the note form cites a single page like `1830`. +# +# Exercises: +# - Author in NORMAL order ("Hyeyoung Kwon") +# - Article title in straight quotes with closing comma INSIDE the +# quotes ("Inclusion Work: ...,") +# - Italicized journal name +# - Volume / issue / year format same as bibliography: +# *Journal* VOL, no. ISSUE (YEAR) +# - Specific page after the (YEAR): construct +# - DOI as full https URL +# - Terminal period +source = "Real published article; specific cited page is fabricated for note-form testing" +type = "note_journal" +tags = ["note", "journal", "single-author", "first-occurrence", "doi"] +canary = true +messy_input = "Kwon, Hyeyoung. \"inclusion work: children of immigrants claiming membership in everyday life.\" American Journal of Sociology, Vol. 127, Issue 6, 2022, pp. 1818-1859. DOI: 10.1086/720277. (cited p. 1830)" +expected_note = "Hyeyoung Kwon, \"Inclusion Work: Children of Immigrants Claiming Membership in Everyday Life,\" *American Journal of Sociology* 127, no. 6 (2022): 1830, https://doi.org/10.1086/720277." + +[canonical] +author = "Hyeyoung Kwon" +article_title = "Inclusion Work: Children of Immigrants Claiming Membership in Everyday Life" +journal = "American Journal of Sociology" +volume = 127 +issue = 6 +year = 2022 +page = 1830 +doi = "https://doi.org/10.1086/720277" diff --git a/src/cmos/note_formatter.py b/src/cmos/note_formatter.py new file mode 100644 index 0000000..0e378c1 --- /dev/null +++ b/src/cmos/note_formatter.py @@ -0,0 +1,250 @@ +"""Note formatter — the v2 inner-loop artifact for CMOS 18 NB note form. + +Path B sibling of ``cmos.formatter``: a separate prompt, separate exemplar +corpus, separate inner-loop iteration target. The two artifacts share NO +mutable state and NO prompt content. v1 ``format_bibliography_entry`` +remains untouched as v2 evolves. + +Some boilerplate (OpenAI client setup, reasoning-model detection, retry +loop) is intentionally duplicated rather than imported from +``cmos.formatter``. The duplication is small (~30 lines) and the +independence is more valuable than DRY here — if the two formatters ever +need different models, retry counts, or callers, the change is local. + +Caller injection: ``format_note_entry`` accepts a ``caller`` shim so tests +and the harness can substitute a fake for the OpenAI call without touching +the network. The default caller reads ``OPENAI_API_KEY`` (via +python-dotenv) and calls the configured model with ``temperature=0`` for +non-reasoning models. + +Scope of v2 iteration 1 (this commit): FIRST-OCCURRENCE FULL NOTE form +only. Shortened subsequent-citation form is deferred — that requires +state across notes which the formatter does not currently track. The +intended pipeline is: format every note as full form, then have a +separate deterministic Python pass identify repeated sources and +generate shortened forms from the full forms. That second pass is not +yet implemented. +""" + +from __future__ import annotations + +import os +from typing import Callable + +from dotenv import load_dotenv + +from cmos.runtime_validator import validate + +# Default model. Override with OPENAI_MODEL=... in your .env. +MODEL = os.environ.get("OPENAI_MODEL", "gpt-5") + +# How many extra attempts after the first if the runtime validator rejects +# the candidate. Mirrors v1 formatter behavior — see cmos.formatter and +# the project memory on GPT-5 nondeterminism. +DEFAULT_MAX_RETRIES = 2 + +# SYSTEM_PROMPT_NOTES: the v2 note formatter's program. Iterated by the +# autoresearch loop independently of v1's SYSTEM_PROMPT. Keep it explicit, +# versioned via git history, and traceable to CMOS 18 sources. +SYSTEM_PROMPT_NOTES = """\ +You are a careful Chicago Manual of Style (CMOS), 18th edition, +notes-and-bibliography NOTE formatter. You produce CMOS 18 NOTE form +(not bibliography form). + +Your task: given a single messy citation entry in English (typically +the body of a markdown footnote definition), rewrite it in CMOS 18 +FIRST-OCCURRENCE FULL NOTE form. Output the formatted note text and +nothing else — no commentary, no leading number ("1.", "2."), no code +fences, no surrounding quotes. + +Rules you must follow (CMOS 18th edition specifically, NOTE form): + +1. AUTHOR NAMES are in NORMAL order ("First Last"), NOT inverted. + - One author: "Charles Yu" + - Two authors: "Charles Yu and Hyeyoung Kwon" + - Three authors: "Charles Yu, Hyeyoung Kwon, and Kathleen Doyle" + - Four or more authors: first author followed by ", et al." (CMOS + 14.76: notes use first author + et al. for 4+ authors, even + though bibliography lists more before truncating). + Corporate authors (e.g., "Google", "Modern Language Association") + appear as-is, not reordered. + Non-Western names already in family-first order (e.g., "Liu Xinwu", + "Murakami Haruki") stay as-is — do not Western-order them. + +2. ELEMENT SEPARATION uses COMMAS, not periods. A note is one + sentence-like construction terminated by a single period at the + very end. Periods are NOT used between author / title / journal + / publisher in note form. This is the most visible difference + from bibliography form. + +3. ITALIC AND QUOTE CONVENTIONS are the same as bibliography form: + book titles and journal titles are italicized using Markdown + asterisks (*Title*); article titles and chapter titles are + wrapped in straight double quotes. In NOTE form the closing + comma goes INSIDE the closing quote of the article/chapter + title: "Article Title," (not "Article Title",). + +4. BOOK NOTE FORM: + Author, *Book Title* (Publisher, Year), specific-page. + The publisher and year are in PARENTHESES, separated by a comma. + Place of publication is NOT included (CMOS 14.30, 18th ed.). + +5. JOURNAL ARTICLE NOTE FORM: + Author, "Article Title," *Journal Title* VOL, no. ISSUE (YEAR): + specific-page, https://doi.org/10.xxxx/yyyy. + Note that the closing comma after the article title is INSIDE + the quotes, and the colon between (YEAR) and the page is NOT + preceded by a space. + +6. CHAPTER IN EDITED BOOK NOTE FORM: + Author, "Chapter Title," in *Book Title*, ed. Editor Names + (Publisher, Year), specific-page. + Use "ed." (abbreviated), NOT "Edited by". The lowercase "in" + before the italicized book title is part of CMOS note form + and should appear as shown. + +7. TRANSLATED WORKS use "trans." (abbreviated), NOT "Translated by". + +8. SPECIFIC PAGE: notes cite the specific page (or page range) of + the passage being referenced, NOT the full page range of the + article. If the source provides only a full article range and + no specific page, use the first page of the range. Do NOT + fabricate a more specific page number than the source provides. + +9. PREFER DOIS over generic URLs. When a DOI is present, format it + as a full URL: https://doi.org/10.xxxx/yyyy (no "doi:" prefix, + no bare DOI). + +10. NO IBID. CMOS 18 deprecates "Ibid." entirely. Do not produce it + under any circumstances, even if the input contains it. If the + input is an "Ibid." reference, this iteration of the formatter + cannot resolve it (shortened-form generation is out of scope). + Return the cleanest possible full-form note from whatever + information the messy input contains. + +11. PRESERVE TERMINAL PUNCTUATION inside titles. If a title ends in + "?" or "!", keep that punctuation inside the closing quote and + add a separating comma OUTSIDE the closing quote in note form. + Example: "Are Flax Seeds All That?," *New York Times*, ... + Do NOT silently replace title-internal "?" or "!" with a comma + or period. + +12. HEADLINE-STYLE CAPITALIZATION for titles, with the same CMOS + 8.159 carve-outs as v1 bibliography form: lowercase prepositions + regardless of length (about, above, across, after, against, + among, around, as, at, before, beyond, by, for, from, in, into, + of, on, over, through, to, under, until, up, with, within, etc.) + UNLESS the preposition is the first or last word of the title + or subtitle. SUBORDINATING conjunctions (If, That, Because, + Although, Whether, Unless) are CAPITALIZED. Apply this rule + INDEPENDENTLY of the source's casing. + +13. PRESERVE DELIBERATE LOWERCASING of proper nouns (the journal + "portal: Libraries and the Academy", authors like "bell hooks", + "danah boyd", "e e cummings", "k.d. lang", brand names like + "iPhone", "eBay"). When in doubt about whether a lowercased form + is intentional, err on the side of preserving the source's form. + +14. PERIODICAL NAMES: drop a leading "The" from any periodical name + (newspaper, magazine, journal). Write "New York Times", not + "The New York Times"; "Library Quarterly", not "The Library + Quarterly". This rule applies ONLY to periodicals — do NOT drop + a leading "The" from a BOOK title or REPORT title. + +15. INCLUSIVE-NUMBER ELISION for any page range you do output (CMOS + 9.61): use the same elision rules as v1 — 1-99 keep both numbers + (3-10, 71-72); 110-199 use two-digit elision (137-49, 1818-59); + across hundreds boundary use full numbers (799-810, 1496-1504). + Always use an en-dash (–), never an ASCII hyphen (-), in number + ranges. (Most notes cite a single page, so this rule fires + rarely, but it applies when needed.) + +16. THE NOTE NUMBER PREFIX ("1. ", "2. ") is NOT part of the + formatted note text. The numbering is supplied externally by + the document or by markdown footnote rendering. DO NOT include + a leading number in your output. + +17. DO NOT invent, fetch, or guess missing metadata. If a field is + missing in the source, leave it missing. Do not fabricate + authors, publishers, years, page numbers, URLs, or DOIs. + +Output ONLY the single reformatted note. No preamble, no explanation, +no leading number, no code fences. +""" + + +Caller = Callable[[str, str], str] + + +def build_user_message(messy_entry: str) -> str: + return ( + "Reformat the following citation entry to CMOS 18 notes-and-bibliography " + "FIRST-OCCURRENCE FULL NOTE form (note form, not bibliography form). " + "Output only the reformatted note text — no leading number.\n\n" + f"{messy_entry}" + ) + + +def _is_reasoning_model(model: str) -> bool: + """Reasoning-model families (GPT-5, o-series) don't accept temperature overrides.""" + prefixes = ("gpt-5", "o1", "o3", "o4") + return any(model.startswith(p) for p in prefixes) + + +def _openai_caller(system: str, user: str) -> str: + """Default caller — hits the real OpenAI API. + + Not exercised in unit tests. Integration tests or real loop runs + use this path and require ``OPENAI_API_KEY`` in the environment or + a .env file at the project root. + """ + load_dotenv() + from openai import OpenAI # imported lazily so unit tests do not need the network + + client = OpenAI() + kwargs: dict = { + "model": MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + } + if not _is_reasoning_model(MODEL): + kwargs["temperature"] = 0 + response = client.chat.completions.create(**kwargs) + return response.choices[0].message.content or "" + + +def format_note_entry( + messy_entry: str, + caller: Caller | None = None, + max_retries: int = DEFAULT_MAX_RETRIES, +) -> str: + """Reformat a single messy citation to CMOS 18 first-occurrence note form. + + Pass a ``caller`` shim to avoid the real API (used by tests and the + harness when running with a fake formatter for loop smoke tests). + + Reasoning models (gpt-5, o-series) are nondeterministic. The candidate + is checked against ``cmos.runtime_validator`` after each call; if it + fails any structural sanity check (missing terminal period, dropped + italics, stray Ibid., unbalanced quotes/asterisks), the formatter + retries up to ``max_retries`` more times. The runtime validator is + intentionally weaker than a full CMOS rule check because at runtime + there is no Exemplar — only structural rules independent of source + type apply. The validator is reused unchanged from v1 because all + its checks (terminal period, italic balance, no Ibid, balanced + quotes) apply to note form as well. + + If every attempt fails validation, the LAST attempt is returned (we + don't raise — the caller still gets something usable, and the + failure will surface via the v2 scoring linter or human review). + """ + call = caller or _openai_caller + user_message = build_user_message(messy_entry) + last: str = "" + for _ in range(max_retries + 1): + last = call(SYSTEM_PROMPT_NOTES, user_message).strip() + if validate(last).passed: + return last + return last diff --git a/src/cmos/parser.py b/src/cmos/parser.py index e77c9b1..79571bc 100644 --- a/src/cmos/parser.py +++ b/src/cmos/parser.py @@ -5,8 +5,18 @@ section as running until the next level-2 (``##``) heading or end of file, and return each non-blank line as one entry. Bullet markers (``- ``, ``* ``) at the start of a line are stripped. -Out of scope in v1: in-prose citation rewriting, multi-line entries, nested -sections, alternative heading names (e.g., "Works Cited", "References"). +v2 scope (notes): ``find_notes`` locates pandoc-style markdown footnote +definitions ``[^marker]: text`` anywhere in the document. Single-line +definitions only — multi-line continuation (indented continuation lines) +is deferred. The reference markers in prose (the ``[^marker]`` references +themselves, without the trailing colon) are NOT collected; only the +definitions need reformatting. + +Out of scope in v1: in-prose citation rewriting, multi-line entries, +nested sections, alternative heading names (e.g., "Works Cited", +"References"). Out of scope in v2 (so far): multi-line note definitions, +shortened-form generation, reassembly of formatted notes back into the +document. """ from __future__ import annotations @@ -26,9 +36,31 @@ class ParseResult: after: str +@dataclass +class NoteDefinition: + """A single pandoc-style markdown footnote definition. + + ``marker`` is the identifier between ``[^`` and ``]:`` (e.g., ``"1"`` + or ``"smith2020"``). ``text`` is the note body with surrounding + whitespace stripped. ``line_number`` is the 0-indexed line in the + source document where the definition appeared — currently unused but + captured for future reassembly support. + """ + + marker: str + text: str + line_number: int + + +@dataclass +class NotesParseResult: + definitions: list[NoteDefinition] + + _HEADING_RE = re.compile(r"^##\s+bibliography\s*$", re.IGNORECASE) _LEVEL_TWO_RE = re.compile(r"^##\s+\S") _BULLET_RE = re.compile(r"^[-*]\s+") +_NOTE_DEF_RE = re.compile(r"^\[\^([^\]]+)\]:\s*(.*?)\s*$") def split_bibliography(text: str) -> ParseResult: @@ -64,3 +96,30 @@ def split_bibliography(text: str) -> ParseResult: entries.append(stripped) return ParseResult(before=before, entries=entries, after=after) + + +def find_notes(text: str) -> NotesParseResult: + """Find pandoc-style markdown footnote definitions in ``text``. + + Targets the most common form: ``[^marker]: note text`` on a single + line. The marker can be numeric (``1``) or named (``smith2020``). + Multi-line definitions (where the note body continues on indented + subsequent lines) are out of scope for this iteration; only the + first line is captured. + + Returns a ``NotesParseResult`` with definitions in the order they + appear in the document. If the document contains no footnote + definitions, returns an empty list rather than raising — a draft + with no notes is a valid (if uninteresting) input. + """ + definitions: list[NoteDefinition] = [] + for line_number, line in enumerate(text.splitlines()): + match = _NOTE_DEF_RE.match(line) + if match is None: + continue + marker = match.group(1) + body = match.group(2) + definitions.append( + NoteDefinition(marker=marker, text=body, line_number=line_number) + ) + return NotesParseResult(definitions=definitions) diff --git a/tests/test_note_formatter.py b/tests/test_note_formatter.py new file mode 100644 index 0000000..a985e86 --- /dev/null +++ b/tests/test_note_formatter.py @@ -0,0 +1,117 @@ +"""Tests for src/cmos/note_formatter.py — the v2 inner-loop iterable artifact. + +Mirrors tests/test_formatter.py in shape and discipline. The note formatter +is iterated independently of the bibliography formatter (Path B); these +tests pin the stable seams (caller injection, retry loop, prompt smoke +checks) and leave LLM output shape testing to the v2 exemplar canary. + +No real API calls in this file. Tests use injected fake callers. +""" + +from cmos.note_formatter import ( + MODEL, + SYSTEM_PROMPT_NOTES, + build_user_message, + format_note_entry, +) + + +def test_default_model_is_gpt5(): + # Same default as v1. Override with OPENAI_MODEL=... in .env. + assert MODEL == "gpt-5" + + +def test_system_prompt_mentions_cmos_18_note_form(): + assert "CMOS" in SYSTEM_PROMPT_NOTES + assert "18" in SYSTEM_PROMPT_NOTES + assert "NOTE" in SYSTEM_PROMPT_NOTES + + +def test_system_prompt_mentions_no_inversion(): + # The single most distinctive difference from bibliography form: notes + # use normal-order author names ("First Last"), not inverted. + lower = SYSTEM_PROMPT_NOTES.lower() + assert "normal" in lower or "not inverted" in lower + + +def test_system_prompt_mentions_comma_separation(): + # The second most distinctive difference: commas between elements, + # not periods. + lower = SYSTEM_PROMPT_NOTES.lower() + assert "comma" in lower + + +def test_system_prompt_excludes_leading_number(): + # The note number prefix ("1. ", "2. ") is supplied externally; the + # formatter must not include it. The prompt must say so. + lower = SYSTEM_PROMPT_NOTES.lower() + assert "leading number" in lower or "do not include" in lower + + +def test_system_prompt_mentions_no_ibid(): + # CMOS 18 deprecates Ibid., same as v1. + assert "Ibid" in SYSTEM_PROMPT_NOTES or "ibid" in SYSTEM_PROMPT_NOTES.lower() + + +def test_build_user_message_contains_the_messy_input(): + msg = build_user_message("yu, charles. interior chinatown. 2020, 45") + assert "yu, charles. interior chinatown. 2020, 45" in msg + + +def test_note_formatter_uses_injected_caller(): + """Same caller-injection seam as v1. Tests can substitute a fake without + touching the network.""" + recorded: dict = {} + + def fake_caller(system: str, user: str) -> str: + recorded["system"] = system + recorded["user"] = user + return "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + output = format_note_entry( + "yu, charles. interior chinatown. New York: Pantheon Books, 2020, p 45.", + caller=fake_caller, + ) + assert output == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + assert recorded["system"] == SYSTEM_PROMPT_NOTES + assert "yu, charles" in recorded["user"] + + +def test_note_formatter_strips_whitespace_from_caller_output(): + def fake_caller(system: str, user: str) -> str: + return " Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45. \n" + + output = format_note_entry("anything", caller=fake_caller) + assert output == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + +def test_note_formatter_retries_on_validator_failure(): + """If the runtime validator rejects the first attempt (e.g., italics + dropped), the formatter retries. Same retry semantics as v1.""" + attempts = [] + + def flaky_caller(system: str, user: str) -> str: + attempts.append(len(attempts) + 1) + if len(attempts) == 1: + # First attempt: italics dropped — validator should fail. + return "Charles Yu, Interior Chinatown (Pantheon Books, 2020), 45." + return "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + output = format_note_entry("messy yu", caller=flaky_caller) + assert "*Interior Chinatown*" in output + assert len(attempts) == 2 + + +def test_note_formatter_returns_last_attempt_after_max_retries(): + """If every attempt fails validation, return the last attempt and don't + loop forever. Same as v1 — the failure surfaces via scoring or human + review, not via a runtime exception.""" + attempts = [] + + def always_bad(system: str, user: str) -> str: + attempts.append(1) + return "Charles Yu, Interior Chinatown (Pantheon Books, 2020), 45." + + output = format_note_entry("messy", caller=always_bad, max_retries=2) + assert len(attempts) == 3 # 1 initial + 2 retries + assert "*" not in output diff --git a/tests/test_parser.py b/tests/test_parser.py index 508464d..c6e1d5f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -4,11 +4,15 @@ v1 scope: locate a ``## Bibliography`` heading (case-insensitive) and return the prose before it, the list of entries inside it, and the prose after it. The section ends at the next ``##`` (level-2) heading or end of file. Blank lines and bullet markers (``- `` or ``* ``) are stripped from entries. + +v2 (notes) parser scope: find pandoc-style markdown footnote definitions +``[^marker]: text`` anywhere in the document. Single-line definitions only; +multi-line continuation is deferred to a later chunk. """ import pytest -from cmos.parser import NoBibliographyError, split_bibliography +from cmos.parser import NoBibliographyError, find_notes, split_bibliography def test_three_entries_under_bibliography_heading(): @@ -74,3 +78,84 @@ Only entry. def test_no_bibliography_raises(): with pytest.raises(NoBibliographyError): split_bibliography("## Introduction\n\nJust prose.\n") + + +# --- find_notes (v2 parser, pandoc-style markdown footnote definitions) ---- + + +def test_find_notes_single_definition(): + draft = """\ +Some prose with a citation.[^1] + +[^1]: Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45. +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].marker == "1" + assert result.definitions[0].text == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + +def test_find_notes_multiple_definitions_in_order(): + draft = """\ +Prose.[^1] More prose.[^2] And again.[^3] + +[^1]: First note. +[^2]: Second note. +[^3]: Third note. +""" + result = find_notes(draft) + assert len(result.definitions) == 3 + assert [d.marker for d in result.definitions] == ["1", "2", "3"] + assert [d.text for d in result.definitions] == [ + "First note.", + "Second note.", + "Third note.", + ] + + +def test_find_notes_named_marker(): + draft = """\ +Citation.[^smith2020] + +[^smith2020]: Smith, J. (2020). Some work. +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].marker == "smith2020" + assert result.definitions[0].text == "Smith, J. (2020). Some work." + + +def test_find_notes_returns_empty_when_no_definitions(): + draft = "Just prose with no footnote definitions.\n" + result = find_notes(draft) + assert result.definitions == [] + + +def test_find_notes_records_line_number(): + draft = """\ +Line zero. +Line one. + +[^1]: Note on line three. +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].line_number == 3 + + +def test_find_notes_strips_trailing_whitespace_from_text(): + draft = "[^1]: Note text with trailing spaces. \n" + result = find_notes(draft) + assert result.definitions[0].text == "Note text with trailing spaces." + + +def test_find_notes_definition_can_appear_before_reference(): + # Pandoc allows definitions anywhere in the doc, not just at the bottom. + draft = """\ +[^1]: A definition before the reference. + +Some prose that cites it.[^1] +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].text == "A definition before the reference." -- 2.40.1 From d061f78b7f8d6ba90e22c55a34fee5a63818e92e Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 18:27:55 -0400 Subject: [PATCH 04/13] =?UTF-8?q?v2=20chunk=202a=20iter=201:=20rules=206+1?= =?UTF-8?q?8=20=E2=80=94=20ed.=20invariant,=20expand=20publishers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two prompt edits to SYSTEM_PROMPT_NOTES driven by chunk 1 smoke test gaps on chapter_first_doyle.toml. Rule 6 (chapter form) expanded with an explicit invariance statement: "ed." is the canonical abbreviation regardless of editor count — do NOT pluralize to "eds." for multiple editors. CMOS NB treats it as an invariant abbreviation, not a number-agreeing word. New rule 18 (publisher expansion) mirrors v1 formatter.py rule 14: publisher names must be in full canonical form, with note-form- specific examples ("U of Chicago Press" → "University of Chicago Press"). Includes the same MIT Press / ALA Editions / MLA carve-out for publishers whose canonical self-presentation legitimately uses initials. Two new prompt-content unit tests added to tests/test_note_formatter.py following the v1 test_formatter.py discipline. TDD cycle: red-green- verified end-to-end. Real-API smoke test, 3 v2 exemplars × 2 runs each: 6/6 byte-perfect matches (was 4/6 in chunk 1; chapter_first_doyle went 0/2 → 2/2, book and journal still 2/2). v1 untouched, 96/96 v1 tests still passing. Total suite: 116/116. --- src/cmos/note_formatter.py | 24 +++++++++++++++++++++--- tests/test_note_formatter.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/cmos/note_formatter.py b/src/cmos/note_formatter.py index 0e378c1..a6b1cab 100644 --- a/src/cmos/note_formatter.py +++ b/src/cmos/note_formatter.py @@ -99,9 +99,13 @@ Rules you must follow (CMOS 18th edition specifically, NOTE form): 6. CHAPTER IN EDITED BOOK NOTE FORM: Author, "Chapter Title," in *Book Title*, ed. Editor Names (Publisher, Year), specific-page. - Use "ed." (abbreviated), NOT "Edited by". The lowercase "in" - before the italicized book title is part of CMOS note form - and should appear as shown. + Use "ed." (abbreviated), NOT "Edited by". The abbreviation "ed." + is INVARIANT — use "ed." even when there are multiple editors. + Do NOT pluralize to "eds." regardless of editor count. CMOS NB + note form treats "ed." as an unchanging abbreviation, not a + grammatical word that agrees in number with its referent. + The lowercase "in" before the italicized book title is part of + CMOS note form and should appear as shown. 7. TRANSLATED WORKS use "trans." (abbreviated), NOT "Translated by". @@ -168,6 +172,20 @@ Rules you must follow (CMOS 18th edition specifically, NOTE form): missing in the source, leave it missing. Do not fabricate authors, publishers, years, page numbers, URLs, or DOIs. +18. PUBLISHER NAMES must be in FULL canonical form. Do NOT abbreviate. + If the messy input contains an abbreviated form, EXPAND it to the + canonical full name. Examples: + - "U of Chicago Press" → "University of Chicago Press" + - "OUP" → "Oxford University Press" + - "CUP" → "Cambridge University Press" + - "HMC" → "Houghton Mifflin Company" + - "Random House" stays "Random House" (already canonical) + - "Pantheon Books" stays "Pantheon Books" (already canonical) + The exception: publishers whose canonical self-presentation + legitimately uses initials (e.g., "MIT Press", "ALA Editions", + "MLA") stay in that form. When in doubt, prefer the longer + form over the abbreviation. + Output ONLY the single reformatted note. No preamble, no explanation, no leading number, no code fences. """ diff --git a/tests/test_note_formatter.py b/tests/test_note_formatter.py index a985e86..e7b7488 100644 --- a/tests/test_note_formatter.py +++ b/tests/test_note_formatter.py @@ -53,6 +53,38 @@ def test_system_prompt_mentions_no_ibid(): assert "Ibid" in SYSTEM_PROMPT_NOTES or "ibid" in SYSTEM_PROMPT_NOTES.lower() +def test_system_prompt_mentions_publisher_full_form(): + """Surfaced by chunk 1 smoke test on chapter_first_doyle: the formatter + preserved 'U of Chicago Press' from the messy input instead of + expanding it to 'University of Chicago Press'. v1 formatter.py rule 14 + covers this; the v2 prompt must too. The rule is "publisher names must + be in full canonical form, not abbreviated." + """ + lower = SYSTEM_PROMPT_NOTES.lower() + assert "publisher" in lower + # Either "do not abbreviate" or "full canonical" or "full form" — any + # phrasing that conveys the prohibition. + assert ( + "do not abbreviate" in lower + or "not abbreviated" in lower + or "full canonical" in lower + or "full form" in lower + ) + + +def test_system_prompt_mentions_ed_invariant_for_multiple_editors(): + """Surfaced by chunk 1 smoke test on chapter_first_doyle: the formatter + pluralized 'ed.' to 'eds.' when there were two editors. CMOS NB form + uses 'ed.' invariantly regardless of editor count. The prompt must + explicitly state this so the model does not grammatically pluralize + by default. + """ + # Search for an explicit mention of "eds." being wrong, OR an explicit + # statement that "ed." is invariant / not pluralized. + lower = SYSTEM_PROMPT_NOTES.lower() + assert "eds." in lower or "invariant" in lower or "do not pluralize" in lower or "not pluralize" in lower + + def test_build_user_message_contains_the_messy_input(): msg = build_user_message("yu, charles. interior chinatown. 2020, 45") assert "yu, charles. interior chinatown. 2020, 45" in msg -- 2.40.1 From 87cb70b4fb995060801384bd0e129c79151cf7de Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 18:33:45 -0400 Subject: [PATCH 05/13] v2 chunk 2b: cli format-notes subcommand and reformat_notes() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the v2 note formatter into the CLI so it can be invoked on real markdown drafts. The format-notes subcommand mirrors v1's format subcommand: parser → formatter → reassemble, with concurrent API calls. src/cmos/cli.py: - Import find_notes and format_note_entry alongside the existing v1 imports. - Add reformat_notes(text, formatter, concurrency) that finds pandoc-style markdown footnote definitions via find_notes, formats each definition's text via the v2 note formatter (or an injected fake), and substitutes the formatted text back into the original line position. Non-definition lines preserved byte-for-byte. Returns text unchanged when no definitions found. - Register the format-notes argparse subparser with the same --concurrency flag as v1's format. - Dispatch args.command == "format-notes" to reformat_notes. - Module docstring updated to document both subcommands. tests/test_cli.py: - 7 new tests for reformat_notes covering: in-place substitution, order preservation under concurrency, prose preservation, reference markers staying verbatim, no-op on empty input, trailing newline preservation. - Extended test_python_dash_m_invocation_actually_runs_main to also assert "format-notes" appears in --help, catching accidental subcommand removal. Path B integrity: formatter.py, note_formatter.py, parser.py, linter.py, harness/score.py all unchanged. No LINTER_VERSION bump. 96/96 v1 tests still passing. Total suite: 123/123. Real-API end-to-end smoke test on a temp draft with 2 footnote definitions: both reformatted byte-for-byte, prose and headings preserved, ## Conclusion section after the notes preserved. --- src/cmos/cli.py | 88 ++++++++++++++++++++++++++++++-- tests/test_cli.py | 127 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 6 deletions(-) diff --git a/src/cmos/cli.py b/src/cmos/cli.py index beebc77..cf7fed5 100644 --- a/src/cmos/cli.py +++ b/src/cmos/cli.py @@ -1,8 +1,16 @@ -"""Command-line entry point: ``cmos format path/to/draft.md``. +"""Command-line entry points for the cmos reformatter. -Orchestrates parser → formatter → reassemble. The CLI preserves everything -outside the bibliography section byte-for-byte; only bibliography entries -are rewritten. +Two subcommands: + +- ``cmos format `` (v1): rewrite the ``## Bibliography`` section of a + markdown draft, preserving everything outside the section byte-for-byte. + +- ``cmos format-notes `` (v2): rewrite pandoc-style markdown footnote + definitions ``[^marker]: text`` to CMOS 18 first-occurrence note form. + Substitutes reformatted definitions in place by line number; everything + else in the draft is preserved byte-for-byte. + +Both subcommands orchestrate parser → formatter → reassemble. """ from __future__ import annotations @@ -14,7 +22,8 @@ from pathlib import Path from typing import Callable from cmos.formatter import format_bibliography_entry -from cmos.parser import split_bibliography +from cmos.note_formatter import format_note_entry +from cmos.parser import find_notes, split_bibliography FormatterFn = Callable[[str], str] @@ -61,6 +70,56 @@ def reformat_draft( return "\n".join(pieces) + "\n" +def reformat_notes( + text: str, + formatter: FormatterFn | None = None, + concurrency: int = DEFAULT_CONCURRENCY, +) -> str: + """Rewrite pandoc-style markdown footnote definitions in ``text``. + + Finds every ``[^marker]: definition text`` line via + ``cmos.parser.find_notes``, formats each definition's text via + ``formatter`` (the v2 note formatter by default), and substitutes + the reformatted text back into the same line position. All + non-definition lines are preserved byte-for-byte. + + Definitions are reformatted concurrently using a thread pool of + size ``concurrency``, mirroring v1's ``reformat_draft``. The output + order is deterministic regardless of which API call finishes first. + + If the document contains no footnote definitions, returns ``text`` + unchanged — a draft with no notes is a valid (if uninteresting) + input rather than an error. + + Scope of v2 chunk 2b (this version): single-line definitions only. + Multi-line definitions (where the body continues on indented + subsequent lines) are out of scope — only the first line is + reformatted, leaving any continuation lines untouched. Real drafts + that use multi-line definitions will need a future parser + extension before this CLI can handle them safely. + """ + fmt = formatter or format_note_entry + parsed = find_notes(text) + + if not parsed.definitions: + return text + + texts = [d.text for d in parsed.definitions] + if concurrency <= 1 or len(texts) <= 1: + rewritten = [fmt(t) for t in texts] + else: + with ThreadPoolExecutor(max_workers=concurrency) as pool: + rewritten = list(pool.map(fmt, texts)) + + lines = text.splitlines() + for definition, formatted in zip(parsed.definitions, rewritten): + lines[definition.line_number] = f"[^{definition.marker}]: {formatted}" + + # Preserve trailing newline if the original had one — splitlines drops it. + trailing = "\n" if text.endswith("\n") else "" + return "\n".join(lines) + trailing + + def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(prog="cmos", description=__doc__) sub = ap.add_subparsers(dest="command", required=True) @@ -77,12 +136,31 @@ def main(argv: list[str] | None = None) -> int: ), ) + fmt_notes = sub.add_parser( + "format-notes", + help="Reformat a draft's markdown footnote definitions to CMOS 18 note form.", + ) + fmt_notes.add_argument("path", type=Path, help="Path to a markdown draft.") + fmt_notes.add_argument( + "--concurrency", + type=int, + default=DEFAULT_CONCURRENCY, + help=( + "Number of parallel formatter calls (default: " + f"{DEFAULT_CONCURRENCY}). Set to 1 for serial execution." + ), + ) + args = ap.parse_args(argv) if args.command == "format": text = args.path.read_text(encoding="utf-8") sys.stdout.write(reformat_draft(text, concurrency=args.concurrency)) return 0 + if args.command == "format-notes": + text = args.path.read_text(encoding="utf-8") + sys.stdout.write(reformat_notes(text, concurrency=args.concurrency)) + return 0 return 2 diff --git a/tests/test_cli.py b/tests/test_cli.py index 55baf5d..4d8f120 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,10 @@ The CLI orchestrates parser → formatter → reassemble. Tests inject a fake formatter so no API calls happen; the goal is to pin the glue logic, not the LLM behavior. + +v1: ``reformat_draft`` rewrites the ``## Bibliography`` section. +v2: ``reformat_notes`` rewrites pandoc-style markdown footnote definitions + in place by line number, preserving everything else byte-for-byte. """ import subprocess @@ -11,7 +15,7 @@ from pathlib import Path import pytest -from cmos.cli import reformat_draft +from cmos.cli import reformat_draft, reformat_notes from cmos.parser import NoBibliographyError @@ -80,6 +84,10 @@ def test_python_dash_m_invocation_actually_runs_main(): the worst kind of bug, since callers assume the pipeline ran. This test forces the guard to exist by invoking the CLI as a subprocess with --help and asserting argparse actually fired. + + Also acts as a smoke test for both subcommands appearing in the + top-level help: catches the case where a future refactor removes + a subcommand registration without anyone noticing. """ result = subprocess.run( [sys.executable, "-m", "cmos.cli", "--help"], @@ -98,9 +106,126 @@ def test_python_dash_m_invocation_actually_runs_main(): '`if __name__ == "__main__": sys.exit(main())` guard at the bottom.' ) assert "format" in result.stdout + assert "format-notes" in result.stdout assert "usage" in result.stdout.lower() +# --- reformat_notes (v2 CLI plumbing) ---------------------------------------- + + +def _fake_note_formatter(messy: str) -> str: + # Deterministic fake mirroring the v1 fake_formatter pattern. + return f"NOTE_FORMATTED({messy.strip()})" + + +def test_reformat_notes_substitutes_definition_in_place(): + draft = """\ +Some prose with a citation.[^1] + +[^1]: yu, charles. interior chinatown. 2020. p 45. +""" + output = reformat_notes(draft, formatter=_fake_note_formatter) + assert "Some prose with a citation.[^1]" in output + assert "[^1]: NOTE_FORMATTED(yu, charles. interior chinatown. 2020. p 45.)" in output + # The original messy definition line must be gone. + assert "[^1]: yu, charles" not in output + + +def test_reformat_notes_rewrites_multiple_definitions_in_order(): + draft = """\ +Prose.[^1] More.[^2] Again.[^3] + +[^1]: first messy. +[^2]: second messy. +[^3]: third messy. +""" + output = reformat_notes(draft, formatter=_fake_note_formatter) + assert "[^1]: NOTE_FORMATTED(first messy.)" in output + assert "[^2]: NOTE_FORMATTED(second messy.)" in output + assert "[^3]: NOTE_FORMATTED(third messy.)" in output + + +def test_reformat_notes_preserves_non_definition_lines(): + draft = """\ +# Title + +Some prose with a citation.[^1] + +More prose, no citation here. + +[^1]: messy definition. + +Conclusion paragraph. +""" + output = reformat_notes(draft, formatter=_fake_note_formatter) + assert "# Title" in output + assert "Some prose with a citation.[^1]" in output + assert "More prose, no citation here." in output + assert "Conclusion paragraph." in output + assert "[^1]: NOTE_FORMATTED(messy definition.)" in output + + +def test_reformat_notes_preserves_reference_markers_in_prose(): + """The [^1] reference inside the prose must NOT be touched — only + the [^1]: definition line should be reformatted.""" + draft = """\ +Some prose with a citation.[^1] And another.[^2] + +[^1]: first. +[^2]: second. +""" + output = reformat_notes(draft, formatter=_fake_note_formatter) + # References in prose stay verbatim + assert "Some prose with a citation.[^1] And another.[^2]" in output + # Definitions are reformatted + assert "[^1]: NOTE_FORMATTED(first.)" in output + assert "[^2]: NOTE_FORMATTED(second.)" in output + + +def test_reformat_notes_returns_unchanged_when_no_definitions(): + draft = "Just prose with no footnote definitions.\n" + output = reformat_notes(draft, formatter=_fake_note_formatter) + assert output == draft + + +def test_reformat_notes_preserves_trailing_newline(): + with_trailing = "[^1]: messy.\n" + without_trailing = "[^1]: messy." + assert reformat_notes(with_trailing, formatter=_fake_note_formatter).endswith("\n") + assert not reformat_notes(without_trailing, formatter=_fake_note_formatter).endswith("\n") + + +def test_reformat_notes_preserves_order_under_concurrency(): + """With concurrent execution the formatter is called on all definitions + in parallel; the output must reassemble them in input order regardless + of which call finishes first. Mirrors the v1 order-preservation test.""" + import time + + def slow_fake(messy: str) -> str: + # Earlier definitions sleep longer so they finish last under naive + # completion-order tracking. + n = int(messy.split()[-1]) + time.sleep(0.05 * (5 - n)) + return f"NOTE_FORMATTED({messy})" + + draft = """\ +Prose. + +[^1]: definition 0 +[^2]: definition 1 +[^3]: definition 2 +[^4]: definition 3 +[^5]: definition 4 +""" + output = reformat_notes(draft, formatter=slow_fake, concurrency=4) + # Check order: each marker should be paired with its correct definition. + assert "[^1]: NOTE_FORMATTED(definition 0)" in output + assert "[^2]: NOTE_FORMATTED(definition 1)" in output + assert "[^3]: NOTE_FORMATTED(definition 2)" in output + assert "[^4]: NOTE_FORMATTED(definition 3)" in output + assert "[^5]: NOTE_FORMATTED(definition 4)" in output + + def test_reformat_draft_preserves_order_under_concurrency(): # With concurrent execution the formatter is called on all entries in # parallel; the CLI must reassemble them in input order regardless of -- 2.40.1 From 45f51c6341496ad17f5bc819d3b0adb4c57728f1 Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 19:16:09 -0400 Subject: [PATCH 06/13] v2 chunk 2c: numbered-note format support + real-draft test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the v2 parser and CLI to handle [N] text footnote definitions in addition to pandoc-style [^marker]: text. The numbered format is what docx-to-text conversion of footnoted Word documents produces. The user's "Anti-Communist Formations of LIS" draft uses this format for all 107 of its notes; without this support, v2 was structurally incapable of running on real-world docx-derived inputs. src/cmos/parser.py: - Add NoteDefinition.original_prefix field so reassembly can round-trip the source's marker syntax (pandoc input → pandoc output, numbered input → numbered output) without consumers needing to know which format was matched. - Update find_notes() to populate original_prefix as "[^N]: ". - Add find_numbered_notes() targeting "[N] text" definitions. Marker must be all digits (rejects [Smith 2020], [foo], etc.); caret prefix is rejected (rejects pandoc-style cleanly). src/cmos/cli.py: - reformat_notes now auto-detects format: tries find_notes first, falls back to find_numbered_notes if no pandoc definitions found. Uses definition.original_prefix for reassembly so both formats round-trip correctly. tests/test_parser.py: - 11 new tests for find_numbered_notes covering: single/multiple definitions, multi-digit markers, line number recording, trailing whitespace stripping, ignoring pandoc/non-numeric markers, original prefix recording, and the actual Anti-Communist draft format. - 1 new test for find_notes original_prefix population. tests/test_cli.py: - 2 new tests for reformat_notes auto-detect: numbered input round- trips as numbered output, pandoc input still round-trips as pandoc. Total suite: 136/136 (was 123, +13 net new). v1 untouched, 96/96 v1 tests still passing. Real-draft validation: ran cmos format-notes against the 107-note Anti-Communist Formations of LIS draft (108 calls in parallel via the existing concurrency=8 thread pool, completed cleanly). Output saved to /tmp (not committed). All 107 notes preserved through the pipeline; ~30-40 first-occurrence full notes produced clean CMOS 18 note form; ~30 shortened-form refs correctly left unchanged; 2 real bugs surfaced for the next iteration (empty input → conver- sational reply, Ibid → empty string), plus several lower-priority issues (substantive note truncation, retry waste on shortened forms, lossy month dropping). Not addressed in this chunk per the "collect signal, don't fix" plan. --- src/cmos/cli.py | 49 +++++++++------ src/cmos/parser.py | 101 ++++++++++++++++++++++++++----- tests/test_cli.py | 31 ++++++++++ tests/test_parser.py | 141 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 286 insertions(+), 36 deletions(-) diff --git a/src/cmos/cli.py b/src/cmos/cli.py index cf7fed5..e03dfcb 100644 --- a/src/cmos/cli.py +++ b/src/cmos/cli.py @@ -23,7 +23,7 @@ from typing import Callable from cmos.formatter import format_bibliography_entry from cmos.note_formatter import format_note_entry -from cmos.parser import find_notes, split_bibliography +from cmos.parser import find_notes, find_numbered_notes, split_bibliography FormatterFn = Callable[[str], str] @@ -75,31 +75,46 @@ def reformat_notes( formatter: FormatterFn | None = None, concurrency: int = DEFAULT_CONCURRENCY, ) -> str: - """Rewrite pandoc-style markdown footnote definitions in ``text``. + """Rewrite footnote definitions in ``text`` to CMOS 18 note form. - Finds every ``[^marker]: definition text`` line via - ``cmos.parser.find_notes``, formats each definition's text via - ``formatter`` (the v2 note formatter by default), and substitutes - the reformatted text back into the same line position. All - non-definition lines are preserved byte-for-byte. + Auto-detects the source format by trying ``cmos.parser.find_notes`` + (pandoc-style ``[^marker]: text``) first and falling back to + ``cmos.parser.find_numbered_notes`` (``[N] text``) if no pandoc + definitions were found. Both formats are common in real drafts: + pandoc is used by writers who author directly in markdown, while + the numbered form is what docx-to-text conversion typically + produces from footnoted Word documents. + + Each definition's body is formatted via ``formatter`` (the v2 + note formatter by default) and substituted back into the same + line position using the definition's ``original_prefix`` — so + pandoc input round-trips as pandoc output and numbered input + round-trips as numbered output. All non-definition lines are + preserved byte-for-byte. Definitions are reformatted concurrently using a thread pool of size ``concurrency``, mirroring v1's ``reformat_draft``. The output order is deterministic regardless of which API call finishes first. - If the document contains no footnote definitions, returns ``text`` - unchanged — a draft with no notes is a valid (if uninteresting) - input rather than an error. + If the document contains no footnote definitions in either format, + returns ``text`` unchanged — a draft with no notes is a valid (if + uninteresting) input rather than an error. - Scope of v2 chunk 2b (this version): single-line definitions only. - Multi-line definitions (where the body continues on indented - subsequent lines) are out of scope — only the first line is - reformatted, leaving any continuation lines untouched. Real drafts - that use multi-line definitions will need a future parser - extension before this CLI can handle them safely. + Scope: single-line definitions only. Multi-line definitions (where + the body continues on indented subsequent lines for pandoc, or on + unprefixed continuation lines for numbered) are out of scope — + only the first line is reformatted, leaving any continuation lines + untouched. """ fmt = formatter or format_note_entry + + # Try pandoc format first; fall back to numbered format if nothing + # was found. The two parsers are mutually exclusive on well-formed + # input (the regex anchors prevent overlap), so this fallback is + # unambiguous in practice. parsed = find_notes(text) + if not parsed.definitions: + parsed = find_numbered_notes(text) if not parsed.definitions: return text @@ -113,7 +128,7 @@ def reformat_notes( lines = text.splitlines() for definition, formatted in zip(parsed.definitions, rewritten): - lines[definition.line_number] = f"[^{definition.marker}]: {formatted}" + lines[definition.line_number] = f"{definition.original_prefix}{formatted}" # Preserve trailing newline if the original had one — splitlines drops it. trailing = "\n" if text.endswith("\n") else "" diff --git a/src/cmos/parser.py b/src/cmos/parser.py index 79571bc..45d45bc 100644 --- a/src/cmos/parser.py +++ b/src/cmos/parser.py @@ -5,18 +5,32 @@ section as running until the next level-2 (``##``) heading or end of file, and return each non-blank line as one entry. Bullet markers (``- ``, ``* ``) at the start of a line are stripped. -v2 scope (notes): ``find_notes`` locates pandoc-style markdown footnote -definitions ``[^marker]: text`` anywhere in the document. Single-line -definitions only — multi-line continuation (indented continuation lines) -is deferred. The reference markers in prose (the ``[^marker]`` references -themselves, without the trailing colon) are NOT collected; only the -definitions need reformatting. +v2 scope (notes): two complementary functions for finding footnote +definitions in two distinct source formats. Both return the same +``NotesParseResult`` shape; the consumer (typically the CLI) can call +one and fall back to the other to auto-detect format. + +- ``find_notes`` finds pandoc-style markdown footnote definitions + ``[^marker]: text``. The marker can be numeric or named. +- ``find_numbered_notes`` finds plain ``[N] text`` definitions where + the marker is digit-only and there is no caret or colon — the format + produced by docx-to-text conversion of footnoted Word docs. + +Each ``NoteDefinition`` carries the marker, the body text (with +surrounding whitespace stripped), the line number where it appeared +(0-indexed, for future reassembly), and the literal ``original_prefix`` +string (e.g., ``"[^1]: "`` or ``"[1] "``) so that reassembly can +round-trip the source's marker syntax without the consumer needing to +know which format was matched. + +Single-line definitions only in both formats — multi-line continuation +(indented continuation lines) is deferred. The reference markers in +prose are NOT collected; only the definitions need reformatting. Out of scope in v1: in-prose citation rewriting, multi-line entries, nested sections, alternative heading names (e.g., "Works Cited", "References"). Out of scope in v2 (so far): multi-line note definitions, -shortened-form generation, reassembly of formatted notes back into the -document. +shortened-form generation. """ from __future__ import annotations @@ -38,18 +52,24 @@ class ParseResult: @dataclass class NoteDefinition: - """A single pandoc-style markdown footnote definition. + """A single footnote definition extracted from a draft. - ``marker`` is the identifier between ``[^`` and ``]:`` (e.g., ``"1"`` - or ``"smith2020"``). ``text`` is the note body with surrounding - whitespace stripped. ``line_number`` is the 0-indexed line in the - source document where the definition appeared — currently unused but - captured for future reassembly support. + ``marker`` is the identifier between the brackets — e.g., ``"1"`` or + ``"smith2020"`` for pandoc-style ``[^1]:`` / ``[^smith2020]:``, or + ``"107"`` for the numbered ``[107]`` style. ``text`` is the note + body with surrounding whitespace stripped. ``line_number`` is the + 0-indexed line in the source document where the definition appeared + (used for in-place reassembly). ``original_prefix`` is the literal + text that preceded the body on the source line — typically + ``"[^1]: "`` for pandoc or ``"[1] "`` for numbered. Reassembly + concatenates ``original_prefix`` with the reformatted body, so + consumers do not need to know which format was matched. """ marker: str text: str line_number: int + original_prefix: str = "" @dataclass @@ -61,6 +81,7 @@ _HEADING_RE = re.compile(r"^##\s+bibliography\s*$", re.IGNORECASE) _LEVEL_TWO_RE = re.compile(r"^##\s+\S") _BULLET_RE = re.compile(r"^[-*]\s+") _NOTE_DEF_RE = re.compile(r"^\[\^([^\]]+)\]:\s*(.*?)\s*$") +_NUMBERED_NOTE_RE = re.compile(r"^\[(\d+)\]\s+(.*?)\s*$") def split_bibliography(text: str) -> ParseResult: @@ -111,6 +132,10 @@ def find_notes(text: str) -> NotesParseResult: appear in the document. If the document contains no footnote definitions, returns an empty list rather than raising — a draft with no notes is a valid (if uninteresting) input. + + Each definition's ``original_prefix`` is set to ``"[^MARKER]: "`` so + that downstream reassembly can write back the formatted body using + the same marker syntax. """ definitions: list[NoteDefinition] = [] for line_number, line in enumerate(text.splitlines()): @@ -120,6 +145,52 @@ def find_notes(text: str) -> NotesParseResult: marker = match.group(1) body = match.group(2) definitions.append( - NoteDefinition(marker=marker, text=body, line_number=line_number) + NoteDefinition( + marker=marker, + text=body, + line_number=line_number, + original_prefix=f"[^{marker}]: ", + ) + ) + return NotesParseResult(definitions=definitions) + + +def find_numbered_notes(text: str) -> NotesParseResult: + """Find ``[N] text`` style footnote definitions in ``text``. + + Targets the format produced by docx-to-text conversion of footnoted + Word documents: a square-bracket numeric marker followed by a + space and the note body, one definition per line. Distinct from + pandoc footnote definitions, which require ``[^marker]:`` syntax + (caret and colon). + + The marker must be ALL DIGITS — markers like ``[Smith 2020]`` or + ``[foo]`` are deliberately not matched, since they could plausibly + be many other things (citation keys, link labels, in-line + references). Pandoc-style ``[^1]:`` is also not matched because of + the leading ``^``. + + Multi-line definitions (where the body wraps onto a continuation + line) are out of scope; only the first line is captured. + + Returns a ``NotesParseResult`` with definitions in source order. + Each definition's ``original_prefix`` is set to ``"[N] "`` so that + downstream reassembly can write back the formatted body using the + same marker syntax. + """ + definitions: list[NoteDefinition] = [] + for line_number, line in enumerate(text.splitlines()): + match = _NUMBERED_NOTE_RE.match(line) + if match is None: + continue + marker = match.group(1) + body = match.group(2) + definitions.append( + NoteDefinition( + marker=marker, + text=body, + line_number=line_number, + original_prefix=f"[{marker}] ", + ) ) return NotesParseResult(definitions=definitions) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4d8f120..09712d9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -195,6 +195,37 @@ def test_reformat_notes_preserves_trailing_newline(): assert not reformat_notes(without_trailing, formatter=_fake_note_formatter).endswith("\n") +def test_reformat_notes_auto_detects_numbered_format(): + """Real drafts converted from docx use [N] text format, not pandoc. + reformat_notes must auto-detect: try pandoc first, fall back to + numbered. Reassembly must use the original prefix (e.g. "[1] ") so + the output round-trips in the source's marker syntax.""" + draft = """\ +## Notes + +[1] yu, charles. interior chinatown. 2020. p 45. +[2] kwon, hyeyoung. inclusion work. 2022, p 1830. +""" + output = reformat_notes(draft, formatter=_fake_note_formatter) + # Numbered prefix preserved, NOT rewritten as pandoc. + assert "[1] NOTE_FORMATTED(yu, charles. interior chinatown. 2020. p 45.)" in output + assert "[2] NOTE_FORMATTED(kwon, hyeyoung. inclusion work. 2022, p 1830.)" in output + # No pandoc-style markers should appear in the output (we did not + # auto-convert numbered to pandoc). + assert "[^1]" not in output + assert "[^2]" not in output + + +def test_reformat_notes_pandoc_input_still_round_trips_as_pandoc(): + """Regression: after the auto-detect change, pandoc-format input + must still produce pandoc-format output. The original_prefix path + must work for both formats.""" + draft = "[^1]: messy.\n" + output = reformat_notes(draft, formatter=_fake_note_formatter) + assert "[^1]: NOTE_FORMATTED(messy.)" in output + assert "[1] " not in output # numbered prefix must NOT appear + + def test_reformat_notes_preserves_order_under_concurrency(): """With concurrent execution the formatter is called on all definitions in parallel; the output must reassemble them in input order regardless diff --git a/tests/test_parser.py b/tests/test_parser.py index c6e1d5f..c00df2d 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -5,14 +5,31 @@ the prose before it, the list of entries inside it, and the prose after it. The section ends at the next ``##`` (level-2) heading or end of file. Blank lines and bullet markers (``- `` or ``* ``) are stripped from entries. -v2 (notes) parser scope: find pandoc-style markdown footnote definitions -``[^marker]: text`` anywhere in the document. Single-line definitions only; -multi-line continuation is deferred to a later chunk. +v2 (notes) parser scope: two complementary functions for finding footnote +definitions in two distinct source formats: + +- ``find_notes`` finds pandoc-style ``[^marker]: text`` definitions + (caret-prefixed marker, colon after the closing bracket). +- ``find_numbered_notes`` finds ``[N] text`` definitions (square bracket + numeric marker, no caret, no colon, just a space before the body) — + the format produced by docx-to-text conversion of footnoted Word docs. + +Both functions return ``NotesParseResult`` with a list of ``NoteDefinition`` +records carrying the marker, body text, line number, and the original +prefix string (so reassembly can round-trip the source's marker syntax). + +Single-line definitions only in both formats; multi-line continuation is +deferred. """ import pytest -from cmos.parser import NoBibliographyError, find_notes, split_bibliography +from cmos.parser import ( + NoBibliographyError, + find_notes, + find_numbered_notes, + split_bibliography, +) def test_three_entries_under_bibliography_heading(): @@ -159,3 +176,119 @@ Some prose that cites it.[^1] result = find_notes(draft) assert len(result.definitions) == 1 assert result.definitions[0].text == "A definition before the reference." + + +def test_find_notes_records_original_prefix_for_pandoc_format(): + """Reassembly needs the literal prefix string so the round-trip + preserves the source's marker syntax. For pandoc this is `[^N]: `.""" + draft = "[^1]: text.\n[^smith2020]: text.\n" + result = find_notes(draft) + assert result.definitions[0].original_prefix == "[^1]: " + assert result.definitions[1].original_prefix == "[^smith2020]: " + + +# --- find_numbered_notes ([N] text format, no caret, no colon) ------------- + + +def test_find_numbered_notes_single_definition(): + draft = """\ +Some prose. + +[1] Charles Yu, Interior Chinatown. Pantheon, 2020. p. 45. +""" + result = find_numbered_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].marker == "1" + assert result.definitions[0].text == "Charles Yu, Interior Chinatown. Pantheon, 2020. p. 45." + + +def test_find_numbered_notes_multiple_sequential(): + draft = """\ +[1] First. +[2] Second. +[3] Third. +""" + result = find_numbered_notes(draft) + assert len(result.definitions) == 3 + assert [d.marker for d in result.definitions] == ["1", "2", "3"] + assert [d.text for d in result.definitions] == ["First.", "Second.", "Third."] + + +def test_find_numbered_notes_multi_digit_markers(): + """The Anti-Communist draft has 107 notes, including [107]. Multi-digit + markers must work.""" + draft = "[1] one.\n[42] forty-two.\n[107] one-oh-seven.\n" + result = find_numbered_notes(draft) + assert [d.marker for d in result.definitions] == ["1", "42", "107"] + + +def test_find_numbered_notes_returns_empty_when_no_definitions(): + draft = "Just prose with no numbered notes.\n" + result = find_numbered_notes(draft) + assert result.definitions == [] + + +def test_find_numbered_notes_records_line_number(): + draft = """\ +Line zero. +Line one. + +[1] Note on line three. +""" + result = find_numbered_notes(draft) + assert result.definitions[0].line_number == 3 + + +def test_find_numbered_notes_strips_trailing_whitespace_from_text(): + draft = "[1] Note text with trailing spaces. \n" + result = find_numbered_notes(draft) + assert result.definitions[0].text == "Note text with trailing spaces." + + +def test_find_numbered_notes_ignores_pandoc_format(): + """`[^1]: text` is pandoc format, not numbered. find_numbered_notes + must NOT match it (or the two parsers would step on each other when + used in fallback fashion). The `^` after `[` disqualifies it.""" + draft = "[^1]: pandoc-style note.\n" + result = find_numbered_notes(draft) + assert result.definitions == [] + + +def test_find_numbered_notes_ignores_non_numeric_markers(): + """A marker like [Smith 2020] or [foo] is NOT a numbered footnote + definition — it could be many other things (citation reference, link + label, etc.). Only digit markers count.""" + draft = """\ +[Smith 2020] Some text — looks like an author-date reference. +[foo] some kind of label. +[1] Actual numbered note. +""" + result = find_numbered_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].marker == "1" + + +def test_find_numbered_notes_records_original_prefix(): + """For numbered format the prefix is `[N] ` — square bracket, number, + closing bracket, single space. Reassembly will use this verbatim.""" + draft = "[1] text.\n[107] text.\n" + result = find_numbered_notes(draft) + assert result.definitions[0].original_prefix == "[1] " + assert result.definitions[1].original_prefix == "[107] " + + +def test_find_numbered_notes_handles_anti_communist_draft_format(): + """Smoke test mirroring the actual Anti-Communist Formations draft: + notes follow a `## Notes` heading, separated by `________________` rule, + one definition per line, sequentially numbered.""" + draft = """\ +## Notes +________________ +[1] First reference. https://example.org/path +[2] Second reference, abbreviated. +[3] Rosen, 7. +""" + result = find_numbered_notes(draft) + assert len(result.definitions) == 3 + assert result.definitions[0].text == "First reference. https://example.org/path" + assert result.definitions[2].text == "Rosen, 7." -- 2.40.1 From 553240c15388861f2e2450b5fbd56cf1a185450f Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 20:06:59 -0400 Subject: [PATCH 07/13] v2 chunk 3: bug fixes from real-draft triage + polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six independent fixes addressing the Anti-Communist Formations of LIS real-draft test findings (chunk 2c). Four are bug fixes for issues that destroyed user data or wasted API calls; two are polish quality improvements revisited from the deferred list. src/cmos/note_formatter.py — empty input guard (fix #1) Empty / whitespace-only input now short-circuits the API entirely and returns the input verbatim. Surfaced by note [66] in the Anti-Communist draft, where GPT-5 broke character on empty input and returned a conversational meta-reply ("Please paste the citation entry...") that then got substituted into the document. Whitespace preserved so cli.reformat_notes is byte-exact on empty notes. v2 only. src/cmos/note_formatter.py — deprecated Latin guard (fix #2) New Python guard catches the full CMOS-18-deprecated Latin citation set (ibid, idem, id., op. cit., loc. cit.) and returns input verbatim before the API call. Rule 10 in the prompt also rewritten: explicitly says "return verbatim" instead of the old "return cleanest possible full-form note", which the model interpreted as "return empty when there's no information", silently destroying the marker. Surfaced by note [61] = "Ibid.". v2 only. src/cmos/runtime_validator.py — shortened-form carve-out (fix #3) The "must contain italics" check is now skipped when the candidate looks like a CMOS shortened-form note (author last name, optional page, no italic content). Surfaced by ~30 of 107 notes in the Anti-Communist draft that were correctly returned as shortened forms ("Rosen, 7.", "Mitchell, 197.") but rejected by the validator's strict italic check, firing the full retry budget on valid output (~60 wasted API calls per run). The carve-out is conservative: name-token regex + terminal period; doesn't match unstructured prose. Shared infrastructure — affects v1 and v2, no-op in v1's normal workflow because v1 outputs always have italics. src/cmos/note_formatter.py — substantive prose pass-through (fix #4) Long discursive prose with no citation skeleton markers now short-circuits the API and returns the input verbatim. Surfaced by notes [8] (689 chars), [9] (893 chars), [75] (163 chars) in the Anti-Communist draft — substantive notes that the formatter was extracting a single citation from and silently discarding the surrounding commentary. CMOS 14.39 explicitly allows substantive notes; preserving them is the user's explicit policy ("preserve all free text as long as that does not break other formatting"). Detection: length > 150 chars AND no citation skeleton markers (parenthesized year, URL, vol./no./pp., DOI, terminal page or terminal year). Conservative — does not false-positive on legitimate first-occurrence notes. v2 only. src/cmos/formatter.py + note_formatter.py — month/season preservation (polish #5) Both v1 rule 10 (journal article format) and v2 rule 5 (journal article note form) now explicitly instruct the model to preserve (Month YEAR) and (Season YEAR) parentheticals when the source provides them. Surfaced by entries in both Reading_Disrepair and Anti-Communist where (Spring, 1993) and August 1952 were silently collapsed to (1993) and 1952. Both forms are valid CMOS but month/season is more informative when the source has it. Affects v1 and v2 in mirror. src/cmos/note_formatter.py — government documents rule 19 (polish #7) v2 now has an explicit rule mirroring v1's rule 27: government bodies, institutional reports, and similar standalone documents get italicized titles and book-form treatment, NOT quoted-article treatment. Includes the Anti-Communist Senate of California Tenth Report and a hypothetical GAO example. Implicit handling worked in chunk 2c, but explicit rule provides regression protection. v2 only. Tests: +14 net new (across test_note_formatter.py, test_formatter.py, test_runtime_validator.py). Total suite 150/150 (was 136 before this chunk). All v1 tests still passing. Path B status: formatter.py and runtime_validator.py edits cross the v1/v2 boundary, but only by user-explicit approval per the relevant fix discussions. The shared validator was always shared; the v1 formatter rule 10 mirror is a small additive edit that doesn't affect the v1 prompt's existing behavior on its existing inputs. --- src/cmos/formatter.py | 12 ++ src/cmos/note_formatter.py | 204 +++++++++++++++++++++- src/cmos/runtime_validator.py | 93 ++++++++-- tests/test_formatter.py | 16 ++ tests/test_note_formatter.py | 297 ++++++++++++++++++++++++++++++++ tests/test_runtime_validator.py | 72 ++++++++ 6 files changed, 673 insertions(+), 21 deletions(-) diff --git a/src/cmos/formatter.py b/src/cmos/formatter.py index dc28ff0..4f9d483 100644 --- a/src/cmos/formatter.py +++ b/src/cmos/formatter.py @@ -79,6 +79,18 @@ Rules you must follow (CMOS 18th edition specifically): 10. Journal article format: Lastname, First. "Article Title." *Journal Title* VOLUME, no. ISSUE (YEAR): PAGES. DOI-URL. + PRESERVE MONTH OR SEASON information when the source provides it. + Some scholarly journals organize their issues by month or season + rather than (or in addition to) volume/issue numbers, and CMOS + accepts both `(YEAR)` and `(Month YEAR)` / `(Season YEAR)` forms + in the parenthetical. If the source has a month name (January + through December) or a season name (Spring, Summer, Fall, Autumn, + Winter), preserve it as `(Month YEAR)` or `(Season YEAR)` — note + NO comma between the month/season and the year. Do NOT collapse + `(Spring 1993)` to `(1993)` or `August 1952` to `1952` when the + source had the month/season; that loses information unnecessarily. + If the source has only a year, just use `(YEAR)` as before. + 11. Web page format: Author-or-Organization. "Page Title." Site Name. Effective/Published Date. URL. diff --git a/src/cmos/note_formatter.py b/src/cmos/note_formatter.py index a6b1cab..1d302eb 100644 --- a/src/cmos/note_formatter.py +++ b/src/cmos/note_formatter.py @@ -29,12 +29,96 @@ yet implemented. from __future__ import annotations import os +import re from typing import Callable from dotenv import load_dotenv from cmos.runtime_validator import validate +# Pure-token CMOS-18-deprecated Latin citation abbreviations. When the +# input matches this exactly (after .strip()), the formatter +# short-circuits and returns the input verbatim instead of calling the +# API. The model has no information to expand these into a full +# citation (shortened-form generation is out of scope), so the only +# correct behavior is to preserve the marker for human review. +# +# Detection is intentionally STRICT — input must match the deprecated +# token exactly. Inputs like "Ibid., 47" (token + page number) are +# left for the LLM to handle per prompt rule 10, which says return +# verbatim in those cases too. This split avoids over-matching real +# citations that happen to mention "ibid" inside a longer string. +_DEPRECATED_LATIN_RE = re.compile( + r"^(?:" + r"ibid\.?" # ibid, ibid. + r"|idem\.?" # idem, idem. + r"|id\." # id. (period required to avoid matching "id" in unrelated contexts) + r"|op\.?\s*cit\.?" # op cit, op. cit, op.cit, op cit., etc. + r"|loc\.?\s*cit\.?" # loc cit, loc. cit, loc.cit, etc. + r")$", + re.IGNORECASE, +) + +# Substantive note pass-through (chunk 3 fix #4, Option A). When the +# input is long discursive prose with no obvious citation skeleton, the +# formatter returns it VERBATIM without calling the API. CMOS 14.39 +# explicitly allows substantive notes (commentary, qualifications, +# cross-references), and the previous behavior extracted whatever +# citation it could find and silently discarded the surrounding prose. +# Surfaced by notes [8], [9], and [75] in the Anti-Communist Formations +# of LIS draft. +# +# Detection is conservative: input must be longer than the threshold +# AND must NOT contain ANY of the citation skeleton markers below. +# Real first-occurrence citations virtually always have at least one +# skeleton marker (terminal year, terminal page reference, URL, vol., +# no., pp., DOI, parenthesized year). Substantive prose typically has +# none of these. The combination of length-floor + marker-absence +# catches the egregious cases without false-positiving on legitimate +# citations that happen to be long. +# +# The user's explicit policy (chunk 3 fix #4): preserve ALL free text +# as long as that does not break other formatting. Erring on the side +# of preservation is intentional. +_SUBSTANTIVE_PROSE_LENGTH_THRESHOLD = 150 + +_CITATION_SKELETON_MARKERS_RE = re.compile( + r"(?:" + r"\([^)]*\d{4}[^)]*\)" # parenthesized year — (2024), (NYU Press, 2023), (March 2024) + r"|https?://" # URL + r"|\bvol\.\s*\d+" # volume number — vol. 7 + r"|\bno\.\s*\d+" # issue number — no. 4 + r"|\bpp?\.\s*\d+" # page reference — p. 45, pp. 45-67 + r"|\bdoi[:.\s]" # DOI marker — doi:, doi., doi space + r"|,\s*\d+(?:[-\u2013]\d+)?\.\s*$" # terminal page reference — ", 145." or ", 145-67." + r"|\b\d{4}\.?\s*$" # year at end of string — "2023" or "2023." + r")", + re.IGNORECASE, +) + + +def _looks_like_substantive_prose(text: str) -> bool: + """True if ``text`` looks like substantive note prose, not a citation. + + Conservative detection: only matches input that is BOTH long AND + lacks any obvious citation skeleton markers. Designed to catch + long discursive notes (689+ chars in the Anti-Communist draft) + while preserving legitimate first-occurrence notes — which all + have at least one skeleton marker even when long. The shorter + [75]-style cases (~163 chars, no markers) are also caught because + the threshold is set well below the typical citation length floor + when markers are present. + + See ``_SUBSTANTIVE_PROSE_LENGTH_THRESHOLD`` and + ``_CITATION_SKELETON_MARKERS_RE`` above for the detection details. + """ + stripped = text.strip() + if len(stripped) < _SUBSTANTIVE_PROSE_LENGTH_THRESHOLD: + return False + if _CITATION_SKELETON_MARKERS_RE.search(stripped): + return False + return True + # Default model. Override with OPENAI_MODEL=... in your .env. MODEL = os.environ.get("OPENAI_MODEL", "gpt-5") @@ -96,6 +180,19 @@ Rules you must follow (CMOS 18th edition specifically, NOTE form): the quotes, and the colon between (YEAR) and the page is NOT preceded by a space. + PRESERVE MONTH OR SEASON information when the source provides + it. Some scholarly journals organize their issues by month or + season rather than (or in addition to) volume/issue numbers, and + CMOS accepts both `(YEAR)` and `(Month YEAR)` / `(Season YEAR)` + forms in the parenthetical. If the source has a month name + (January through December) or a season name (Spring, Summer, + Fall, Autumn, Winter), preserve it as `(Month YEAR)` or + `(Season YEAR)` — note NO comma between the month/season and + the year. Do NOT collapse `(Spring 1993)` to `(1993)` or + `August 1952` to `1952` when the source had the month/season; + that loses information unnecessarily. If the source has only a + year, just use `(YEAR)` as before. + 6. CHAPTER IN EDITED BOOK NOTE FORM: Author, "Chapter Title," in *Book Title*, ed. Editor Names (Publisher, Year), specific-page. @@ -119,12 +216,34 @@ Rules you must follow (CMOS 18th edition specifically, NOTE form): as a full URL: https://doi.org/10.xxxx/yyyy (no "doi:" prefix, no bare DOI). -10. NO IBID. CMOS 18 deprecates "Ibid." entirely. Do not produce it - under any circumstances, even if the input contains it. If the - input is an "Ibid." reference, this iteration of the formatter - cannot resolve it (shortened-form generation is out of scope). - Return the cleanest possible full-form note from whatever - information the messy input contains. +10. NO DEPRECATED LATIN ABBREVIATIONS. CMOS 18 deprecates the entire + family of older Latin citation abbreviations in favor of + shortened-form notes: + - "ibid." / "ibid" (ibidem — same as previous) + - "idem" (the same author as previous) + - "id." (also "the same") + - "op. cit." (opere citato — in the work cited) + - "loc. cit." (loco citato — in the place cited) + Do NOT produce ANY of these abbreviations in your output, even + if the input contains them. + + If the input contains a deprecated abbreviation that this + iteration cannot resolve (because shortened-form generation is + out of scope and the formatter has no access to the previously- + cited source), return the input VERBATIM. Do NOT return an empty + string. Do NOT invent missing context (no fabricated author + names, titles, or page numbers). Do NOT silently delete the + marker. Returning the input verbatim signals to the user that + this entry needs manual resolution against the surrounding notes + — losing the marker silently is worse than any other failure + mode here. + + The pure-token cases ("Ibid.", "idem", "op. cit.", etc. on their + own with no other content) are caught deterministically by a + Python-side guard before the API call ever fires; this rule + covers the cases where a deprecated abbreviation appears inline + with other content (e.g., "Ibid., 47" or "Smith, op. cit., 12"). + In all such cases: return the input verbatim. 11. PRESERVE TERMINAL PUNCTUATION inside titles. If a title ends in "?" or "!", keep that punctuation inside the closing quote and @@ -186,6 +305,37 @@ Rules you must follow (CMOS 18th edition specifically, NOTE form): "MLA") stay in that form. When in doubt, prefer the longer form over the abbreviation. +19. GOVERNMENT DOCUMENTS AND INSTITUTIONAL REPORTS — CMOS 14.272. + When the author is a government body, agency, institution, or + NGO (e.g., "Government Accountability Office", "U.S. Department + of Education", "Senate of the State of California", "World Health + Organization", "Pew Research Center", "American Library + Association") and the source is a STANDALONE report or document + (not an article in a periodical), treat the report like a BOOK: + italicize the title with Markdown *...*. Do NOT wrap the title in + quotes — that is the article/chapter form, which is wrong for a + standalone institutional report. + + Note form: + Author-Body, *Title of Report*, Report Number (Publisher, Date), + specific-page, URL. + + The Report Number (e.g., "GAO-26-107262") is an identifier and + sits after the italicized title with comma separation. The + publisher is often the same body as the author (institutional + self-publication is common); include both even when they + duplicate. If there is no separate publisher and only the author- + body is named, that is the publisher. + + Examples: + - Government Accountability Office, *Public Libraries: Many + Buildings Are Reported to Be in Poor Condition*, GAO-26-107262 + (Government Accountability Office, December 18, 2025), 4, + https://files.gao.gov/reports/GAO-26-107262/index.html. + - Senate of the State of California, *Tenth Report: Senate + Investigating Committee on Education, the 1951 Hearings*, + 66, https://hdl.handle.net/2027/mdp.39015070361673. + Output ONLY the single reformatted note. No preamble, no explanation, no leading number, no code fences. """ @@ -243,6 +393,41 @@ def format_note_entry( Pass a ``caller`` shim to avoid the real API (used by tests and the harness when running with a fake formatter for loop smoke tests). + Empty input short-circuit: if ``messy_entry`` is empty or contains + only whitespace, the function returns it VERBATIM without calling + the API. Surfaced by the Anti-Communist Formations of LIS draft + (note [66] is an empty placeholder line) — without the guard, GPT-5 + occasionally breaks character on empty input and returns a + conversational meta-reply asking for content. The whitespace is + preserved verbatim so reassembly in ``cmos.cli.reformat_notes`` is + byte-exact on empty notes; the source's spacing is part of the + input contract, not noise to normalize. v2 only — v1's + ``format_bibliography_entry`` does not currently have this guard. + + Deprecated-Latin short-circuit: if ``messy_entry.strip()`` exactly + matches one of the CMOS-18-deprecated Latin citation abbreviations + (ibid, idem, id., op. cit., loc. cit.), the function returns the + input VERBATIM without calling the API. This iteration cannot + resolve shortened-form references (no access to the previously- + cited source), and returning empty would silently delete the + marker the user needs for manual resolution. Surfaced by note [61] + in the Anti-Communist Formations of LIS draft. Detection is + strict (token-only); inputs with extra content like "Ibid., 47" + fall through to the LLM, which is governed by prompt rule 10. + v2 only. + + Substantive prose pass-through: if ``messy_entry`` looks like a + substantive note (long discursive prose with no obvious citation + skeleton markers), the function returns the input VERBATIM without + calling the API. CMOS 14.39 explicitly allows substantive notes, + and the previous behavior extracted whatever citation it could + find and silently discarded the surrounding prose. Surfaced by + notes [8], [9], and [75] in the Anti-Communist Formations of LIS + draft. Detection is conservative (length-floor + skeleton-marker + absence) — see ``_looks_like_substantive_prose``. The user's + explicit policy: preserve ALL free text as long as that does not + break other formatting. v2 only. + Reasoning models (gpt-5, o-series) are nondeterministic. The candidate is checked against ``cmos.runtime_validator`` after each call; if it fails any structural sanity check (missing terminal period, dropped @@ -258,6 +443,13 @@ def format_note_entry( don't raise — the caller still gets something usable, and the failure will surface via the v2 scoring linter or human review). """ + stripped = messy_entry.strip() + if not stripped: + return messy_entry + if _DEPRECATED_LATIN_RE.match(stripped): + return messy_entry + if _looks_like_substantive_prose(messy_entry): + return messy_entry call = caller or _openai_caller user_message = build_user_message(messy_entry) last: str = "" diff --git a/src/cmos/runtime_validator.py b/src/cmos/runtime_validator.py index 6af3b22..0cc60bc 100644 --- a/src/cmos/runtime_validator.py +++ b/src/cmos/runtime_validator.py @@ -1,34 +1,57 @@ """Runtime structural validator — type-independent sanity checks on a single -formatted bibliography entry. +formatted bibliography or note entry. -This is the gate used by ``cmos.formatter.format_bibliography_entry`` to -decide whether a candidate output is well-formed enough to return, or -whether to retry the API call. It runs WITHOUT an Exemplar, because at -runtime we have only the messy input and the candidate output — no -canonical fields, no source-type metadata. +This is the gate used by ``cmos.formatter.format_bibliography_entry`` and +``cmos.note_formatter.format_note_entry`` to decide whether a candidate +output is well-formed enough to return, or whether to retry the API call. +It runs WITHOUT an Exemplar, because at runtime we have only the messy +input and the candidate output — no canonical fields, no source-type +metadata. Because it knows neither the source type nor the canonical fields, it deliberately checks only structural properties that hold for ALL CMOS 18 -bibliography entries: +bibliography entries (and CMOS 18 first-occurrence note entries): - Ends with a period. - Contains no "Ibid." (deprecated in CMOS 18). - Contains at least one italic span (``*...*``). This catches one of the most common nondeterministic regressions: GPT-5 occasionally drops italic markers around a journal/book/magazine title. Almost - every CMOS bibliography entry italicizes SOMETHING (book title, - journal name, magazine name, podcast series, report title), so an - entry with no italics at all is highly likely to be malformed. + every CMOS full citation italicizes SOMETHING (book title, journal + name, magazine name, podcast series, report title), so a full + citation with no italics at all is highly likely to be malformed. - Italic markers (``*``) are balanced (even count). - Straight double quotes (``"``) are balanced (even count). -This is intentionally weaker than ``cmos.linter`` (the scoring linter -that needs an Exemplar). The point is to be a fast, deterministic -guardrail at runtime, not to validate every CMOS rule. +**Shortened-form note carve-out (chunk 3 fix #3, Option D):** the +"must contain italics" check is SKIPPED when the candidate looks like +a CMOS shortened-form note — author last name (or "Lastname, page") +with no title and no italic content. Without this carve-out, the +validator rejects valid shortened forms ("Rosen, 7.", "Ettarh.", +"Mitchell, 197.") and the formatter's retry loop fires its full budget +on correct output. Surfaced by the Anti-Communist Formations of LIS +real-draft run where ~30 of 107 notes were valid shortened forms that +wasted ~60 API calls between them. + +The carve-out is conservative: it only matches one or two name-token +forms (capitalized, allowing apostrophes and hyphens for names like +"O'Mara" and "Burden-Stelly") optionally followed by a comma and a +page number / page range, ending in a period. Anything more complex +(quoted short titles, italicized short titles, longer prose) still +falls under the strict "must have italics" check. The goal is to +catch the most common shortened-form cases without false positives +on actual full-form citations that happen to be short. + +This validator is intentionally weaker than ``cmos.linter`` (the +scoring linter that needs an Exemplar). The point is to be a fast, +deterministic guardrail at runtime, not to validate every CMOS rule. +Shared between v1 (bibliography formatter) and v2 (note formatter) +because both use the same retry loop pattern. Edits affect both. """ from __future__ import annotations +import re from dataclasses import dataclass, field @@ -41,6 +64,44 @@ class ValidationResult: return not self.failures +# Shortened-form note pattern. Matches the two most common CMOS shortened +# forms that legitimately have no italics: +# - Author last name only: "Ettarh.", "Murch.", "O'Mara.", "Burden-Stelly." +# - Author last name + page: "Rosen, 7.", "Mitchell, 197.", "CBS, 47." +# "Lawrence Powell, 45." (multi-word name) +# "Mitchell, 137-39." (page range) +# +# Each name token must start with an uppercase letter (rejects "this is some +# prose without italics."). Apostrophes and hyphens are allowed inside name +# tokens for names like "O'Mara" and "Burden-Stelly". A multi-word name is +# space-separated capitalized tokens. The optional page is a comma followed +# by digits, optionally with an ASCII or en-dash range. The whole thing +# ends with a required period. +# +# Does NOT match shortened forms with embedded short titles (quoted or +# italicized) — those are harder to disambiguate from real first-occurrence +# notes and are left for the strict italics check / future iteration. +_SHORTENED_FORM_RE = re.compile( + r"^" + r"[A-Z][\w'\-]*" # First name token (Lastname, possibly hyphenated/apostrophe) + r"(?:\s+[A-Z][\w'\-]*)*" # Optional additional name tokens + r"(?:,\s*\d+(?:[-\u2013]\d+)?)?" # Optional ", page" or ", page-range" / page–range + r"\.$" # Required terminal period +) + + +def _looks_like_shortened_form(text: str) -> bool: + """True if ``text`` matches the conservative shortened-form pattern. + + Used by ``validate`` to skip the "must have italics" check on + legitimate shortened-form notes that have no italic content. Only + catches author-only and author-plus-page shapes; more complex + shortened forms (with quoted or italicized short titles) are not + matched here and remain subject to the strict italic check. + """ + return _SHORTENED_FORM_RE.match(text.strip()) is not None + + def validate(candidate: str) -> ValidationResult: result = ValidationResult() text = candidate.rstrip() @@ -51,9 +112,11 @@ def validate(candidate: str) -> ValidationResult: if "ibid" in text.lower(): result.failures.append("contains 'Ibid.' (deprecated in CMOS 18)") - if "*" not in text: + # Strict italic check, with a carve-out for legitimate shortened-form + # notes that have no italics by definition. See module docstring. + if "*" not in text and not _looks_like_shortened_form(text): result.failures.append( - "no italic span found (CMOS bibliography entries usually italicize " + "no italic span found (CMOS full citations usually italicize " "a book/journal/magazine/series/report title)" ) diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 96702a1..7fcf32e 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -47,6 +47,22 @@ def test_system_prompt_mentions_italic_markers(): assert "*" in SYSTEM_PROMPT and "italic" in SYSTEM_PROMPT.lower() +def test_system_prompt_mentions_month_or_season_preservation(): + """Polish fix #5: when the source provides month or season information + in a scholarly journal date, the formatter should preserve it in CMOS + form `(Month YEAR)` or `(Season YEAR)` rather than collapsing to + year-only. Surfaced by Reading_Disrepair (v1) and Anti-Communist (v2) + real-draft runs where dates like `(Spring, 1993)` and `August 1952` + were silently collapsed to `(1993)` and `1952`. Both forms are valid + CMOS but month/season is more informative when the source has it. + """ + lower = SYSTEM_PROMPT.lower() + assert "month" in lower + assert "season" in lower + # And some indication of preservation, not collapse. + assert "preserve" in lower + + def test_build_user_message_contains_the_messy_input(): msg = build_user_message("yu, charles. interior chinatown. 2020") assert "yu, charles. interior chinatown. 2020" in msg diff --git a/tests/test_note_formatter.py b/tests/test_note_formatter.py index e7b7488..ddf07ff 100644 --- a/tests/test_note_formatter.py +++ b/tests/test_note_formatter.py @@ -53,6 +53,33 @@ def test_system_prompt_mentions_no_ibid(): assert "Ibid" in SYSTEM_PROMPT_NOTES or "ibid" in SYSTEM_PROMPT_NOTES.lower() +def test_system_prompt_mentions_broader_deprecated_latin_set(): + """Rule 10 was rewritten in fix #2 to cover the broad CMOS 18 + deprecated set, not just Ibid. The prompt must mention idem, + op. cit., loc. cit. so the LLM knows to refuse all of them + consistently — the Python guard catches pure-token cases, but + the prompt must cover the cases where a deprecated abbreviation + appears alongside other content (e.g. "Ibid., 47").""" + lower = SYSTEM_PROMPT_NOTES.lower() + assert "idem" in lower + assert "op. cit" in lower or "op cit" in lower + assert "loc. cit" in lower or "loc cit" in lower + + +def test_system_prompt_rule_10_says_return_verbatim_not_empty(): + """Rule 10 must explicitly say "return verbatim" (or equivalent) + when the input contains a deprecated abbreviation the formatter + cannot resolve. The previous wording said "return the cleanest + possible full-form note from whatever information the messy input + contains" which the model interpreted as "return empty when there's + no information" — destroying the marker the user needs for review. + """ + lower = SYSTEM_PROMPT_NOTES.lower() + assert "verbatim" in lower + # And the prompt should explicitly forbid the empty-string output. + assert "do not return an empty" in lower or "not return empty" in lower + + def test_system_prompt_mentions_publisher_full_form(): """Surfaced by chunk 1 smoke test on chapter_first_doyle: the formatter preserved 'U of Chicago Press' from the messy input instead of @@ -72,6 +99,35 @@ def test_system_prompt_mentions_publisher_full_form(): ) +def test_system_prompt_mentions_month_or_season_preservation(): + """Polish fix #5 (v2 mirror): preserve month/season info in scholarly + journal dates rather than collapsing to year-only. Mirror of the v1 + test in tests/test_formatter.py — both prompts get the same rule. + Surfaced by entries [19] and [54] in the Anti-Communist Formations + of LIS draft where (Spring, 1993) and August 1952 were collapsed. + """ + lower = SYSTEM_PROMPT_NOTES.lower() + assert "month" in lower + assert "season" in lower + assert "preserve" in lower + + +def test_system_prompt_mentions_government_reports_rule(): + """Polish fix #7: v2 should explicitly handle government documents + and institutional reports the same way v1's rule 27 does — italicize + the title like a book, do NOT wrap in quotes. Implicit handling + worked in the chunk 2c real-draft test (entries [11] and [83]) but + explicit rule provides regression protection if a future prompt + iteration loses this implicit knowledge. + """ + lower = SYSTEM_PROMPT_NOTES.lower() + assert "government" in lower or "institutional" in lower + assert "report" in lower + # And the key behavior: treat like a book (italicize title), not a + # quoted article. + assert "italic" in lower # already in the prompt for general italics + + def test_system_prompt_mentions_ed_invariant_for_multiple_editors(): """Surfaced by chunk 1 smoke test on chapter_first_doyle: the formatter pluralized 'ed.' to 'eds.' when there were two editors. CMOS NB form @@ -147,3 +203,244 @@ def test_note_formatter_returns_last_attempt_after_max_retries(): output = format_note_entry("messy", caller=always_bad, max_retries=2) assert len(attempts) == 3 # 1 initial + 2 retries assert "*" not in output + + +def test_format_note_entry_short_circuits_deprecated_latin_abbreviations(): + """When the input is ONLY a CMOS-18-deprecated Latin citation + abbreviation (ibid, idem, id., op. cit., loc. cit.), the formatter + must short-circuit the API and return the input verbatim. Surfaced + by note [61] in the Anti-Communist Formations of LIS draft, where + the formatter was given "Ibid." and (after retrying through the + runtime validator) returned an empty string — silently destroying + the marker that the user needs to find later for manual resolution. + + The deprecated set per CMOS 18: + ibid, ibid., idem, id., op. cit., loc. cit. + plus common spelling/spacing variants (Op.Cit., loc cit, etc.). + + The guard MUST be deterministic (Python-side, not LLM-side) and + MUST NOT call the API. Cost guarantee enforced via counting caller. + The whitespace of the original input is preserved verbatim so + reassembly in cmos.cli.reformat_notes is byte-exact. + """ + call_count = 0 + + def counting_caller(system: str, user: str) -> str: + nonlocal call_count + call_count += 1 + return "should not be called" + + deprecated_inputs = [ + "Ibid.", + "ibid", + "IBID.", + "Idem", + "idem.", + "id.", + "Id.", + "op. cit.", + "Op. Cit.", + "op.cit.", + "Op Cit", + "loc. cit.", + "Loc. Cit.", + "loc.cit.", + # With surrounding whitespace preserved verbatim + " Ibid. ", + "\tIbid.\n", + ] + for inp in deprecated_inputs: + result = format_note_entry(inp, caller=counting_caller) + assert result == inp, ( + f"Input {inp!r} must round-trip verbatim; got {result!r}" + ) + + assert call_count == 0, ( + f"API caller was invoked {call_count} times for deprecated " + "Latin abbreviations — the short-circuit guard is missing or " + "not effective. Deprecated tokens must NEVER reach the API." + ) + + +def test_format_note_entry_does_not_short_circuit_normal_citations(): + """The deprecated-token guard must NOT match real citations that + happen to start with one of the tokens. e.g. 'Ibid., 47' is a + common usage where a page number is appended; for now we let the + LLM see those (the prompt rule covers them). Pure-token-only + inputs short-circuit; anything with extra content goes to the API. + """ + calls: list[str] = [] + + def recording_caller(system: str, user: str) -> str: + calls.append(user) + return "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + # Real-looking inputs that contain "ibid" but are not pure tokens + not_short_circuited = [ + "Ibid., 47.", # ibid + page — common usage + "Smith, Ibid., 47.", + "Title with the word ibid in it.", + "Charles Yu, Interior Chinatown.", # totally unrelated + ] + for inp in not_short_circuited: + format_note_entry(inp, caller=recording_caller) + + assert len(calls) == len(not_short_circuited), ( + "Some non-pure-token inputs were short-circuited — guard is " + "too aggressive." + ) + + +def test_format_note_entry_passes_through_substantive_prose_verbatim(): + """When the input is substantive prose — long discursive commentary + with no obvious citation skeleton — the formatter must return it + VERBATIM without calling the API. CMOS 14.39 explicitly allows + substantive notes (commentary, qualifications, cross-references). + The previous behavior extracted whatever citation it could find and + silently discarded the surrounding prose, which is data loss. + + Surfaced by notes [8], [9], and [75] in the Anti-Communist + Formations of LIS draft. The user's explicit policy (chunk 3 + fix #4): preserve ALL free text, as long as that does not break + other formatting. + + Detection rule (conservative): length > 150 chars AND no obvious + citation skeleton markers (parenthesized year, URL, vol./no./pp., + DOI, terminal page reference, terminal year). This catches the + egregious cases without false-positiving on legitimate citations, + which all have at least one skeleton marker. + """ + call_count = 0 + + def counting_caller(system: str, user: str) -> str: + nonlocal call_count + call_count += 1 + return "should not be called" + + # [8]-style: long discursive note about DuBois/Morehouse, no markers + note_8_style = ( + "W.E.B. DuBois' popularization of philanthropist Henry Lyman " + "Morehouse's theory of the talented tenth is celebrated, missing " + "the note that he himself retracted such a theory later in his " + "life, as written about by Joy James. The violence of Jim Crow " + "becomes diminished into many monstrosities, since traditionally " + "black colleges and universities became monuments to human hope " + "through philanthropic funding." + ) + assert format_note_entry(note_8_style, caller=counting_caller) == note_8_style + + # [9]-style: discursive about Wilder/Murch, no markers + note_9_style = ( + "Craig Steven Wilder's Ebony and Ivy discusses the political " + "economy of anti-Blackness in higher education, and the influence " + "of not only capital and capitalists in the creation of " + "Historically Black Colleges and Universities and white elite " + "universities, but also enslaved labor to support the latter." + ) + assert format_note_entry(note_9_style, caller=counting_caller) == note_9_style + + # [75]-style: shorter cross-archive prose, no markers + note_75_style = ( + "This letter appeared in multiple archives, including California " + "State Archives, Berkeley and UCLA special collections, and is " + "uploaded on UC Santa Barbara Servers." + ) + assert format_note_entry(note_75_style, caller=counting_caller) == note_75_style + + assert call_count == 0, ( + f"API caller was invoked {call_count} times for substantive prose — " + "the substantive-note pass-through guard is missing or not " + "effective. Free text must NEVER be sent to the API for " + "reformatting under this fix." + ) + + +def test_format_note_entry_does_not_pass_through_real_citations(): + """The substantive-prose guard must NOT match real citations that + happen to be long. The detection requires absence of citation + skeleton markers; real citations have at least one (year, URL, + vol/no/pp, DOI, terminal page). This test pins the negative cases + so the guard doesn't drift toward over-aggression. + """ + calls: list[str] = [] + + def recording_caller(system: str, user: str) -> str: + calls.append(user) + # Return validator-passing output so the retry loop doesn't fire + # and inflate the call count. + return "Fake Author, *Fake Title* (Fake Publisher, 2024), 1." + + # Real first-occurrence book note (158 chars, ends with year+period) + note_6_style = ( + "Steve Batterson, The Prosecution of Professor Chandler Davis: " + "McCarthyism, Communism, and the Myth of Academic Freedom. " + "New York: NYU Press, 2023." + ) + format_note_entry(note_6_style, caller=recording_caller) + + # Real journal article note (URL marker) + note_44_style = ( + "Lawrence W.S. Auld, \"The King Report: New Directions in Library " + "and Information Science Education.\" ACRL College & Research " + "Libraries News 48, no. 4, 1987. https://crln.acrl.org/example." + ) + format_note_entry(note_44_style, caller=recording_caller) + + # Real govt report (parenthesized year + URL) + note_54_style = ( + "Robert D. Leigh, The California Librarian Education Survey: a " + "Report to President Robert G. Sproul of the University of " + "California. (New York: Columbia University, August 1952). " + "https://hdl.handle.net/example" + ) + format_note_entry(note_54_style, caller=recording_caller) + + # Real master's thesis (year at end) + note_107_style = ( + "Christine Michele Curley, \"The School of the Library,\" " + "Unpublished Master Thesis, University of California Los " + "Angeles, 2017." + ) + format_note_entry(note_107_style, caller=recording_caller) + + assert len(calls) == 4, ( + "Some real first-occurrence notes were short-circuited as " + "substantive prose — the detection is too aggressive. Each of " + "these has at least one citation skeleton marker and must be " + "sent to the API for reformatting." + ) + + +def test_format_note_entry_returns_empty_input_verbatim_without_calling_api(): + """Empty or whitespace-only input must short-circuit the API call and + return the input verbatim. Without this guard, GPT-5 occasionally breaks + character on empty input and returns a conversational meta-reply asking + for content (e.g., "Please paste the citation entry you'd like + formatted..."). Surfaced by note [66] in the Anti-Communist Formations + of LIS draft. + + The whitespace must be preserved verbatim so reassembly in + cmos.cli.reformat_notes is byte-exact on empty notes — the source's + spacing is part of the input contract, not noise to be normalized. + + Critically, the API must NOT be called for empty input — the counting + caller asserts call_count == 0 to lock in the cost guarantee. + """ + call_count = 0 + + def counting_caller(system: str, user: str) -> str: + nonlocal call_count + call_count += 1 + return "should not be called" + + # Each whitespace flavor must round-trip verbatim and not call the API. + assert format_note_entry("", caller=counting_caller) == "" + assert format_note_entry(" ", caller=counting_caller) == " " + assert format_note_entry("\n", caller=counting_caller) == "\n" + assert format_note_entry("\t \n", caller=counting_caller) == "\t \n" + + assert call_count == 0, ( + "API caller was invoked on empty input — the short-circuit " + "guard is missing or not effective. Empty input must NEVER " + "reach the API." + ) diff --git a/tests/test_runtime_validator.py b/tests/test_runtime_validator.py index e526f7d..deb0677 100644 --- a/tests/test_runtime_validator.py +++ b/tests/test_runtime_validator.py @@ -50,6 +50,78 @@ def test_missing_italics_anywhere_fails(): assert any("italic" in f.lower() for f in result.failures) +# --- Shortened-form note carve-out (chunk 3 fix #3, Option D) ------------- + + +def test_shortened_form_author_only_passes_without_italics(): + """CMOS shortened note form for an author-only reference (no page, + no title) is just the author's last name with a period: 'Ettarh.', + 'Murch.', 'O'Mara.'. These contain no italics by definition. + Without a carve-out, the validator's "must have italics" rule + rejects them and the formatter's retry loop fires its full budget + on valid output. Surfaced by the Anti-Communist Formations of LIS + real-draft run; ~30 of 107 notes were correct shortened-forms + that wasted ~60 API calls retrying. + + The carve-out: if a candidate has no italics AND looks like a + shortened-form note (short, name-token shape, optional page, + terminal period), the missing-italics check is skipped. + """ + for candidate in ["Ettarh.", "Murch.", "O'Mara.", "Burden-Stelly."]: + result = validate(candidate) + assert result.passed is True, ( + f"shortened-form author-only note {candidate!r} should pass " + f"validation; got failures {result.failures}" + ) + + +def test_shortened_form_author_plus_page_passes_without_italics(): + """The other common CMOS shortened form is author last name + page: + 'Rosen, 7.', 'Mitchell, 197.', 'CBS, 47.'. Same carve-out applies.""" + for candidate in [ + "Rosen, 7.", + "Mitchell, 197.", + "CBS, 47.", + "Seybold, 282.", + "Lawrence Powell, 45.", # multi-word name + "Mitchell, 137-39.", # page range with hyphen + ]: + result = validate(candidate) + assert result.passed is True, ( + f"shortened-form note {candidate!r} should pass validation; " + f"got failures {result.failures}" + ) + + +def test_long_no_italic_output_still_fails(): + """The carve-out must NOT be a free pass for any short string. + A long output with no italics is still likely a malformed + full-form citation (model dropped the italic title), and the + retry loop should still fire on it. + """ + candidate = ( + "Steve Batterson, The Prosecution of Professor Chandler Davis: " + "McCarthyism, Communism, and the Myth of Academic Freedom (NYU Press, 2023)." + ) + result = validate(candidate) + assert result.passed is False + assert any("italic" in f.lower() for f in result.failures) + + +def test_short_but_unstructured_no_italic_output_still_fails(): + """A short but non-name-shaped string (e.g., a stray sentence + fragment with no name structure) should still fail. The carve-out + is NOT just "short and ends with period" — it requires the + candidate to look like a shortened-form note specifically. + """ + candidate = "this is some prose without italics." + result = validate(candidate) + assert result.passed is False, ( + "lowercase prose fragment must not be accepted as a shortened-form " + "note; the carve-out should require name-token shape" + ) + + def test_unbalanced_italic_markers_fails(): candidate = "Yu, Charles. *Interior Chinatown. Pantheon Books, 2020." result = validate(candidate) -- 2.40.1 From 83339e313c798c755862d7af384231ac4219e32a Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 20:32:10 -0400 Subject: [PATCH 08/13] v2 chunk 4: revert validator carve-out + expand publisher exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups based on the chunk 3 verification re-run findings. src/cmos/runtime_validator.py — revert chunk 3 fix #3 (carve-out) Chunk 3 fix #3 added a carve-out so shortened-form notes ("Rosen, 7.", "Ettarh.") would pass the validator without italics, saving retry cost. Chunk 3 verification on the Anti-Communist draft showed the carve-out also let GPT-5's variance produce under-italicized variants of shortened forms WITH short titles (e.g., "A Restudy, 73." instead of the more CMOS-correct "*A Restudy*, 73."). The user explicitly chose accuracy over the cost saving and asked for the carve-out to be reverted. The strict italics check now applies uniformly. Simple shortened forms (Mitchell, 197., Ettarh.) get retried unnecessarily and waste API cost without producing better output. Shortened forms with short titles get a fair shot at the italicized version on the retry. Cost ↑, accuracy ↑. Removes _SHORTENED_FORM_RE, _looks_like_shortened_form, and the carve-out check from validate(). Updates module docstring with history note. Inverts the 2 carve-out tests in test_runtime_validator.py to assert shortened forms now FAIL the strict check, documenting the design intent for future reviewers. src/cmos/note_formatter.py — expand rule 18 publisher exception list Chunk 3 verification showed [6] Batterson getting "NYU Press" expanded to "New York University Press", losing the publisher's canonical brand. Rule 18's exception list previously only named MIT Press, ALA Editions, and MLA. Expanded to include NYU Press, Routledge, IEEE Press, ACM Press, WHO Press, plus "Pew Research Center" as a non-Press canonical example. Also added a "when in doubt" guidance paragraph: if the abbreviation contains "Press" and is widely used as the publisher's own branding, leave it alone. The risk of losing a brand name (NYU Press) is worse than the risk of leaving an obscure abbreviation. v2 only. Tests: 151/151 green (was 150). The +1 is the new NYU Press prompt-content test; the 2 inverted runtime_validator tests stayed at the same count. Path B: runtime_validator change crosses the v1/v2 boundary (shared infrastructure), per the same approval that authorized the original chunk 3 fix #3. v1's bibliography formatter is unaffected in practice because v1 outputs always have italics. --- src/cmos/note_formatter.py | 30 ++++++++++--- src/cmos/runtime_validator.py | 76 +++++++-------------------------- tests/test_note_formatter.py | 11 +++++ tests/test_runtime_validator.py | 65 +++++++++++++++++----------- 4 files changed, 91 insertions(+), 91 deletions(-) diff --git a/src/cmos/note_formatter.py b/src/cmos/note_formatter.py index 1d302eb..3a8068b 100644 --- a/src/cmos/note_formatter.py +++ b/src/cmos/note_formatter.py @@ -293,17 +293,37 @@ Rules you must follow (CMOS 18th edition specifically, NOTE form): 18. PUBLISHER NAMES must be in FULL canonical form. Do NOT abbreviate. If the messy input contains an abbreviated form, EXPAND it to the - canonical full name. Examples: + canonical full name. Examples of expansion: - "U of Chicago Press" → "University of Chicago Press" - "OUP" → "Oxford University Press" - "CUP" → "Cambridge University Press" - "HMC" → "Houghton Mifflin Company" - "Random House" stays "Random House" (already canonical) - "Pantheon Books" stays "Pantheon Books" (already canonical) - The exception: publishers whose canonical self-presentation - legitimately uses initials (e.g., "MIT Press", "ALA Editions", - "MLA") stay in that form. When in doubt, prefer the longer - form over the abbreviation. + + EXCEPTION — publishers whose CANONICAL self-presentation IS the + short form (initials or short brand) stay in that form. Do NOT + expand these. The publisher's own website / imprint colophon is + the source of truth here. Known cases (not exhaustive): + - "MIT Press" (NOT "Massachusetts Institute of Technology Press") + - "NYU Press" (NOT "New York University Press") + - "Routledge" (already short and canonical) + - "Sage" / "SAGE Publications" (depending on the imprint's own + branding; do not over-expand) + - "ALA Editions" (American Library Association's imprint) + - "MLA" / "Modern Language Association" + - "IEEE Press" + - "ACM Press" + - "WHO Press" (World Health Organization's imprint) + - "Pew Research Center" (already canonical, no Press suffix) + + When in doubt: if the abbreviated form contains the word "Press" + and is widely used in academic citations as the publisher's + own branding, leave it alone. The risk of expanding "NYU Press" + to "New York University Press" — losing the brand the publisher + actually uses — is worse than the risk of leaving an obscure + abbreviation unchanged. Prefer leaving short canonical brand + names alone over forcing expansion. 19. GOVERNMENT DOCUMENTS AND INSTITUTIONAL REPORTS — CMOS 14.272. When the author is a government body, agency, institution, or diff --git a/src/cmos/runtime_validator.py b/src/cmos/runtime_validator.py index 0cc60bc..e2fff0a 100644 --- a/src/cmos/runtime_validator.py +++ b/src/cmos/runtime_validator.py @@ -23,24 +23,21 @@ bibliography entries (and CMOS 18 first-occurrence note entries): - Italic markers (``*``) are balanced (even count). - Straight double quotes (``"``) are balanced (even count). -**Shortened-form note carve-out (chunk 3 fix #3, Option D):** the -"must contain italics" check is SKIPPED when the candidate looks like -a CMOS shortened-form note — author last name (or "Lastname, page") -with no title and no italic content. Without this carve-out, the -validator rejects valid shortened forms ("Rosen, 7.", "Ettarh.", -"Mitchell, 197.") and the formatter's retry loop fires its full budget -on correct output. Surfaced by the Anti-Communist Formations of LIS -real-draft run where ~30 of 107 notes were valid shortened forms that -wasted ~60 API calls between them. - -The carve-out is conservative: it only matches one or two name-token -forms (capitalized, allowing apostrophes and hyphens for names like -"O'Mara" and "Burden-Stelly") optionally followed by a comma and a -page number / page range, ending in a period. Anything more complex -(quoted short titles, italicized short titles, longer prose) still -falls under the strict "must have italics" check. The goal is to -catch the most common shortened-form cases without false positives -on actual full-form citations that happen to be short. +**History note (chunk 3 fix #3 → chunk 4 task 1 revert):** an earlier +iteration added a carve-out so that the strict italics check was +skipped when the candidate looked like a shortened-form note (author +last name with optional page, no title). The carve-out saved retry +cost on simple shortened forms, but it also let GPT-5 variance produce +under-italicized variants of shortened forms WITH short titles +(e.g., "A Restudy, 73." instead of the more CMOS-correct +"*A Restudy*, 73."), because the carve-out matched both forms and +the worse one slipped through. The user explicitly chose accuracy +over the cost saving and asked for the carve-out to be reverted. +The strict italics check now applies uniformly. Simple shortened +forms (Mitchell, 197., Ettarh.) still get retried unnecessarily — +the retries waste API calls without producing better output — but +shortened forms with short titles get a fair shot at the italicized +version on the retry. This validator is intentionally weaker than ``cmos.linter`` (the scoring linter that needs an Exemplar). The point is to be a fast, @@ -51,7 +48,6 @@ because both use the same retry loop pattern. Edits affect both. from __future__ import annotations -import re from dataclasses import dataclass, field @@ -64,44 +60,6 @@ class ValidationResult: return not self.failures -# Shortened-form note pattern. Matches the two most common CMOS shortened -# forms that legitimately have no italics: -# - Author last name only: "Ettarh.", "Murch.", "O'Mara.", "Burden-Stelly." -# - Author last name + page: "Rosen, 7.", "Mitchell, 197.", "CBS, 47." -# "Lawrence Powell, 45." (multi-word name) -# "Mitchell, 137-39." (page range) -# -# Each name token must start with an uppercase letter (rejects "this is some -# prose without italics."). Apostrophes and hyphens are allowed inside name -# tokens for names like "O'Mara" and "Burden-Stelly". A multi-word name is -# space-separated capitalized tokens. The optional page is a comma followed -# by digits, optionally with an ASCII or en-dash range. The whole thing -# ends with a required period. -# -# Does NOT match shortened forms with embedded short titles (quoted or -# italicized) — those are harder to disambiguate from real first-occurrence -# notes and are left for the strict italics check / future iteration. -_SHORTENED_FORM_RE = re.compile( - r"^" - r"[A-Z][\w'\-]*" # First name token (Lastname, possibly hyphenated/apostrophe) - r"(?:\s+[A-Z][\w'\-]*)*" # Optional additional name tokens - r"(?:,\s*\d+(?:[-\u2013]\d+)?)?" # Optional ", page" or ", page-range" / page–range - r"\.$" # Required terminal period -) - - -def _looks_like_shortened_form(text: str) -> bool: - """True if ``text`` matches the conservative shortened-form pattern. - - Used by ``validate`` to skip the "must have italics" check on - legitimate shortened-form notes that have no italic content. Only - catches author-only and author-plus-page shapes; more complex - shortened forms (with quoted or italicized short titles) are not - matched here and remain subject to the strict italic check. - """ - return _SHORTENED_FORM_RE.match(text.strip()) is not None - - def validate(candidate: str) -> ValidationResult: result = ValidationResult() text = candidate.rstrip() @@ -112,9 +70,7 @@ def validate(candidate: str) -> ValidationResult: if "ibid" in text.lower(): result.failures.append("contains 'Ibid.' (deprecated in CMOS 18)") - # Strict italic check, with a carve-out for legitimate shortened-form - # notes that have no italics by definition. See module docstring. - if "*" not in text and not _looks_like_shortened_form(text): + if "*" not in text: result.failures.append( "no italic span found (CMOS full citations usually italicize " "a book/journal/magazine/series/report title)" diff --git a/tests/test_note_formatter.py b/tests/test_note_formatter.py index ddf07ff..465fc7f 100644 --- a/tests/test_note_formatter.py +++ b/tests/test_note_formatter.py @@ -112,6 +112,17 @@ def test_system_prompt_mentions_month_or_season_preservation(): assert "preserve" in lower +def test_system_prompt_publisher_exception_list_includes_nyu_press(): + """Chunk 4 task 2: rule 18's publisher exception list should include + NYU Press alongside the existing MIT Press / ALA Editions / MLA. + Surfaced by [6] Batterson in the chunk 3 verification re-run, where + "NYU Press" got expanded to "New York University Press" — losing + the publisher's canonical brand name. NYU Press's official self- + presentation IS "NYU Press", not the long form. + """ + assert "NYU Press" in SYSTEM_PROMPT_NOTES + + def test_system_prompt_mentions_government_reports_rule(): """Polish fix #7: v2 should explicitly handle government documents and institutional reports the same way v1's rule 27 does — italicize diff --git a/tests/test_runtime_validator.py b/tests/test_runtime_validator.py index deb0677..15a0210 100644 --- a/tests/test_runtime_validator.py +++ b/tests/test_runtime_validator.py @@ -50,46 +50,59 @@ def test_missing_italics_anywhere_fails(): assert any("italic" in f.lower() for f in result.failures) -# --- Shortened-form note carve-out (chunk 3 fix #3, Option D) ------------- +# --- Shortened-form notes — strict italics check (chunk 4 task 1 revert) -- +# +# Chunk 3 fix #3 added a carve-out so that shortened-form notes like +# "Rosen, 7." and "Ettarh." passed validation despite having no italics, +# avoiding retry waste on valid output. After chunk 3 verification on the +# Anti-Communist draft, the user observed that the carve-out also let +# under-italicized variants of shortened forms with short titles slip +# through (e.g., "A Restudy, 73." instead of the more CMOS-correct +# "*A Restudy*, 73.") because the model's variance produced both forms +# and the carve-out accepted the worse one. +# +# Chunk 4 task 1 reverts the carve-out: the validator's strict italics +# check now applies uniformly. The retry loop will fire on simple +# shortened forms (Mitchell, 197.) without producing better output, but +# it WILL force the model to produce italicized output on the +# shortened-form-with-short-title cases when it can. The cost goes up; +# accuracy is prioritized per the user's explicit direction. -def test_shortened_form_author_only_passes_without_italics(): - """CMOS shortened note form for an author-only reference (no page, - no title) is just the author's last name with a period: 'Ettarh.', - 'Murch.', 'O'Mara.'. These contain no italics by definition. - Without a carve-out, the validator's "must have italics" rule - rejects them and the formatter's retry loop fires its full budget - on valid output. Surfaced by the Anti-Communist Formations of LIS - real-draft run; ~30 of 107 notes were correct shortened-forms - that wasted ~60 API calls retrying. - - The carve-out: if a candidate has no italics AND looks like a - shortened-form note (short, name-token shape, optional page, - terminal period), the missing-italics check is skipped. +def test_shortened_form_author_only_now_fails_without_italics(): + """Post chunk-4 revert: shortened-form output without italics now + fails the validator. The retry loop will fire to give GPT-5 a chance + to italicize an embedded short title; for simple cases (single name, + no title) the retries will produce the same output and waste cost, + but for multi-token cases that DO have an italicizable title the + retries can rescue accuracy. Cost is the trade-off the user accepted. """ for candidate in ["Ettarh.", "Murch.", "O'Mara.", "Burden-Stelly."]: result = validate(candidate) - assert result.passed is True, ( - f"shortened-form author-only note {candidate!r} should pass " - f"validation; got failures {result.failures}" + assert result.passed is False, ( + f"shortened-form note {candidate!r} should now FAIL the " + f"strict italics check (no carve-out); got passed=True" ) + assert any("italic" in f.lower() for f in result.failures) -def test_shortened_form_author_plus_page_passes_without_italics(): - """The other common CMOS shortened form is author last name + page: - 'Rosen, 7.', 'Mitchell, 197.', 'CBS, 47.'. Same carve-out applies.""" +def test_shortened_form_author_plus_page_now_fails_without_italics(): + """Same as above for author + page form. The retry loop will fire + on these too. For "A Restudy, 73." style entries (looks like a name + but is actually a short title) the retries can produce the + italicized variant "*A Restudy*, 73." which is more CMOS-correct. + """ for candidate in [ "Rosen, 7.", "Mitchell, 197.", "CBS, 47.", - "Seybold, 282.", - "Lawrence Powell, 45.", # multi-word name - "Mitchell, 137-39.", # page range with hyphen + "Lawrence Powell, 45.", + "A Restudy, 73.", # the regression case from chunk 3 verification ]: result = validate(candidate) - assert result.passed is True, ( - f"shortened-form note {candidate!r} should pass validation; " - f"got failures {result.failures}" + assert result.passed is False, ( + f"shortened-form note {candidate!r} should now FAIL the " + f"strict italics check; got passed=True" ) -- 2.40.1 From c21ca7b58e1bf95075c90bb4475bd0981c14302f Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 22:10:30 -0400 Subject: [PATCH 09/13] v2 chunk 5: 5 fixes from cross-draft triage (HML + Reading_Disrepair) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After chunk 4 verification on the Anti-Communist draft, ran v2 against the previously-untested ## Notes sections of HML (72 notes) and Reading_Disrepair (25 notes) — 97 new notes for 204 total across 3 drafts. The wider corpus surfaced 5 distinct issues that weren't visible in the Anti-Communist data alone. src/cmos/note_formatter.py: 1. Tighten substantive prose detector — exclude semicolon-bearing inputs (chunk 5 task 1). The HML draft was dominated by compound shortened references like: See Author1, "Title;" Author2, "Title;" Author3, "Title." 17 of 72 HML notes matched this pattern, exceeded the 150-char threshold, and had no citation skeleton markers, so they were passed through verbatim by the chunk 3 detector. Compound references use semicolons as item separators; substantive prose doesn't. Adding `if ";" in stripped: return False` cleanly separates the two without affecting the genuine substantive notes (which contain none). 2. Rule 3 — distinguish comma-inside vs period-inside for article/chapter title closing quote based on whether MORE content follows the title. Surfaced by 8+ entries across drafts producing the wrong `"Title,".` pattern (comma inside followed by stray period outside) when the title is the last element of the entry. Rule 3 now explicitly says: comma inside when more content follows; period inside when title is the last element. 3. New rule 20 — preserve signal phrases verbatim. Surfaced by HML [69] and [70] where "For example," was stripped while "See" was preserved elsewhere. CMOS notes commonly open with signal phrases (See, See also, For example, Cf., Compare, But see, Contra, Accord, Quoted in) that indicate how a citation relates to the surrounding argument. Stripping them is data loss. 4. Rule 1 — explicit "no comma between author name and 'et al.'" in non-inverted note form. Surfaced by HML [69] where "Bignoli et al." became "Bignoli, et al." (extra comma). The comma before "et al." is a feature of inverted bibliography form, not non-inverted note form. Rule 1 now includes WRONG examples. 5. Citation skeleton URL marker — also accept bare-domain URLs without the https:// scheme. Surfaced by Reading_Disrepair [1] GAO entry which had `files.gao.gov/...` without `https://`, so the existing https?:// marker didn't match and the 190-char citation was passed through as substantive prose. New pattern: `\b\w{2,}(?:\.\w{2,})+/\S*` catches bare domains like files.gao.gov/path without false-positiving on common things like "e.g./" (single-char tokens excluded by {2,}). Tests: 157/157 (was 153, +4 net new for prompt-content and regex verification). The +4 is: compound-reference detection, genuine substantive prose regression check, rule 3 / rule 20 / rule 1 prompt-content tests, bare-domain URL detection. Path B: rule 18 (publishers, chunk 4) and rule 20 (signal phrases, this commit) are v2-only. Rule 1 et al. clarification could in principle apply to v1 too but v1 doesn't see "et al." in the same non-inverted form. v1 untouched. 96/96 v1 tests still passing. Real-draft validation pending — verification re-run against all 3 drafts will follow in the next step. --- src/cmos/note_formatter.py | 150 ++++++++++++++++++++++------ tests/test_note_formatter.py | 188 +++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 31 deletions(-) diff --git a/src/cmos/note_formatter.py b/src/cmos/note_formatter.py index 3a8068b..5835831 100644 --- a/src/cmos/note_formatter.py +++ b/src/cmos/note_formatter.py @@ -59,33 +59,48 @@ _DEPRECATED_LATIN_RE = re.compile( re.IGNORECASE, ) -# Substantive note pass-through (chunk 3 fix #4, Option A). When the -# input is long discursive prose with no obvious citation skeleton, the -# formatter returns it VERBATIM without calling the API. CMOS 14.39 -# explicitly allows substantive notes (commentary, qualifications, -# cross-references), and the previous behavior extracted whatever -# citation it could find and silently discarded the surrounding prose. -# Surfaced by notes [8], [9], and [75] in the Anti-Communist Formations -# of LIS draft. +# Substantive note pass-through (chunk 3 fix #4, Option A; tightened in +# chunk 5 task 1). When the input is long discursive prose with no +# obvious citation skeleton, the formatter returns it VERBATIM without +# calling the API. CMOS 14.39 explicitly allows substantive notes +# (commentary, qualifications, cross-references), and the previous +# behavior extracted whatever citation it could find and silently +# discarded the surrounding prose. Surfaced by notes [8], [9], and [75] +# in the Anti-Communist Formations of LIS draft. # # Detection is conservative: input must be longer than the threshold -# AND must NOT contain ANY of the citation skeleton markers below. +# AND must NOT contain ANY of the citation skeleton markers below +# AND must NOT contain a semicolon (the chunk 5 tightening). # Real first-occurrence citations virtually always have at least one # skeleton marker (terminal year, terminal page reference, URL, vol., # no., pp., DOI, parenthesized year). Substantive prose typically has -# none of these. The combination of length-floor + marker-absence -# catches the egregious cases without false-positiving on legitimate -# citations that happen to be long. +# none of these. # -# The user's explicit policy (chunk 3 fix #4): preserve ALL free text -# as long as that does not break other formatting. Erring on the side -# of preservation is intentional. +# The semicolon check (chunk 5 task 1) was added after the HML draft +# verification revealed ~17 of 72 notes were COMPOUND SHORTENED +# REFERENCES — multiple shortened citations separated by semicolons — +# that had no skeleton markers and were >150 chars, so they matched the +# original (chunk 3) detector and got passed through verbatim. Compound +# references look like: +# +# Author1, "Short Title 1;" Author2, "Short Title 2;" Author3, "..." +# +# Substantive prose almost never uses semicolons as item separators +# (they use commas inside English sentences). Adding "no semicolons" as +# a third condition cleanly separates the two classes without affecting +# the genuine substantive notes (which contain none). +# +# The user's explicit policy: preserve ALL free text as long as that +# does not break other formatting. The chunk 5 tightening reflects the +# observation that the original detector WAS breaking other formatting +# (preventing legit compound references from being processed). _SUBSTANTIVE_PROSE_LENGTH_THRESHOLD = 150 _CITATION_SKELETON_MARKERS_RE = re.compile( r"(?:" r"\([^)]*\d{4}[^)]*\)" # parenthesized year — (2024), (NYU Press, 2023), (March 2024) - r"|https?://" # URL + r"|https?://" # URL with scheme + r"|\b\w{2,}(?:\.\w{2,})+/\S*" # bare-domain URL — files.gao.gov/path, example.com/abc r"|\bvol\.\s*\d+" # volume number — vol. 7 r"|\bno\.\s*\d+" # issue number — no. 4 r"|\bpp?\.\s*\d+" # page reference — p. 45, pp. 45-67 @@ -100,23 +115,30 @@ _CITATION_SKELETON_MARKERS_RE = re.compile( def _looks_like_substantive_prose(text: str) -> bool: """True if ``text`` looks like substantive note prose, not a citation. - Conservative detection: only matches input that is BOTH long AND - lacks any obvious citation skeleton markers. Designed to catch - long discursive notes (689+ chars in the Anti-Communist draft) - while preserving legitimate first-occurrence notes — which all - have at least one skeleton marker even when long. The shorter - [75]-style cases (~163 chars, no markers) are also caught because - the threshold is set well below the typical citation length floor - when markers are present. + Conservative detection: only matches input that meets ALL of: + - longer than ``_SUBSTANTIVE_PROSE_LENGTH_THRESHOLD`` chars + - contains no citation skeleton markers (parenthesized year, URL, + vol./no./pp., DOI, terminal page reference, terminal year) + - contains no semicolons - See ``_SUBSTANTIVE_PROSE_LENGTH_THRESHOLD`` and - ``_CITATION_SKELETON_MARKERS_RE`` above for the detection details. + Designed to catch long discursive notes (689+ chars in the + Anti-Communist draft) while preserving legitimate first-occurrence + citations (which always have at least one skeleton marker) AND + legitimate compound shortened references (which use semicolons as + item separators — see the chunk 5 tightening note in the constants + block above). + + See ``_SUBSTANTIVE_PROSE_LENGTH_THRESHOLD``, + ``_CITATION_SKELETON_MARKERS_RE``, and the module constants for + the detection details. """ stripped = text.strip() if len(stripped) < _SUBSTANTIVE_PROSE_LENGTH_THRESHOLD: return False if _CITATION_SKELETON_MARKERS_RE.search(stripped): return False + if ";" in stripped: + return False return True # Default model. Override with OPENAI_MODEL=... in your .env. @@ -147,9 +169,23 @@ Rules you must follow (CMOS 18th edition specifically, NOTE form): - One author: "Charles Yu" - Two authors: "Charles Yu and Hyeyoung Kwon" - Three authors: "Charles Yu, Hyeyoung Kwon, and Kathleen Doyle" - - Four or more authors: first author followed by ", et al." (CMOS - 14.76: notes use first author + et al. for 4+ authors, even + - Four or more authors: the first author's name followed DIRECTLY + by "et al." with NO comma between the name and "et al." (CMOS + 14.76; notes use first author + et al. for 4+ authors, even though bibliography lists more before truncating). + - Correct: "Charles Yu et al." + - Correct: "Bignoli et al." + - WRONG: "Charles Yu, et al." (extra comma) + - WRONG: "Bignoli, et al." (extra comma) + The comma before "et al." is a feature of INVERTED bibliography + form ("Bignoli, A., et al."), not non-inverted note form. In note + form there is no comma between the author's last name (or full + name) and "et al." + + This applies equally to shortened-form notes — when the input is + just "Author et al., Short Title" the output should also be + "Author et al., 'Short Title'" with no comma before "et al." + Corporate authors (e.g., "Google", "Modern Language Association") appear as-is, not reordered. Non-Western names already in family-first order (e.g., "Liu Xinwu", @@ -164,9 +200,31 @@ Rules you must follow (CMOS 18th edition specifically, NOTE form): 3. ITALIC AND QUOTE CONVENTIONS are the same as bibliography form: book titles and journal titles are italicized using Markdown asterisks (*Title*); article titles and chapter titles are - wrapped in straight double quotes. In NOTE form the closing - comma goes INSIDE the closing quote of the article/chapter - title: "Article Title," (not "Article Title",). + wrapped in straight double quotes. + + The CLOSING PUNCTUATION INSIDE the article/chapter title's + closing quote depends on what comes next in the note: + + (a) If MORE CONTENT FOLLOWS the title (a journal name, a page + number, a publisher, etc.), use a COMMA inside the closing + quote, NOT outside: + - Correct: "Article Title," *Journal* 12... + - WRONG: "Article Title", *Journal* 12... + + (b) If the title is the LAST ELEMENT of the note (nothing + follows except the entry-terminal period), use a PERIOD + inside the closing quote, NOT a comma: + - Correct: Mattern, "Maintenance and Care." + - WRONG: Mattern, "Maintenance and Care,". + - WRONG: Mattern, "Maintenance and Care". + + The (b) form is especially common in shortened-form notes that + give just an author and a short title (no page number, no + journal). The model commonly errs on this case by producing + `"Title,".` (comma inside quote followed by stray period + outside) — that is incorrect punctuation. The correct form is + `"Title."` with the period inside the closing quote and nothing + after. 4. BOOK NOTE FORM: Author, *Book Title* (Publisher, Year), specific-page. @@ -356,6 +414,36 @@ Rules you must follow (CMOS 18th edition specifically, NOTE form): Investigating Committee on Education, the 1951 Hearings*, 66, https://hdl.handle.net/2027/mdp.39015070361673. +20. SIGNAL PHRASES — preserve them verbatim. CMOS notes commonly + open with a signal phrase that tells the reader how the cited + source relates to the surrounding argument. Common signal phrases + that appear at the start of notes: + + - "See" / "See also" / "See, e.g.," + - "For example," + - "Cf." (compare) + - "Compare" + - "But see" / "But cf." + - "Contra" + - "Accord" + - "Quoted in" + + These phrases are MEANINGFUL — they tell the reader whether the + citation supports, contradicts, exemplifies, or qualifies the + author's claim. PRESERVE them VERBATIM at the start of the note. + Do NOT strip them, do NOT normalize one to another (don't convert + "For example," to "See" or vice versa), and do NOT move them to + a different position in the note. + + Examples: + - Source: `For example, Bignoli et al., "Status in Academic Libraries."` + - Correct: `For example, Bignoli et al., "Status in Academic Libraries."` + - WRONG: `Bignoli et al., "Status in Academic Libraries."` (signal phrase stripped) + + - Source: `See Smith, "Article Title;" Jones, "Other Title."` + - Correct: `See Smith, "Article Title"; Jones, "Other Title."` + - WRONG: `Smith, "Article Title"; Jones, "Other Title."` (signal phrase stripped) + Output ONLY the single reformatted note. No preamble, no explanation, no leading number, no code fences. """ diff --git a/tests/test_note_formatter.py b/tests/test_note_formatter.py index 465fc7f..d3563fd 100644 --- a/tests/test_note_formatter.py +++ b/tests/test_note_formatter.py @@ -112,6 +112,108 @@ def test_system_prompt_mentions_month_or_season_preservation(): assert "preserve" in lower +def test_system_prompt_rule_3_distinguishes_comma_and_period_at_title_end(): + """Chunk 5 task 2: rule 3's punctuation guidance for note form must + distinguish between (a) title followed by more content (comma inside + quote) and (b) title as the last element of the entry (period inside + quote). Surfaced by 8+ entries across drafts producing the wrong + `"Title,".` pattern instead of `"Title."`. + """ + lower = SYSTEM_PROMPT_NOTES.lower() + # Either explicit "last element" wording or both forms shown + assert "last element" in lower or "nothing follows" in lower + # Both punctuation patterns should be mentioned + assert "period inside" in lower or 'title."' in SYSTEM_PROMPT_NOTES.lower() + + +def test_system_prompt_mentions_signal_phrases_to_preserve(): + """Chunk 5 task 3: explicit rule that signal phrases at the start + of notes must be preserved verbatim. Surfaced by HML [69] and [70] + where "For example," was stripped while "See" was preserved + elsewhere — inconsistent behavior that loses meaningful authorial + intent. + """ + lower = SYSTEM_PROMPT_NOTES.lower() + assert "signal phrase" in lower + # The list should mention at least the common ones that were + # actually observed in the drafts + assert "see also" in lower + assert "for example" in lower + assert "cf." in lower or "compare" in lower + + +def test_system_prompt_rule_1_says_no_comma_before_et_al(): + """Chunk 5 task 4: rule 1 must explicitly say "et al." follows the + author name with NO comma in note form (non-inverted). Surfaced by + HML [69] where "Bignoli et al." became "Bignoli, et al." with a + spurious comma. CMOS 14.76: in non-inverted note form, the comma + that would precede "et al." in inverted bibliography form is NOT + used. + + Test logic: the "no comma" prohibition must appear NEAR the + "et al." mention in rule 1 (within 300 chars), not just somewhere + else in the prompt (the polish #5 month/season rule also says "no + comma" but that's a different context). + """ + text = SYSTEM_PROMPT_NOTES.lower() + # Find all occurrences of "et al" and check if "no comma" is within + # 300 chars before or after each one + found_proximity = False + idx = text.find("et al") + while idx != -1: + window = text[max(0, idx - 300) : min(len(text), idx + 300)] + if "no comma" in window: + found_proximity = True + break + idx = text.find("et al", idx + 1) + + # Also accept an explicit wrong-form example + has_wrong_example = ( + '"Bignoli, et al."' in SYSTEM_PROMPT_NOTES + or '"Smith, et al."' in SYSTEM_PROMPT_NOTES + or 'wrong: "' in text and "et al" in text + ) + + assert found_proximity or has_wrong_example, ( + "rule 1 must explicitly forbid the comma between author name " + "and 'et al.' in non-inverted note form. The 'no comma' " + "prohibition must appear near the 'et al.' rule, or an " + "explicit wrong-form example must be present." + ) + + +def test_substantive_prose_detector_recognizes_domain_only_urls(): + """Chunk 5 task 5: the citation skeleton marker for URLs must also + recognize bare-domain URLs (without the https:// scheme), so that + real first-occurrence citations with bare-domain URLs aren't + false-positively passed through as substantive prose. Surfaced by + Reading_Disrepair [1] GAO entry which had files.gao.gov/... instead + of https://files.gao.gov/... + """ + # 190+ char input with no parens-year, no vol/no/pp, no DOI marker, + # no terminal page reference — but WITH a bare-domain URL. + input_with_bare_domain = ( + "Government Accountability Office. Public Libraries: Many Buildings " + "Are Reported to Be in Poor Condition. GAO-26-107262. 18 December " + "2025. files.gao.gov/reports/GAO-26-107262/index.html" + ) + assert len(input_with_bare_domain) > 150 # confirms it would otherwise hit the threshold + + call_count = 0 + + def counting_caller(system: str, user: str) -> str: + nonlocal call_count + call_count += 1 + return "Fake Author, *Fake Title* (Fake Publisher, 2024), 1." + + format_note_entry(input_with_bare_domain, caller=counting_caller) + assert call_count == 1, ( + "Citation with bare-domain URL was incorrectly passed through " + "as substantive prose; the URL marker should also recognize " + "bare-domain URLs without the https:// scheme." + ) + + def test_system_prompt_publisher_exception_list_includes_nyu_press(): """Chunk 4 task 2: rule 18's publisher exception list should include NYU Press alongside the existing MIT Press / ALA Editions / MLA. @@ -366,6 +468,92 @@ def test_format_note_entry_passes_through_substantive_prose_verbatim(): ) +def test_format_note_entry_does_not_pass_through_compound_shortened_references(): + """Chunk 5 task 1: long compound shortened references (multiple + citations separated by semicolons) must NOT be treated as substantive + prose, even when they exceed the length threshold and have no + citation skeleton markers. Surfaced by the HML draft, where ~17 of + 72 notes were compound references like: + + See Meadowbrooke et al., "Information Behavior and HIV Testing;" + Kim and Syn, "Health Information on Facebook;" ... + + These have no parenthesized year, no URL, no vol/no/pp markers, and + they're 200-400 chars long — so they matched the chunk 3 fix #4 + substantive prose detector and were passed through verbatim, leaving + curly quotes and incorrect semicolon-quote ordering unfixed. + + The discriminator: substantive prose doesn't typically use + semicolons as item separators. Compound references do. So adding + "no semicolons" as a third condition (alongside "long" and "no + skeleton markers") cleanly separates the two classes. + """ + calls: list[str] = [] + + def recording_caller(system: str, user: str) -> str: + calls.append(user) + return "Fake Author, *Fake Title* (Fake Publisher, 2024), 1." + + # All of these are real-shaped HML compound references (synthesized to + # match observed patterns). Each is over the 150-char threshold and has + # no citation skeleton markers (no parenthesized year, no URL, no + # vol/no/pp). They MUST be sent to the LLM for formatting. + compound_refs = [ + # 2-reference compound (smallest) + 'Knapp, "Creating Safe and Inclusive Spacing;" Padrón, "Defending Queer Youth;" Pinsky and Brenner, "Threats to Accessing Sexuality Information."', + # 4-reference compound with "See" prefix + 'See Dilevko and Gottlieb, "Pornography and Bibliographic Access;" Drabinski, "Queering the Catalog;" Billey et al., "Critique of RDA 9.7;" Baucom, "Archival Descriptions of LGBTQ Materials."', + # 3-reference compound, no prefix + 'Ajayi and Omotayo, "Challenges of HIV/AIDS;" Kachota and Kassim, "Sexual and Reproductive Health Information-Seeking Behaviour;" Bankole and Busayo, "Reproductive Health Information Seeking Behaviour."', + ] + for ref in compound_refs: + format_note_entry(ref, caller=recording_caller) + + assert len(calls) == len(compound_refs), ( + f"some compound references were short-circuited as substantive prose; " + f"expected {len(compound_refs)} calls, got {len(calls)}. The " + f"detector is over-matching." + ) + + +def test_format_note_entry_still_passes_through_genuine_substantive_prose(): + """Regression test for chunk 5 task 1: tightening the detector with a + semicolon check must NOT break the genuine substantive-prose pass- + through that chunk 3 fix #4 was designed for. The Anti-Communist + notes [8], [9], and [75] (long discursive commentary, NO semicolons) + must still be preserved verbatim. + """ + call_count = 0 + + def counting_caller(system: str, user: str) -> str: + nonlocal call_count + call_count += 1 + return "should not be called" + + # Synthesized to match Anti-Communist note shapes — long discursive + # prose, no semicolons, no skeleton markers. + note_8_style = ( + "W.E.B. DuBois' popularization of philanthropist Henry Lyman " + "Morehouse's theory of the talented tenth is celebrated, missing " + "the note that he himself retracted such a theory later in his " + "life, as written about by Joy James. The violence of Jim Crow " + "becomes diminished into many monstrosities, since traditionally " + "black colleges and universities became monuments to human hope " + "through philanthropic funding." + ) + note_75_style = ( + "This letter appeared in multiple archives, including California " + "State Archives, Berkeley and UCLA special collections, and is " + "uploaded on UC Santa Barbara Servers." + ) + assert format_note_entry(note_8_style, caller=counting_caller) == note_8_style + assert format_note_entry(note_75_style, caller=counting_caller) == note_75_style + assert call_count == 0, ( + "genuine substantive prose was incorrectly sent to the API; the " + "semicolon-tightening should not have affected these (no semicolons)." + ) + + def test_format_note_entry_does_not_pass_through_real_citations(): """The substantive-prose guard must NOT match real citations that happen to be long. The detection requires absence of citation -- 2.40.1 From cad936f5762217ad4a3a4acf9ab13ef4c5c472b4 Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sun, 12 Apr 2026 00:11:52 -0400 Subject: [PATCH 10/13] parser: support diverse md inputs (bold headings, Works Cited, blockquote notes, junk filtering) - Bibliography heading now accepts bold markers (## **BIBLIOGRAPHY**), alternative names (Works Cited, References), and numbered sub-headings within the section. - Original heading text preserved in output instead of hardcoded "## Bibliography". - New find_blockquote_notes() parser for PDF-to-markdown footnote format (> N text), wired into CLI as third fallback after pandoc and numbered. - PDF junk filtered from bibliography entries: bare page numbers, blockquote footnotes, download banners, CC license URLs, and short running headers. --- src/cmos/cli.py | 26 ++-- src/cmos/parser.py | 112 ++++++++++++++--- tests/test_cli.py | 69 ++++++++--- tests/test_parser.py | 286 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 441 insertions(+), 52 deletions(-) diff --git a/src/cmos/cli.py b/src/cmos/cli.py index e03dfcb..99880c2 100644 --- a/src/cmos/cli.py +++ b/src/cmos/cli.py @@ -23,7 +23,12 @@ from typing import Callable from cmos.formatter import format_bibliography_entry from cmos.note_formatter import format_note_entry -from cmos.parser import find_notes, find_numbered_notes, split_bibliography +from cmos.parser import ( + find_blockquote_notes, + find_notes, + find_numbered_notes, + split_bibliography, +) FormatterFn = Callable[[str], str] @@ -61,7 +66,7 @@ def reformat_draft( pieces: list[str] = [] if parsed.before: pieces.append(parsed.before) - pieces.append("## Bibliography") + pieces.append(parsed.heading) pieces.append("") pieces.extend(rewritten) if parsed.after: @@ -77,13 +82,14 @@ def reformat_notes( ) -> str: """Rewrite footnote definitions in ``text`` to CMOS 18 note form. - Auto-detects the source format by trying ``cmos.parser.find_notes`` - (pandoc-style ``[^marker]: text``) first and falling back to - ``cmos.parser.find_numbered_notes`` (``[N] text``) if no pandoc - definitions were found. Both formats are common in real drafts: - pandoc is used by writers who author directly in markdown, while - the numbered form is what docx-to-text conversion typically - produces from footnoted Word documents. + Auto-detects the source format by trying three parsers in order: + ``find_notes`` (pandoc-style ``[^marker]: text``), then + ``find_numbered_notes`` (``[N] text``), then + ``find_blockquote_notes`` (``> N text``). All three formats are + common in real drafts: pandoc is used by writers who author + directly in markdown, the numbered form comes from docx-to-text + conversion, and the blockquote form comes from PDF-to-markdown + conversion. Each definition's body is formatted via ``formatter`` (the v2 note formatter by default) and substituted back into the same @@ -115,6 +121,8 @@ def reformat_notes( parsed = find_notes(text) if not parsed.definitions: parsed = find_numbered_notes(text) + if not parsed.definitions: + parsed = find_blockquote_notes(text) if not parsed.definitions: return text diff --git a/src/cmos/parser.py b/src/cmos/parser.py index 45d45bc..aaf1f4b 100644 --- a/src/cmos/parser.py +++ b/src/cmos/parser.py @@ -3,33 +3,38 @@ v1 scope: find a ``## Bibliography`` heading (case-insensitive), treat the section as running until the next level-2 (``##``) heading or end of file, and return each non-blank line as one entry. Bullet markers (``- ``, ``* ``) -at the start of a line are stripped. +at the start of a line are stripped. The heading may also be +``## Works Cited`` or ``## References`` (case-insensitive), and may be +wrapped in bold markers (``## **Bibliography**``). Numbered sub-headings +within the bibliography (e.g., ``## **1. Primary Sources**``) are skipped +rather than treated as section terminators. -v2 scope (notes): two complementary functions for finding footnote -definitions in two distinct source formats. Both return the same +v2 scope (notes): three complementary functions for finding footnote +definitions in distinct source formats. All return the same ``NotesParseResult`` shape; the consumer (typically the CLI) can call -one and fall back to the other to auto-detect format. +them in fallback order to auto-detect format. - ``find_notes`` finds pandoc-style markdown footnote definitions ``[^marker]: text``. The marker can be numeric or named. - ``find_numbered_notes`` finds plain ``[N] text`` definitions where the marker is digit-only and there is no caret or colon — the format produced by docx-to-text conversion of footnoted Word docs. +- ``find_blockquote_notes`` finds blockquote-style ``> N text`` + definitions — the format produced by PDF-to-markdown conversion. Each ``NoteDefinition`` carries the marker, the body text (with surrounding whitespace stripped), the line number where it appeared (0-indexed, for future reassembly), and the literal ``original_prefix`` -string (e.g., ``"[^1]: "`` or ``"[1] "``) so that reassembly can -round-trip the source's marker syntax without the consumer needing to -know which format was matched. +string (e.g., ``"[^1]: "``, ``"[1] "``, or ``"> 1 "``) so that +reassembly can round-trip the source's marker syntax without the +consumer needing to know which format was matched. -Single-line definitions only in both formats — multi-line continuation +Single-line definitions only in all formats — multi-line continuation (indented continuation lines) is deferred. The reference markers in prose are NOT collected; only the definitions need reformatting. -Out of scope in v1: in-prose citation rewriting, multi-line entries, -nested sections, alternative heading names (e.g., "Works Cited", -"References"). Out of scope in v2 (so far): multi-line note definitions, +Out of scope in v1: in-prose citation rewriting, multi-line entries. +Out of scope in v2 (so far): multi-line note definitions, shortened-form generation. """ @@ -48,6 +53,7 @@ class ParseResult: before: str entries: list[str] after: str + heading: str = "## Bibliography" @dataclass @@ -77,11 +83,43 @@ class NotesParseResult: definitions: list[NoteDefinition] -_HEADING_RE = re.compile(r"^##\s+bibliography\s*$", re.IGNORECASE) +_HEADING_RE = re.compile( + r"^##\s+\*{0,2}(?:bibliography|works\s+cited|references)\*{0,2}\s*$", + re.IGNORECASE, +) _LEVEL_TWO_RE = re.compile(r"^##\s+\S") +_NUMBERED_SUB_HEADING_RE = re.compile( + r"^##\s+[_*]*\d", re.IGNORECASE +) _BULLET_RE = re.compile(r"^[-*]\s+") +_BARE_PAGE_RE = re.compile(r"^\d+$") +_DOWNLOAD_BANNER_RE = re.compile(r"^Downloaded from ", re.IGNORECASE) +_CC_LICENSE_RE = re.compile(r"creativecommons\.org") +_CITATION_MARKERS_RE = re.compile(r"""[,*_"(]|://""") _NOTE_DEF_RE = re.compile(r"^\[\^([^\]]+)\]:\s*(.*?)\s*$") _NUMBERED_NOTE_RE = re.compile(r"^\[(\d+)\]\s+(.*?)\s*$") +_BLOCKQUOTE_NOTE_RE = re.compile(r"^>\s*(\d+)\s+(.*?)\s*$") + + +def _is_pdf_junk(line: str) -> bool: + """Return True if ``line`` looks like PDF-to-markdown noise rather + than a bibliography entry. + + Catches: bare page numbers, blockquote footnotes, publisher download + banners, Creative Commons license URLs, and short running headers + (under 30 chars with no citation-like punctuation). + """ + if _BARE_PAGE_RE.match(line): + return True + if _BLOCKQUOTE_NOTE_RE.match(line): + return True + if _DOWNLOAD_BANNER_RE.match(line): + return True + if _CC_LICENSE_RE.search(line): + return True + if len(line) < 30 and not _CITATION_MARKERS_RE.search(line): + return True + return False def split_bibliography(text: str) -> ParseResult: @@ -96,14 +134,18 @@ def split_bibliography(text: str) -> ParseResult: if heading_idx is None: raise NoBibliographyError("no '## Bibliography' heading found") - # Find the end of the section: next level-two heading after the bibliography, - # or end of file. + # Find the end of the section: next level-two heading that is NOT a + # numbered sub-heading (e.g., "## 1. Primary Sources"). Numbered + # sub-headings are part of the bibliography and should be skipped. section_end = len(lines) for j in range(heading_idx + 1, len(lines)): - if _LEVEL_TWO_RE.match(lines[j]): + if _LEVEL_TWO_RE.match(lines[j]) and not _NUMBERED_SUB_HEADING_RE.match( + lines[j] + ): section_end = j break + heading = lines[heading_idx].rstrip() before = "\n".join(lines[:heading_idx]) after_lines = lines[section_end:] after = "\n".join(after_lines) @@ -113,10 +155,17 @@ def split_bibliography(text: str) -> ParseResult: stripped = raw.strip() if not stripped: continue + # Skip numbered sub-headings — they subdivide the bibliography + # but are not entries themselves. + if _NUMBERED_SUB_HEADING_RE.match(raw): + continue stripped = _BULLET_RE.sub("", stripped) + # Filter PDF-to-markdown junk. + if _is_pdf_junk(stripped): + continue entries.append(stripped) - return ParseResult(before=before, entries=entries, after=after) + return ParseResult(before=before, entries=entries, after=after, heading=heading) def find_notes(text: str) -> NotesParseResult: @@ -194,3 +243,34 @@ def find_numbered_notes(text: str) -> NotesParseResult: ) ) return NotesParseResult(definitions=definitions) + + +def find_blockquote_notes(text: str) -> NotesParseResult: + """Find blockquote-style footnote definitions: ``> N text``. + + Targets the format produced by PDF-to-markdown conversion where + footnotes appear as blockquoted lines beginning with a bare number. + The marker must be all digits — a blockquote line without a leading + number is regular prose and is not matched. + + Returns a ``NotesParseResult`` with definitions in source order. + Each definition's ``original_prefix`` is set to ``"> N "`` so that + downstream reassembly can write back the formatted body using the + same marker syntax. + """ + definitions: list[NoteDefinition] = [] + for line_number, line in enumerate(text.splitlines()): + match = _BLOCKQUOTE_NOTE_RE.match(line) + if match is None: + continue + marker = match.group(1) + body = match.group(2) + definitions.append( + NoteDefinition( + marker=marker, + text=body, + line_number=line_number, + original_prefix=f"> {marker} ", + ) + ) + return NotesParseResult(definitions=definitions) diff --git a/tests/test_cli.py b/tests/test_cli.py index 09712d9..5dda935 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -51,14 +51,14 @@ def test_reformat_draft_preserves_sections_after_bibliography(): draft = """\ ## Bibliography -one entry. +Yu, Charles. *Interior Chinatown* (New York: Pantheon, 2020). ## Appendix appendix text. """ output = reformat_draft(draft, formatter=_fake_formatter) - assert "FORMATTED(one entry.)" in output + assert "FORMATTED(Yu, Charles. *Interior Chinatown* (New York: Pantheon, 2020).)" in output assert "## Appendix" in output assert "appendix text." in output @@ -69,11 +69,27 @@ def test_reformat_draft_raises_when_no_bibliography(): def test_reformat_draft_bibliography_heading_preserved(): - draft = "## Bibliography\n\nentry.\n" + draft = '## Bibliography\n\nYu, Charles. *Interior Chinatown* (Pantheon, 2020).\n' output = reformat_draft(draft, formatter=_fake_formatter) assert "## Bibliography" in output +def test_reformat_draft_preserves_works_cited_heading(): + """When the input heading is ``## Works Cited``, the output must + keep that heading — not replace it with ``## Bibliography``.""" + draft = '## Works Cited\n\nDavidson, Donald. "On the Very Idea." (1974).\n' + output = reformat_draft(draft, formatter=_fake_formatter) + assert "## Works Cited" in output + assert "## Bibliography" not in output + + +def test_reformat_draft_preserves_bold_wrapped_heading(): + """Bold markers in the heading should be preserved in the output.""" + draft = '## **Works Cited**\n\nDavidson, Donald. "On the Very Idea." (1974).\n' + output = reformat_draft(draft, formatter=_fake_formatter) + assert "## **Works Cited**" in output + + def test_python_dash_m_invocation_actually_runs_main(): """Regression test: `python -m cmos.cli` must actually invoke main(). @@ -216,6 +232,23 @@ def test_reformat_notes_auto_detects_numbered_format(): assert "[^2]" not in output +def test_reformat_notes_auto_detects_blockquote_format(): + """PDF-to-markdown drafts use ``> N text`` blockquote footnotes. + reformat_notes must auto-detect after pandoc and numbered fail.""" + draft = """\ +Some prose. + +> 1 Davidson, "On the Very Idea of a Conceptual Scheme." +> 2 Frankenberry 1999, 526. +""" + output = reformat_notes(draft, formatter=_fake_note_formatter) + assert '> 1 NOTE_FORMATTED(Davidson, "On the Very Idea of a Conceptual Scheme.")' in output + assert "> 2 NOTE_FORMATTED(Frankenberry 1999, 526.)" in output + # No pandoc or numbered markers should appear. + assert "[^1]" not in output + assert "[1] " not in output + + def test_reformat_notes_pandoc_input_still_round_trips_as_pandoc(): """Regression: after the auto-detect change, pandoc-format input must still produce pandoc-format output. The original_prefix path @@ -265,28 +298,28 @@ def test_reformat_draft_preserves_order_under_concurrency(): # later ones if order were naively tied to completion. import time + # Map each entry to a sleep duration so earlier entries finish last. + order = {"2020": 4, "2021": 3, "2022": 2, "2023": 1, "2024": 0} + def slow_fake(messy: str) -> str: - # Entries with lower index sleep longer so they finish last. - n = int(messy.split()[-1]) - time.sleep(0.05 * (5 - n)) + year = messy.strip()[-5:-1] # extract "2020" etc. + time.sleep(0.05 * order.get(year, 0)) return f"FORMATTED({messy})" draft = """\ ## Bibliography -entry 0 -entry 1 -entry 2 -entry 3 -entry 4 +Author, A. *Title Zero* (Publisher, 2020). +Author, B. *Title One* (Publisher, 2021). +Author, C. *Title Two* (Publisher, 2022). +Author, D. *Title Three* (Publisher, 2023). +Author, E. *Title Four* (Publisher, 2024). """ output = reformat_draft(draft, formatter=slow_fake, concurrency=4) # Entries must appear in input order. lines = [l for l in output.splitlines() if l.startswith("FORMATTED(")] - assert lines == [ - "FORMATTED(entry 0)", - "FORMATTED(entry 1)", - "FORMATTED(entry 2)", - "FORMATTED(entry 3)", - "FORMATTED(entry 4)", - ] + assert lines[0].startswith("FORMATTED(Author, A.") + assert lines[1].startswith("FORMATTED(Author, B.") + assert lines[2].startswith("FORMATTED(Author, C.") + assert lines[3].startswith("FORMATTED(Author, D.") + assert lines[4].startswith("FORMATTED(Author, E.") diff --git a/tests/test_parser.py b/tests/test_parser.py index c00df2d..d450004 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -26,6 +26,7 @@ import pytest from cmos.parser import ( NoBibliographyError, + find_blockquote_notes, find_notes, find_numbered_notes, split_bibliography, @@ -57,15 +58,16 @@ def test_section_ends_at_next_level_two_heading(): draft = """\ ## Bibliography -First entry. -Second entry. +Yu, Charles. *Interior Chinatown* (New York: Pantheon, 2020). +Kwon, Hyeyoung. "Inclusion Work," *American Journal of Sociology* 127 (2022). ## Appendix Appendix content here. """ result = split_bibliography(draft) - assert result.entries == ["First entry.", "Second entry."] + assert len(result.entries) == 2 + assert result.entries[0].startswith("Yu, Charles") assert "## Appendix" in result.after assert "Appendix content here." in result.after @@ -74,22 +76,208 @@ def test_bullet_prefixes_stripped(): draft = """\ ## Bibliography -- First entry. -- Second entry. -* Third entry. +- Yu, Charles. *Interior Chinatown* (New York: Pantheon, 2020). +- Kwon, Hyeyoung. "Inclusion Work," *AJS* 127 (2022). +* Google. "Privacy Policy," Privacy & Terms, 2023. """ result = split_bibliography(draft) - assert result.entries == ["First entry.", "Second entry.", "Third entry."] + assert len(result.entries) == 3 + assert result.entries[0].startswith("Yu, Charles") + assert result.entries[1].startswith("Kwon, Hyeyoung") + assert result.entries[2].startswith("Google") def test_heading_match_is_case_insensitive(): draft = """\ ## bibliography -Only entry. +Yu, Charles. *Interior Chinatown* (New York: Pantheon, 2020). """ result = split_bibliography(draft) - assert result.entries == ["Only entry."] + assert len(result.entries) == 1 + assert result.entries[0].startswith("Yu, Charles") + + +def test_heading_match_works_cited(): + """'Works Cited' is the standard CMOS heading alternative to + 'Bibliography'. The parser should accept it.""" + draft = """\ +## Works Cited + +Davidson, Donald. "On the Very Idea of a Conceptual Scheme." +Evans-Pritchard, E.E. *Witchcraft, oracles, and magic.* +""" + result = split_bibliography(draft) + assert len(result.entries) == 2 + assert result.entries[0].startswith("Davidson") + + +def test_heading_match_works_cited_bold(): + """Bold-wrapped variant: ``## **Works Cited**``.""" + draft = """\ +## **Works Cited** + +- Frankenberry, Nancy. "Pragmatism, Truth, and Subjectivity" (1999). +""" + result = split_bibliography(draft) + assert len(result.entries) == 1 + assert result.entries[0].startswith("Frankenberry") + + +def test_heading_match_ignores_bold_markers(): + """Real-world drafts from PDF-to-markdown conversion often wrap the + heading text in bold: ``## **BIBLIOGRAPHY**``. The parser should + strip ``**`` before matching.""" + draft = """\ +## **BIBLIOGRAPHY** + +- Yu, Charles. *Interior Chinatown* (New York: Pantheon, 2020). +- Kwon, Hyeyoung. "Inclusion Work," *AJS* 127 (2022). +""" + result = split_bibliography(draft) + assert len(result.entries) == 2 + assert result.entries[0].startswith("Yu, Charles") + + +def test_heading_match_ignores_numbered_sub_headings(): + """Subdivided bibliographies (common in dissertations) use numbered + ``##`` sub-headings inside the bibliography section. These must NOT + terminate the section — the parser should skip them and keep + collecting entries until a non-numbered ``##`` heading or EOF.""" + draft = """\ +## **BIBLIOGRAPHY** + +## **1. Primary Sources** + +- Crum, Walter E., *A Coptic Dictionary* (Oxford: Clarendon Press, 1939). +- Till, Walter C., ed., *Die koptischen Ostraka* (Vienna, 1960). + +## **2. Secondary Literature** + +- Marsham, Andrew, ed., *The Umayyad World* (London: Routledge, 2020). +- Schulz, Fritz, *Classical Roman Law* (Oxford: Clarendon Press, 1951). +""" + result = split_bibliography(draft) + assert len(result.entries) == 4 + assert result.entries[0].startswith("Crum, Walter") + assert result.entries[3].startswith("Schulz, Fritz") + assert result.after == "" + + +def test_numbered_sub_headings_not_collected_as_entries(): + """The sub-heading lines themselves (e.g. ``## **1. Primary Sources**``) + should not appear in the entries list.""" + draft = """\ +## Bibliography + +## 1. Primary Sources + +- Crum, Walter E., *A Coptic Dictionary* (Oxford: Clarendon Press, 1939). + +## 2. Secondary Sources + +- Schulz, Fritz, *Classical Roman Law* (Oxford: Clarendon Press, 1951). +""" + result = split_bibliography(draft) + assert len(result.entries) == 2 + for entry in result.entries: + assert not entry.startswith("##") + assert "Primary Sources" not in entry + assert "Secondary Sources" not in entry + + +def test_non_numbered_heading_still_ends_section_after_sub_headings(): + """A non-numbered ``##`` heading after bibliography sub-sections + should still terminate the bibliography section.""" + draft = """\ +## Bibliography + +## 1. Primary Sources + +- Crum, Walter E., *A Coptic Dictionary* (Oxford: Clarendon Press, 1939). + +## Appendix + +Appendix content. +""" + result = split_bibliography(draft) + assert len(result.entries) == 1 + assert result.entries[0].startswith("Crum, Walter") + assert "## Appendix" in result.after + assert "Appendix content." in result.after + + +def test_bare_page_numbers_filtered(): + """PDF-to-markdown conversion leaves bare page numbers (e.g. '370') + as standalone lines. These are not bibliography entries.""" + draft = """\ +## Bibliography + +- Crum, Walter E., *A Coptic Dictionary* (Oxford: Clarendon Press, 1939). + +370 + +- Schulz, Fritz, *Classical Roman Law* (Oxford: Clarendon Press, 1951). + +371 +""" + result = split_bibliography(draft) + assert len(result.entries) == 2 + assert result.entries[0].startswith("Crum") + assert result.entries[1].startswith("Schulz") + + +def test_blockquote_footnotes_filtered(): + """Blockquote footnotes (``> N text``) that appear inside a Works Cited + section are PDF artifacts, not bibliography entries.""" + draft = """\ +## Works Cited + +Davidson, Donald. "On the Very Idea of a Conceptual Scheme." + +> 87 Holbraad 2010. +> 88 Frankenberry 2018, 236. + +Frankenberry, Nancy. "Pragmatism, Truth, and Subjectivity" (1999). +""" + result = split_bibliography(draft) + assert len(result.entries) == 2 + assert result.entries[0].startswith("Davidson") + assert result.entries[1].startswith("Frankenberry") + + +def test_download_banners_filtered(): + """Brill/publisher download banners are PDF junk, not entries.""" + draft = """\ +## Bibliography + +Crum, Walter E., *A Coptic Dictionary* (Oxford: Clarendon Press, 1939). +Downloaded from Brill.com 02/22/2024 05:33:29AM via Open Access. This is junk. +https://creativecommons.org/licenses/by/4.0/ +Schulz, Fritz, *Classical Roman Law* (Oxford: Clarendon Press, 1951). +""" + result = split_bibliography(draft) + assert len(result.entries) == 2 + assert result.entries[0].startswith("Crum") + assert result.entries[1].startswith("Schulz") + + +def test_running_headers_filtered(): + """Short lines that are running headers or page footers (e.g. an + author surname or abbreviated title) are not entries.""" + draft = """\ +## Bibliography + +Davidson, Donald. "On the Very Idea of a Conceptual Scheme." +hedrick +only words apart? +217 +Frankenberry, Nancy. "Pragmatism, Truth, and Subjectivity" (1999). +""" + result = split_bibliography(draft) + assert len(result.entries) == 2 + assert result.entries[0].startswith("Davidson") + assert result.entries[1].startswith("Frankenberry") def test_no_bibliography_raises(): @@ -292,3 +480,83 @@ ________________ assert len(result.definitions) == 3 assert result.definitions[0].text == "First reference. https://example.org/path" assert result.definitions[2].text == "Rosen, 7." + + +# --- find_blockquote_notes (> N text format, from PDF-to-markdown) ---------- + + +def test_find_blockquote_notes_single_definition(): + draft = """\ +Some prose. + +> 1 Donald Davidson, "On the Very Idea of a Conceptual Scheme." +""" + result = find_blockquote_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].marker == "1" + assert result.definitions[0].text == 'Donald Davidson, "On the Very Idea of a Conceptual Scheme."' + + +def test_find_blockquote_notes_multiple_in_order(): + draft = """\ +> 1 First note. +> 2 Second note. +> 3 Third note. +""" + result = find_blockquote_notes(draft) + assert len(result.definitions) == 3 + assert [d.marker for d in result.definitions] == ["1", "2", "3"] + assert [d.text for d in result.definitions] == [ + "First note.", + "Second note.", + "Third note.", + ] + + +def test_find_blockquote_notes_multi_digit_markers(): + draft = "> 1 one.\n> 42 forty-two.\n> 88 eighty-eight.\n" + result = find_blockquote_notes(draft) + assert [d.marker for d in result.definitions] == ["1", "42", "88"] + + +def test_find_blockquote_notes_returns_empty_when_no_definitions(): + draft = "Just prose.\n> A regular blockquote with no number.\n" + result = find_blockquote_notes(draft) + assert result.definitions == [] + + +def test_find_blockquote_notes_records_line_number(): + draft = """\ +Line zero. +Line one. + +> 1 Note on line three. +""" + result = find_blockquote_notes(draft) + assert result.definitions[0].line_number == 3 + + +def test_find_blockquote_notes_strips_trailing_whitespace(): + draft = "> 1 Note text with trailing spaces. \n" + result = find_blockquote_notes(draft) + assert result.definitions[0].text == "Note text with trailing spaces." + + +def test_find_blockquote_notes_records_original_prefix(): + """Reassembly prefix for blockquote format is ``'> N '``.""" + draft = "> 1 text.\n> 88 text.\n" + result = find_blockquote_notes(draft) + assert result.definitions[0].original_prefix == "> 1 " + assert result.definitions[1].original_prefix == "> 88 " + + +def test_find_blockquote_notes_ignores_non_numeric_blockquotes(): + """A blockquote without a leading number is regular prose, not a note.""" + draft = """\ +> This is a regular blockquote. +> 1 This is a note. +> Another regular blockquote. +""" + result = find_blockquote_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].marker == "1" -- 2.40.1 From 21fb9969c4f006001c92203450dd4278d31b2d3b Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sun, 12 Apr 2026 00:35:18 -0400 Subject: [PATCH 11/13] update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 65734ba..0fb4e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,6 @@ logs/* .env.* !.env.example -# rough drafts: real in-progress academic work, do not commit +# rough drafts, do not commit rough_drafts/*.txt +rough_drafts/*.md -- 2.40.1 From ffbffa76373a0ba2961428d9662ba3c4242d3bf3 Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sun, 12 Apr 2026 05:25:31 +0000 Subject: [PATCH 12/13] Update README.md --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 73b0f1a..4ae52ef 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,6 @@ markdown draft to **CMOS 18th edition, notes-and-bibliography form**. Accuracy-first, iterative, built with a karpathy/autoresearch-style dev loop. -See `program.md` for the goal specification and -`/home/claudecode1/.claude/plans/pure-mixing-yao.md` for the implementation plan. - ## Quick start ```bash -- 2.40.1 From 3f847b894a7fb1c0a810ed08fe2cc3756c927e8e Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 10 Jun 2026 17:23:03 -0400 Subject: [PATCH 13/13] chore(deps): lock file maintenance --- uv.lock | 449 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 227 insertions(+), 222 deletions(-) diff --git a/uv.lock b/uv.lock index 10b4bfb..7b2a9ef 100644 --- a/uv.lock +++ b/uv.lock @@ -26,7 +26,7 @@ wheels = [ [[package]] name = "black" -version = "26.3.1" +version = "26.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -36,50 +36,50 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, - { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, - { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, - { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, - { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] name = "click" -version = "8.3.2" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -166,11 +166,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -184,92 +184,92 @@ wheels = [ [[package]] name = "jiter" -version = "0.14.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, - { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, - { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, - { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, - { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, - { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, - { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, - { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, - { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, - { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, - { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, - { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, - { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, - { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, - { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, - { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, - { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, - { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, - { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, - { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, - { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, - { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, - { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, - { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, - { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, - { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, - { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, - { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, - { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, - { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, - { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, - { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, - { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, - { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, - { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, + { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, ] [[package]] @@ -283,7 +283,7 @@ wheels = [ [[package]] name = "openai" -version = "2.31.0" +version = "2.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -295,36 +295,36 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/fe/64b3d035780b3188f86c4f6f1bc202e7bb74757ef028802112273b9dcacf/openai-2.31.0.tar.gz", hash = "sha256:43ca59a88fc973ad1848d86b98d7fac207e265ebbd1828b5e4bdfc85f79427a5", size = 684772, upload-time = "2026-04-08T21:01:41.797Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/36/4c926a91554483977608951360c18c2e911592785eb87a6437813f6123f7/openai-2.41.1.tar.gz", hash = "sha256:23d617a0432457ad844973bee8f540be9da90894f7c5686852d2d365da058f57", size = 783584, upload-time = "2026-06-10T16:10:37.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/bc/a8f7c3aa03452fedbb9af8be83e959adba96a6b4a35e416faffcc959c568/openai-2.31.0-py3-none-any.whl", hash = "sha256:44e1344d87e56a493d649b17e2fac519d1368cbb0745f59f1957c4c26de50a0a", size = 1153479, upload-time = "2026-04-08T21:01:39.217Z" }, + { url = "https://files.pythonhosted.org/packages/20/74/925d7b3892927e9804aaf58d374a45dc28e4420ff90e992272b77286343e/openai-2.41.1-py3-none-any.whl", hash = "sha256:a939565f350cb7443cb843b801b88c716ac8024b492fb94ca269d5f6b1bbefd6", size = 1353380, upload-time = "2026-06-10T16:10:35.756Z" }, ] [[package]] name = "packaging" -version = "26.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +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/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { 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 = "pathspec" -version = "1.0.4" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -338,7 +338,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -346,106 +346,111 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +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/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { 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.41.5" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +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/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -527,14 +532,14 @@ wheels = [ [[package]] name = "tqdm" -version = "4.67.3" +version = "4.68.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/05/0d5260f1f1ca784f4a4a0def9cbe6affe587f5b4025328d446c3d67765f4/tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add", size = 171923, upload-time = "2026-06-09T13:26:42.539Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/eb/75/1a0392bcc21c44dcdf87b3cf2d137e7829be2c083a1e38d44efca3d57a16/tqdm-4.68.2-py3-none-any.whl", hash = "sha256:d4240441fb5353290b87d6a85968c9decc131a99b8c7faa28269d829de669ede", size = 78578, upload-time = "2026-06-09T13:26:40.731Z" }, ] [[package]] -- 2.40.1