pax_global_header00006660000000000000000000000064152603243410014512gustar00rootroot0000000000000052 comment=6e8d825a12ae32b98e4da5348d3ade6fce12a895 python-liblore-0.10.1/000077500000000000000000000000001526032434100145405ustar00rootroot00000000000000python-liblore-0.10.1/.b4-config000066400000000000000000000004501526032434100163100ustar00rootroot00000000000000[b4] midmask = https://lore.kernel.org/%s linkmask = https://patch.msgid.link/%s send-series-to = Kernel.org Tools send-series-cc = Konstantin Ryabitsev send-endpoint-web = https://lkml.kernel.org/_b4_submit send-prefixes = liblore python-liblore-0.10.1/.gitignore000066400000000000000000000001511526032434100165250ustar00rootroot00000000000000__pycache__/ *.py[cod] *.egg-info/ dist/ build/ .venv/ .venv-*/ .mypy_cache/ .ruff_cache/ .pytest_cache/ python-liblore-0.10.1/CHANGELOG000066400000000000000000000374241526032434100157640ustar00rootroot00000000000000CHANGELOG == v0.10.1 (2026-10-04) -------------------- - Add a local_sufficient argument to get_mbox_by_query(), get_thread_by_query() and batch_get_thread_by_query(). It tells a partial mirror that its own answer is sufficient for this search, even when it finds nothing, so the upstream archive is not asked. Use it when you already know the mirror has what you search for. When the mirror can't be reached, the upstream archive still answers. v0.10.0 (2026-10-01) -------------------- - Add support for partial mirrors, such as a maintainer's local mirror of the lists they follow. A server that sends X-Archive-Coverage: partial and names its upstream archive in X-Archive-Upstream is remembered, in cache_dir when set. What it doesn't have is fetched from the upstream archive, with the user's git config for that archive. Searches and thread polling stay on the mirror when it has the answer. When the mirror can't be reached, the upstream archive answers; when neither can, NotOnMirrorError (a RemoteError) says so. Servers without these headers, lore.kernel.org included, work as before. Only https://lore.kernel.org is trusted as an upstream by default, and the mirror's User-Agent plus identifier is not sent to it. - Add the git config keys followupstream, partialsearch, upstreamholdoff and allowupstream, and the matching LoreNode arguments follow_upstream, partial_search, upstream_holdoff and allowed_upstreams. - Add LoreNode.last_source (Source.LOCAL, UPSTREAM or DEGRADED) and LoreNode.upstream_url, so callers can tell the user where answers came from. - Add LoreNode.last_as_of, how far the last answer reaches. Pollers should pass it as the next since of get_thread_updates_since() instead of their own clock: a mirror is always a little behind, and a reply that arrives in the gap is otherwise never reported. - clear_cache() now also clears the cache of a partial mirror's upstream node, and keeps the saved upstream. - Add liblore.series for summarising a thread or patch series without pulling in b4. summarize_thread() returns a ThreadSummary describing the thread root, its participants, dates, patch series numbering and completeness, the collected trailers, and a MessageSummary per message. summarize_threads() and group_by_thread() split a flat query result into threads. - Add Trailer, which records where a trailer came from and whether it names a person. A person trailer in a reply counts only when the sender signs it, as b4 requires. This is not a signature check. - Add email_matches(), name_matches() and Identity for deciding whether two spellings are the same person. - Add parse_patch_subject() for reading a subject line on its own, and ThreadSummary.to_dict() with SCHEMA_VERSION for JSON output. - Add utils helpers split_body_parts(), find_trailers(), body_has_diff() and msg_get_inbody_author(). - Prefer X-Original-From in msg_get_author(), so a message rewritten by a mailing list reports the sender rather than the list. The header is not authenticated; code that needs proof wants b4's attestation. - Fix msg_get_subject(strip_prefixes=True) removing a subsystem prefix such as "mm:" or "net:" when the subject has no [bracketed] prefix, as in "Re: mm: fix the widget". Only real reply and forward markers are removed now. parse_patch_subject() uses the same rule, and both also know Wg:, Tr:, Odp: and Ref:. Add strip_reply_prefixes() for doing this on a subject line on its own. - Fix get_clean_msgid() returning None, or a cut-off ID, for a Message-ID with "(" or a stray '"' in it, such as . The header parser treats these as the start of a comment or a quoted string. The ID now comes from the header as it was written. - Fix msg_get_payload() raising LookupError on a message whose declared charset has no codec (charset="yes" is posted in the wild). errors='replace' does not cover it, because the codec name is what fails to resolve. It now falls back to utf-8, as b4 does for bodies and as clean_header() already did for headers. - Add RemoteError.status_code, the HTTP status the server answered with, or None when no answer arrived (connection error, timeout). Callers can now tell "not found" (404) apart from "could not be reached". Messages are unchanged, and RemoteError('text') still works. - Fix the autoprobe git config key ignoring git's other true values. yes, on and 1 now turn probing on, as they would in git. A value git would reject keeps the default. - Fix probe_origins() trusting a probe cache file in cache_dir that holds valid JSON but not a list of strings. A number or a nested list raised TypeError, and so did every request when autoprobe is on. Such a file is now ignored and the origins are probed again. - Fix get_message_by_msgid() and validate() raising RemoteError instead of OperationCancelledError when cancel_active() or shutdown() stops them mid-request. Code that catches RemoteError no longer mistakes a cancel for a server failure. - Fix the redirect lookup in get_mbox_by_msgid() (the HEAD it sends after a 404) bypassing the fallback origins. It now fails over like other requests, raises OperationCancelledError on a cancel and RemoteError when no host answers, instead of a bare requests exception. A redirect found on a fallback origin no longer produces a broken mbox URL. A 200 without a redirect now fetches the mbox instead of returning an empty thread. - Fix two processes writing the same cache file at the same time: they shared one temporary file, so one write could fail or leave a broken file. Each write now uses its own temporary file. clear_cache() also removes temporary files left by a process that was killed. v0.9.0 (2026-08-12) ------------------- - Make cancellation operation-scoped. Every public entry point now runs as one logical operation, and cancel_active() cancels only operations already in flight: each stays cancelled for the rest of its run (a batch aborts fully, even when the cancel lands between requests), while operations started afterwards proceed normally -- there is no sticky flag to reset. shutdown() is the new terminal state for application exit: it cancels everything in flight and refuses all new operations; is_shutdown reports it. cancel() remains as a deprecated alias for cancel_active(), and reset_cancel() is now a deprecated no-op. See MIGRATIONS.md for the 0.8 -> 0.9 migration guide. v0.8.2 (2026-08-12) ------------------- - Fix get_strict_thread() dropping an entire series when a message's In-Reply-To/References headers point at its own Message-ID. Such self-references are now filtered out for every message, instead of being treated as a parent, which previously caused a self-referencing cover letter with noparent set to seed the ignore set with its own msgid and silently discard all of its children. v0.8.1 (2026-07-22) ------------------- - Fix get_clean_msgid() destroying the message-ID when a msg-id header (In-Reply-To/References/Message-Id) also carries an RFC 2047 encoded-word comment, as Gnus emits. The ID is now extracted straight from the raw header value instead of via clean_header(), matching _clean_msgid_raw(). v0.8.0 (2026-06-11) --------------------- - Add configurable request_timeout (default connect=5s, read=30s) applied to all outgoing HTTP requests, including the direct HEAD calls in _resolve_msgid_via_head() and validate(). Configurable via the `requesttimeout` git config key (single float or "connect,read"). - Add LoreNode.cancel() / reset_cancel() for thread-safe cancellation of in-flight requests. cancel() closes the owned session to interrupt a blocked socket read and raises the new OperationCancelledError on the next request boundary; it never fails over to another origin. Injected sessions are left untouched. v0.7.1 (2026-04-08) -------------------- - Add per-origin git config via [liblore ""] subsections. from_git_config() now looks for configuration in [liblore "https://lore.kernel.org"] (or whichever origin is passed) before falling back to the legacy [lore] section. The [lore] fallback only applies to lore.kernel.org URLs, so non-lore servers like subspace.kernel.org get independent mirror and probe settings without inheriting lore.kernel.org mirrors. Add new _get_subsection_config() helper that correctly handles dots in subsection names (URLs) by prefix-stripping instead of last-dot splitting. v0.7.0 (2026-04-08) -------------------- - Add public request() method to LoreNode for making arbitrary HTTP requests through the failover machinery. Downstream projects like korgalore can now route their own HTTP calls (e.g. manifest.js.gz fetches) through LoreNode's origin rotation and auto-probe support without reimplementing the failover logic. Always raises RemoteError when all origins are exhausted. - Add read-only user_agent_plus property exposing the lore.useragentplus value from git config. Downstream projects can reuse this tracking identifier in their own user-agent strings (e.g. for git HTTP or lei CLI flags) without re-reading git config or accessing private attributes. - Add read-only origins property returning a copy of all configured origins in their current probe order (fallbacks first, then canonical). After probe_origins() runs, the list reflects the fastest-first reordering. Useful for logging, diagnostics, and UI display of configured mirrors. - Add read-only canonical_origin property returning the scheme://host origin extracted from the primary URL. Paired with origins, lets callers distinguish the configured primary from fallback mirrors and construct URLs suitable for the request() method. v0.6.1 (2026-04-08) -------------------- - Fix get_mbox_by_msgid() returning 404 when the configured URL does not include the correct list path. On a 404, the method now falls back to a HEAD request against the bare server origin and follows the redirect to discover the canonical list path, then fetches the mbox from the resolved location. v0.6.0 (2026-04-08) -------------------- - Add URL-level failover via new fallback_urls constructor parameter. Fallback URLs are origin prefixes (scheme://host) that support full scheme rewriting, so local http:// mirrors and .onion endpoints work alongside https:// servers. On each request, origins are tried in order; connection errors, timeouts, and 5xx responses fall through to the next origin while 4xx responses are returned immediately. Cache keys always use the canonical URL regardless of which mirror served the response. - Add concurrent origin probing via probe_origins(), which sends HEAD requests to /manifest.js.gz on all configured origins using ThreadPoolExecutor to find the fastest mirror. Unreachable origins are moved to the end rather than removed. Results are cached to cache_dir for probe_ttl seconds (default 3600). Pass nocache=True to force a live probe even when cached results exist. Set auto_probe=True to trigger probing transparently on the first request. - Add LoreNode.from_git_config() classmethod that reads settings from the [lore] section of git config (repo, global, or system) via a single "git config -z --get-regexp" call. Supported keys: lore.fallback (multi-value), lore.autoprobe, lore.probetimeout, lore.probettl, lore.useragentplus. All keys are optional; missing git or any failure is silently ignored. Explicit keyword arguments take precedence over git config values. - set_user_agent() now falls back to the lore.useragentplus value from git config when the caller does not pass an explicit plus= argument, so a single git config entry identifies the installation across all liblore-based tools. v0.5.0 (2026-04-07) -------------------- - Add optional filesystem caching to LoreNode via new cache_dir and cache_ttl constructor parameters. When cache_dir is set, raw mbox bytes from get_mbox_by_msgid(), get_mbox_by_query(), and get_message_by_msgid() are cached on disk with per-request TTL validation. Stale entries are cleaned up on read. Polling methods (get_thread_updates_since, _fetch_thread_since) are intentionally not cached. Pass nocache=True to any cached method to bypass the cache for a single call. Add clear_cache() for bulk invalidation. - Add read-only url property to LoreNode for accessing the configured endpoint URL. - Add full_threads parameter to get_mbox_by_query(), get_thread_by_query(), and batch_get_thread_by_query(). When set to True, the server expands results to include the full thread for every matching message (public-inbox t=1 parameter). This is needed by callers like b4 that search by patch-id or change-id and expect the complete surrounding thread, not just the matching messages. v0.4.0 (2026-03-05) -------------------- - Add split_mbox_as_bytes() and split_and_dedupe_as_bytes() for working with raw message bytes without invoking the email parser. The regular split_mbox() and split_and_dedupe() now wrap these functions, so the splitting logic is shared. - The bytes-level deduplication extracts Message-ID and List-Id headers directly from raw bytes using lightweight line scanning, avoiding the cost of full message parsing. v0.3.1 (2026-03-04) -------------------- - Lower minimum Python version from 3.11 to 3.9. The codebase already uses ``from __future__ import annotations`` everywhere, so no code changes were required. - Clean up unused imports in test_formatting.py. v0.3.0 (2026-03-04) -------------------- - Add email serialization utilities ported from b4/bugspray: format_addrs(), wrap_header(), get_msg_as_bytes(). These replace Python's buggy as_bytes() with battle-tested RFC 2047 header encoding and line wrapping. - Add minimize_thread() for creating compact thread views by stripping excessive quoting, multi-level quotes, and non-essential headers. Messages containing diffs/diffstats are preserved as-is. Accepts an optional keep_headers parameter for customizing which headers to retain. Compliant email signatures are split off before processing so trailing quoted blocks before a signature are correctly recognised and dropped. - Add optional message authentication (DKIM, DMARC, ARC) via the authheaders library. Enable with LoreNode(add_auth_headers=True). Install the optional dependency with: pip install liblore[auth]. - Add reduce_quote_context option to minimize_thread() for more aggressive quote reduction: long quoted blocks preceding a reply are trimmed to just the last paragraph, with earlier content replaced by a "> [... skip NN lines ...]" marker. Only kicks in when more than 5 lines would be skipped. v0.2.0 (2026-03-03) -------------------- - Fix thread update fetching to use the per-message search endpoint (/{msgid}/?x=m&q=dt:{since}..) instead of a global query with mid:{msgid}. This scopes the search to the correct thread and filters by Date header (dt:) instead of commit date (d:). - Fix get_mbox_by_query() to POST 'x=m' as request body, ensuring the server returns mbox format. - Add get_thread_updates_since() convenience method for checking whether a thread has new messages. Accepts a datetime object and returns an empty list when there are no updates, making it easy to poll for changes. Uses the rt: (Received header) search prefix with epoch timestamps for reliable server-side date filtering. - Refactor get_thread_by_msgid() to use GET /{msgid}/t.mbox.gz for full thread fetches (no since parameter), avoiding unnecessary query overhead. v0.1.0 (2025-08-15) -------------------- - Initial release. - LoreNode class for interacting with public-inbox servers. - Thread fetching by message ID and by search query. - Single message fetching by message ID. - Batch operations with rate limiting. - Strict thread filtering and sort-by-received support. - Configurable User-Agent and external session injection. - Server validation via HEAD request to help endpoint. python-liblore-0.10.1/LICENSES/000077500000000000000000000000001526032434100157455ustar00rootroot00000000000000python-liblore-0.10.1/LICENSES/GPL-2.0-or-later.txt000066400000000000000000000431001526032434100211460ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, see . Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Moe Ghoul, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. python-liblore-0.10.1/MIGRATIONS.md000066400000000000000000000110441526032434100165360ustar00rootroot00000000000000# Migration notes This document describes the changes that downstream tools (such as [b4](https://b4.docs.kernel.org/)) need to make when moving between liblore versions that change API behaviour. Routine additions are listed in the `CHANGELOG`; only migrations that require action from library users are documented here. ## 0.8 → 0.9: operation-scoped cancellation ### What changed In 0.8 and earlier, cancellation was a **sticky flag** on the node: once `cancel()` was called, *every* subsequent request raised `OperationCancelledError` until someone called `reset_cancel()`. On a long-lived shared node this forced a "reset before every fetch" convention onto callers, and a forgotten reset turned into mysterious cancellation errors on unrelated fetches much later. In 0.9, cancellation is **operation-scoped**. Every public entry point (`get_thread_by_msgid()`, `get_mbox_by_query()`, the `batch_*` methods, `probe_origins()`, `validate()`, and so on) runs as one logical operation, and cancellation is expressed with two new methods: - **`cancel_active()`** — cancels only the operations that are in flight *right now*. Each one raises `OperationCancelledError` at its next request and stays cancelled for the rest of its run (a batch aborts fully, even when the cancel lands between its requests). Operations started *after* the call are unaffected. There is no flag, so there is nothing to reset. - **`shutdown()`** — the terminal state for application exit. Cancels everything in flight like `cancel_active()`, and additionally refuses every operation started afterwards (it raises `OperationCancelledError` immediately, before any connection is opened). Cannot be undone. The new `is_shutdown` property reports whether the node has been shut down. The old methods still work, but are deprecated and emit `DeprecationWarning`: - `cancel()` is now an alias for `cancel_active()`. Note the semantic change: it **no longer poisons future requests**. - `reset_cancel()` is now a **no-op**. It exists only so that 0.8-era call sites keep running. ### What you need to do 1. **Delete every `reset_cancel()` call.** They do nothing anymore. If your code wrapped fetches in a "reset first" helper or context manager, the reset half of that helper can go away. 2. **Replace each `cancel()` call with the method that matches its intent:** | You were using `cancel()` to... | Replace with | |---|---| | Let the user abort an in-progress fetch (Esc, Ctrl-C) | `cancel_active()` | | Stop everything because the application is exiting | `shutdown()` | Getting this right matters: `cancel_active()` alone no longer prevents *new* requests from starting, so an exit path that used to rely on the sticky flag to keep racing workers from opening connections should use `shutdown()`. 3. **Drop any workarounds for the sticky flag.** Code that caught `OperationCancelledError` and retried after a reset, or that defensively reset the flag "just in case", can be simplified to a plain call. ### What stays the same - `OperationCancelledError` is still the exception raised on cancellation, and it is still a subclass of `LibloreError`. - Cancelling still closes the node-owned `requests` session, so a thread blocked in a socket read is interrupted immediately. An injected session (`set_requests_session()`) is still left alone. - A socket closed by cancellation is still distinguished from a real origin failure: the request loop raises `OperationCancelledError` instead of failing over to a mirror. - Within a single operation, cancellation is still "sticky": once an operation has been cancelled, all of its remaining requests raise. What changed is only that this no longer leaks into *other* operations. ### Notes for test suites The deprecated shims warn with `DeprecationWarning`, which Python hides by default at runtime but pytest surfaces (and some configurations promote to errors). If you cannot migrate all call sites at once, filter the warning temporarily: ```ini # pytest.ini / pyproject.toml [tool.pytest.ini_options] filterwarnings = [ "ignore:cancel\\(\\) is deprecated:DeprecationWarning", "ignore:reset_cancel\\(\\) is deprecated:DeprecationWarning", ] ``` Tests that asserted the sticky behaviour itself (for example, "request raises unless reset_cancel() was called first") describe 0.8 semantics and should be removed or rewritten against `cancel_active()`/`shutdown()`. ### Removal timeline `cancel()` and `reset_cancel()` will be removed in a future release, no earlier than 1.0. New code should not use them. python-liblore-0.10.1/README.md000066400000000000000000001154271526032434100160310ustar00rootroot00000000000000# liblore A Python library for working with [public-inbox](https://public-inbox.org/) servers, particularly [lore.kernel.org](https://lore.kernel.org/). It fetches email threads, parses mbox files, summarises patch series, and provides utilities for working with email messages from mailing list archives. ## Requirements - Python 3.9 or newer - `requests` >= 2.31 - `authheaders` >= 0.15 (optional, for DKIM/DMARC/ARC verification) ## Installation Install from PyPI: ```shell pip install liblore ``` To include optional email authentication support (DKIM, DMARC, ARC): ```shell pip install liblore[auth] ``` Or install from source: ```shell pip install . ``` ## Quick Start The main entry point is the `LoreNode` class. It connects to a public-inbox endpoint and lets you fetch threads, search for messages, and work with raw mbox data. Use it as a context manager so the underlying HTTP session is cleaned up automatically: ```python from liblore import LoreNode with LoreNode('https://lore.kernel.org/all') as node: msgs = node.get_thread_by_msgid( '20250101-example@kernel.org', sort=True, ) for msg in msgs: print(msg['Subject']) ``` If you omit the URL, it defaults to `https://lore.kernel.org/all`. ## API Reference ### LoreNode ```python from liblore import LoreNode node = LoreNode(url='https://lore.kernel.org/all') ``` #### Git Config Integration The easiest way to create a `LoreNode` is via `from_git_config()`, which reads settings from the `[lore]` section of your git config (repo, global, or system). This gives you per-repository overrides for free -- a subsystem maintainer with a local mirror just adds settings to their repo's `.git/config`: ```python with LoreNode.from_git_config() as node: msgs = node.get_thread_by_msgid('20250101-example@kernel.org') ``` Supported git config keys: ```ini [lore] # Origin URLs to try before the canonical URL (multi-valued, in order) fallback = https://tor.lore.kernel.org fallback = https://sea.lore.kernel.org # Auto-probe all origins on first request, reorder by latency autoprobe = true # Per-origin probe timeout in seconds (default: 5.0) probetimeout = 5.0 # How long cached probe results stay valid, in seconds (default: 3600) probettl = 3600 # Unique identifier appended to User-Agent (typically a UUID) useragentplus = 550e8400-e29b-41d4-a716-446655440000 ``` For any other server, such as a local mirror, put the same keys in a section named after its origin (`scheme://host[:port]`, with no path). lore.kernel.org also reads this section first, and uses `[lore]` only when it is missing: ```ini [liblore "http://localhost:11043"] probetimeout = 2.0 ``` All keys are optional. Missing keys, missing git, or any other failure is silently ignored. Explicit keyword arguments to `from_git_config()` take precedence over git config values. #### Fallback URLs lore.kernel.org uses geodns across multiple nodes. When a node is slow or unavailable, `fallback_urls` lets you specify alternative servers to try automatically: ```python with LoreNode( 'https://lore.kernel.org/all', fallback_urls=[ 'http://mymirror.local', 'https://tor.lore.kernel.org', 'https://sea.lore.kernel.org', ], ) as node: msgs = node.get_thread_by_msgid('20250101-example@kernel.org') ``` Each fallback is an **origin prefix** (`scheme://host`). The path from the primary URL is preserved automatically, so `https://lore.kernel.org/all/...` becomes `http://mymirror.local/all/...`. This supports mixing `https` and `http` schemes -- useful for local mirrors or `.onion` endpoints where TOR provides the encryption layer. On each HTTP request, origins are tried in order. Connection errors, timeouts, and 5xx responses trigger a fall-through to the next origin. 4xx responses (the resource genuinely doesn't exist) are returned immediately without retrying. The `validate()` method intentionally skips fallback and checks only the canonical URL. Cache keys always use the canonical URL, so cache hits work regardless of which mirror served the response. #### Origin Probing When you have multiple fallback URLs, `probe_origins()` finds the fastest mirror by sending a concurrent `HEAD` request to `/manifest.js.gz` on each origin: ```python node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://tor.lore.kernel.org', 'https://sea.lore.kernel.org'], ) # Probe all origins concurrently and reorder by latency results = node.probe_origins() for origin, elapsed in results: print(f'{origin}: {elapsed * 1000:.0f}ms') ``` Unreachable origins are moved to the end rather than removed, so they can recover on subsequent requests. Probe results are cached to `cache_dir` (when set) for `probe_ttl` seconds (default 3600 = 1 hour). Pass `nocache=True` to force a live probe even when cached results exist (the fresh results are still written back to cache). Set `auto_probe=True` to trigger probing transparently on the first request: ```python with LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://tor.lore.kernel.org'], auto_probe=True, cache_dir='/tmp/liblore-cache', ) as node: # First request probes, reorders, then fetches via the fastest mirror msgs = node.get_thread_by_msgid('20250101-example@kernel.org') ``` #### Caching LoreNode can optionally cache raw mbox bytes on disk. Pass `cache_dir` to enable it: ```python with LoreNode(cache_dir='/tmp/liblore-cache', cache_ttl=600) as node: # First call fetches from the network and writes a cache file msgs = node.get_thread_by_msgid('20250101-example@kernel.org') # Second call reads from cache (if within TTL) msgs = node.get_thread_by_msgid('20250101-example@kernel.org') ``` - `cache_dir` -- directory for cache files (`None` to disable, the default) - `cache_ttl` -- time-to-live in seconds (default 600 = 10 minutes) Caching is applied to `get_mbox_by_msgid`, `get_mbox_by_query`, and `get_message_by_msgid`. Polling methods (`get_thread_updates_since`) are intentionally not cached. TTL is checked on every read, so stale data is never returned -- even in long-running processes. Pass `nocache=True` to any cached method to bypass the cache for that call (the response is still written back to refresh the entry). Call `node.clear_cache()` to remove all cached entries. #### Partial Mirrors A *partial mirror* has only some of an archive: for example a maintainer's local mirror of the lists they follow. Such a mirror is fast on a bad connection, and it keeps working when lore.kernel.org is down. But it doesn't have everything, so "not found" from it doesn't mean the message doesn't exist. A partial mirror says so in two response headers: ``` X-Archive-Coverage: partial; updated=1790000000 X-Archive-Upstream: https://lore.kernel.org/all/ ``` LoreNode reads them on every answer. There is nothing to configure: point the node at the mirror, and it learns the rest. - **The mirror answers first.** When it doesn't have a thread or a message (a 404), the node asks the upstream archive. The upstream gets its own node, which uses your git config for its origin (fallbacks, probing, timeouts). - **Searches stay local.** A search the mirror answers is final. The upstream archive is asked only when the mirror finds nothing. Set `partialsearch = upstream` to ask the upstream archive first, if you want every hit and don't mind the wait. When you already know the mirror has what you are searching for (for example, it just answered for the thread the search is about), pass `local_sufficient=True`: then even "no results" is final, and the upstream archive is asked only if the mirror can't be reached. - **Polling stays local.** `get_thread_updates_since()` first asks the mirror whether it has the thread (a `HEAD` request). For a thread it has, its "nothing new" is final, and lore.kernel.org is not contacted. For a thread it doesn't have, the upstream archive answers. - **Outages.** When the mirror can't be reached, the upstream archive answers. When the upstream can't be reached, the node doesn't try it again for `upstreamholdoff` seconds (default 300), so a command doesn't wait for a timeout on every call. - **Remembered.** With `cache_dir` set, the node saves the upstream it learned (in a `*.lore.upstream` file, which `clear_cache()` keeps). So it still works the next time, even when the mirror is stopped. Servers that don't send these headers, lore.kernel.org included, work exactly as before. **Where answers came from.** `node.last_source` tells you where the answer to your last call (in this Python thread) came from: ```python from liblore import LoreNode, Source with LoreNode.from_git_config('http://localhost:11043/lore/all') as node: msgs = node.get_thread_by_msgid('20250101-example@kernel.org') if node.last_source is Source.UPSTREAM: print(f'Not on your mirror, fetched from {node.upstream_url}') ``` - `Source.LOCAL` -- answered by this node (its server or its cache). - `Source.UPSTREAM` -- answered by the upstream archive. - `Source.DEGRADED` -- the upstream archive could not be reached, so only the mirror answered, and the answer may be incomplete. Only `partialsearch = upstream` can give this. It is `None` before the first call and after a call that raised. A batch reports the worst source of all its items. To get a source per item, call the single-item method in your own loop. On a node that is not a partial mirror, every answer is `LOCAL`. **Polling with `last_as_of`.** A mirror is always a little behind the archive it mirrors. Its "nothing new" is true only up to its last update. If you poll with your own clock as the next `since`, a reply can fall into that gap and never be reported. Instead, use `node.last_as_of`. It tells you how far the last answer reaches: ```python since = datetime.now(timezone.utc) - timedelta(days=1) while True: updates = node.get_thread_updates_since(msgid, since) since = node.last_as_of or since # Polls overlap a little: skip messages you have already seen. ... ``` This works on every node, not only on partial mirrors. On a full archive, `last_as_of` is the time the request was sent. **When a mirror sends no `updated` time** (it has never finished an update), its "nothing new" can't be trusted. Thread updates then come from the upstream archive. **Errors.** When the mirror doesn't have something and the upstream archive can't be reached, you get `NotOnMirrorError`. It is a `RemoteError`, so code that already handles `RemoteError` keeps working. When the upstream archive answers "not found", you get a plain `RemoteError` with `status_code == 404`, as from any other server. **Trust.** A mirror shared over a VPN could name any host as its upstream, and your queries would go there. So only `https://lore.kernel.org` is trusted by default. Add others with `allowupstream` (multi-valued) in git config, or `allowed_upstreams=[...]` in Python. An upstream that isn't trusted is ignored, with one warning. The User-Agent `plus` identifier of the mirror's node (passed to `set_user_agent()`, or `useragentplus` in the mirror's git config) is not sent to the upstream archive: it identifies you to one server's operator. The upstream node uses the `useragentplus` that git config sets for its own origin, if any. For lore.kernel.org, that is the one in `[lore]`. **Caching.** The node caches only its own answers. Answers from the upstream archive are cached by the upstream node, so the next call asks the mirror again: by then it may have the thread. Answers that may be incomplete (`DEGRADED`) are never cached. `clear_cache()` clears both. The settings go in the git config section of the mirror's origin, and `from_git_config()` reads them: ```ini [liblore "http://localhost:11043"] partialsearch = upstream upstreamholdoff = 60 allowupstream = https://mirror.example.com ``` | git config key | `LoreNode` argument | Default | |-------------------|---------------------|--------------------------------------------------| | `followupstream` | `follow_upstream` | `true`; `false` ignores the headers | | `partialsearch` | `partial_search` | `local`, or `upstream` | | `upstreamholdoff` | `upstream_holdoff` | `300` seconds; `0` asks the upstream every time | | `allowupstream` | `allowed_upstreams` | none besides `https://lore.kernel.org` | #### Fetching Threads **`node.get_thread_by_msgid(msgid, *, strict=True, sort=False, since=None)`** Fetch a thread by its message ID. This is the highest-level method and the one you will reach for most often. - `strict` (default `True`) -- filter results to only messages that belong to the thread rooted at `msgid`. When a query returns messages from unrelated threads (common with broad date ranges), strict mode discards them. - `sort` -- sort the returned messages by their `Received` header timestamp. - `since` -- a date string appended as a `d:` filter. This uses public-inbox's approxidate syntax, so you can write things like `"20240115"`, `"2.weeks.ago"`, or `"last.month"`. Returns a `list[EmailMessage]`. Raises `LookupError` if no messages match. ```python with LoreNode() as node: # Fetch a thread, sorted by date, only looking at recent messages msgs = node.get_thread_by_msgid( '20250101-example@kernel.org', strict=True, sort=True, since='20250101', ) ``` **`node.get_thread_updates_since(msgid, since, *, strict=True, sort=False)`** Check whether a thread has new messages since a given point in time. This is handy for polling use cases where you want to know if anything new has arrived. - `since` -- a `datetime` object. Converted to a UTC epoch timestamp internally and matched against the server-set `Received` header (`rt:` prefix), which is more reliable than the client-set `Date` header. - `strict` (default `True`) -- filter results to only messages belonging to the thread rooted at `msgid`. - `sort` -- sort the returned messages by their `Received` header timestamp. Returns a `list[EmailMessage]`. Returns an empty list (rather than raising) when there are no updates. ```python from datetime import datetime, timedelta, timezone with LoreNode() as node: cutoff = datetime.now(timezone.utc) - timedelta(hours=24) updates = node.get_thread_updates_since( '20250101-example@kernel.org', cutoff, ) if updates: print(f'{len(updates)} new message(s)') ``` **`node.get_thread_by_query(query, *, full_threads=False)`** Run a search query and return a deduplicated `list[EmailMessage]`. The query uses public-inbox's [Xapian search syntax](https://public-inbox.org/HOWTO#search), which supports prefixes like `msgid:`, `s:` (subject), `f:` (from), `d:` (date range), and more. When `full_threads` is `True`, the server expands results to include the full thread for every matching message. This is useful when searching by patch-id or change-id and you need the complete surrounding thread, not just the matching messages. ```python with LoreNode() as node: # Find all messages from a sender in the last month msgs = node.get_thread_by_query('f:alice@example.com d:last.month..') # Search by patch-id and fetch the full threads msgs = node.get_thread_by_query('patchid:abc123', full_threads=True) ``` #### Batch Fetching When you need to fetch multiple threads, the batch methods handle the loop for you and add a 100 ms cooldown between requests so you're being a good citizen to the server. **`node.batch_get_thread_by_msgid(msgids, *, strict=True, sort=False, since=None)`** Fetch threads for a list of message IDs. Calls `get_thread_by_msgid()` for each one with a brief pause between requests. Returns a `list[list[EmailMessage]]` in the same order as the input. ```python with LoreNode() as node: threads = node.batch_get_thread_by_msgid( ['msg1@example.com', 'msg2@example.com', 'msg3@example.com'], sort=True, since='2.weeks.ago', ) for thread in threads: print(f'Thread with {len(thread)} messages') ``` **`node.batch_get_thread_by_query(queries, *, full_threads=False)`** Run multiple search queries. Same pattern -- calls `get_thread_by_query()` per query with a 100 ms cooldown. Returns a `list[list[EmailMessage]]`. ```python with LoreNode() as node: results = node.batch_get_thread_by_query( [ 's:fix f:alice@example.com', 's:feature f:bob@example.com', ] ) ``` #### Raw Mbox Access These methods return raw mbox bytes rather than parsed messages. They are useful when you need the unprocessed data, or when you want to feed the output into your own parser. **`node.get_mbox_by_msgid(msgid, *, nocache=False)`** -- fetch a thread's mbox by message ID. **`node.get_mbox_by_query(query, *, full_threads=False, nocache=False)`** -- run a search query and return the matching mbox. Pass `full_threads=True` to expand results to include full threads. ```python with LoreNode() as node: raw = node.get_mbox_by_msgid('20250101-example@kernel.org') with open('thread.mbox', 'wb') as f: f.write(raw) ``` #### Single Messages **`node.get_message_by_msgid(msgid, *, nocache=False)`** -- fetch a single raw message (bytes) by its message ID. Useful when you need exactly one message rather than an entire thread. #### Session Configuration **`node.set_user_agent(app_name, version, plus=None)`** -- set a custom `User-Agent` header. Being a good citizen of public infrastructure means identifying your tool: ```python node.set_user_agent('my-tool', '1.0') # User-Agent: my-tool/1.0 ``` The optional `plus` argument appends a unique identifier that server operators can use to identify and prioritize known installations: ```python node.set_user_agent('my-tool', '1.0', plus='550e8400-e29b-41d4') # User-Agent: my-tool/1.0+550e8400-e29b-41d4 ``` When `plus` is not provided and the node was created via `from_git_config()`, the value of `lore.useragentplus` from git config is used automatically. This way a single git config entry identifies the installation across all tools using liblore: ```python # With lore.useragentplus = myuuid in ~/.gitconfig: node = LoreNode.from_git_config() node.set_user_agent('korgalore', '0.7') # User-Agent: korgalore/0.7+myuuid ``` **`node.set_requests_session(session)`** -- inject your own `requests.Session`. Handy when you need custom timeouts, proxies, or authentication. Note that the session's `User-Agent` is not overwritten when you provide your own. **`node.validate()`** -- check that the configured URL actually points to a public-inbox server. Raises `RemoteError` if it does not. **`node.close()`** -- close the HTTP session. Called automatically when using `LoreNode` as a context manager. #### Cancellation Fetches can take a while, and interactive applications need a way to bail out -- the user pressed Escape, or the whole application is exiting. Both methods are thread-safe and are meant to be called from a different thread than the one doing the fetching. Cancellation is *operation-scoped*: each public method call is one operation, and cancelling affects only the operations that are running at that moment. **`node.cancel_active()`** -- cancel every operation currently in flight. Each one raises `OperationCancelledError` (from a worker thread, this surfaces in whatever way your framework reports worker exceptions) and stays cancelled for the rest of its run, so a batch stops fully even when the cancel arrives between its requests. Operations started afterwards are unaffected -- there is no flag to reset before the next fetch. ```python import threading from liblore import LoreNode, OperationCancelledError node = LoreNode() def fetch() -> None: try: node.batch_get_thread_by_msgid(lots_of_msgids) except OperationCancelledError: print('fetch aborted') worker = threading.Thread(target=fetch) worker.start() # ... user presses Escape: node.cancel_active() worker.join() # The node is immediately usable again -- no reset needed: msgs = node.get_thread_by_msgid('20250101-example@kernel.org') ``` **`node.shutdown()`** -- cancel everything in flight *and* refuse every operation started afterwards. This is the terminal state for application exit: a worker racing against shutdown raises `OperationCancelledError` immediately instead of opening a new connection and delaying the exit. There is no way to undo a shutdown. The **`node.is_shutdown`** property reports whether the node has been shut down. Cancelling closes the node-owned HTTP session, so a thread blocked in a socket read is interrupted right away rather than waiting for a timeout. A session you injected with `set_requests_session()` is left untouched. Versions before 0.9 used a sticky `cancel()`/`reset_cancel()` pair; both still work but are deprecated. See `MIGRATIONS.md` for how to move over. #### Message Authentication LoreNode can optionally verify DKIM signatures, DMARC alignment, and ARC chains on every message it retrieves. This requires the `authheaders` package (install with `pip install liblore[auth]`). ```python with LoreNode(add_auth_headers=True) as node: msgs = node.get_thread_by_msgid('20250101-example@kernel.org') for msg in msgs: print(msg['Authentication-Results']) # liblore; dkim=pass header.d=kernel.org; ... ``` When enabled, each returned `EmailMessage` gets an `Authentication-Results` header added by the [authheaders](https://pypi.org/project/authheaders/) library. SPF is not checked because archived messages don't carry the SMTP transaction info (client IP, MAIL FROM, HELO) that SPF requires. If `add_auth_headers=True` is set but `authheaders` is not installed, a `LibloreError` is raised immediately on construction. ### How the API Layers Fit Together The methods build on each other in layers, from raw bytes up to filtered, sorted thread views: ``` get_mbox_by_msgid / get_mbox_by_query -> raw mbox bytes | get_thread_by_query -> split + dedupe -> list[EmailMessage] | get_thread_by_msgid -> strict + sort -> list[EmailMessage] | get_thread_updates_since -> poll for new -> list[EmailMessage] | batch_get_thread_by_msgid / batch_get_... -> rate-limited loop -> list[list[EmailMessage]] ``` You can tap into whichever layer suits your needs. Need raw bytes for archiving? Use the `get_mbox_*` methods. Need parsed messages with deduplication? Use `get_thread_by_query`. Want the full convenience of strict filtering and date sorting? Use `get_thread_by_msgid`. Need to poll for new messages? Use `get_thread_updates_since`. ### Thread and Series Summaries The `liblore.series` module answers one question: *what is in this thread?* It is pure Python -- no network, no git, no subprocess -- so you hand it the messages you already fetched and get back plain dataclasses you can filter, cache or turn into JSON. It reports facts and leaves the judgements to you. Whether a series arrived complete, whether you have already applied it, whether two addresses belong to the same human -- those are your calls. The summary gives you what you need to make them. ```python from liblore import LoreNode from liblore.series import summarize_thread with LoreNode() as node: msgs = node.get_thread_by_msgid('20250101-example@kernel.org') summary = summarize_thread(msgs) print(summary.subject) # subject of the thread root print(summary.count) # how many messages are in the thread print(summary.first, summary.last) # oldest and newest timestamps print(summary.is_patch_series) # did anything carry a [PATCH ...] subject print(summary.patch_count) # how many patches were actually seen print(summary.expected) # how many the brackets claimed (the m in n/m) print(summary.version) # 3 for a [PATCH v3] series, 1 if unstated print(summary.has_cover) # was there a 0/m cover letter print(summary.prefixes) # extra brackets, e.g. ('RFC', 'net-next') print(summary.author) # who opened the thread, as (name, email) print(summary.participants) # everybody who sent something, author included print(summary.repliers) # everybody except the author ``` `patch_count` and `expected` are deliberately two fields. Comparing them is how *you* decide whether a series arrived complete -- liblore will not decide it for you, because "complete" means different things depending on what you are about to do. A lone patch with no `n/m` counter counts as a one-patch series. The `[PATCH` bracket is what makes something a patch here, which is the same rule b4 uses; a message carrying a diff under some other subject is not treated as a submission. #### Messages in Order `messages` holds the same thread message by message, so you can ask questions about *order* -- did the review land before or after the last reply, which patch in the series was tagged, has this person said anything since. ```python for msg in summary.messages: print(msg.msgid, msg.author, msg.date) print(msg.patch.counter, 'of', msg.patch.expected) # parsed subject print(msg.has_diff) # did this message carry a diff print(msg.trailers) # trailers this message offered print(msg.is_submission) # a patch, and not a reply carrying its brackets ``` The thread root comes first and the rest follow oldest-first. Messages sent within the same second -- which is every series sent by `git send-email` -- are ordered by their `n/m` counter rather than left in arrival order. `msgids` runs parallel to `messages`: `msgids[i]` is always `messages[i].msgid`, and both are `count` entries long. A message with no Message-Id keeps its place as an empty string, so the two never drift apart. ```python assert summary.msgids == tuple(m.msgid for m in summary.messages) assert summary.msgids[0] == summary.msgid # the thread root leads ``` You can also summarise a single message on its own: ```python from liblore.series import summarize_message one = summarize_message(msg) ``` #### Trailers Every trailer seen anywhere in the thread is reported once, in the order it first appeared, classified the way b4 classifies them: ```python for trailer in summary.trailers: print(trailer.name) # 'Reviewed-by', as written print(trailer.key) # 'reviewed-by', for comparing print(trailer.value) # 'Ann Submitter ' print(trailer.type) # 'person', 'utility' or 'unknown' print(trailer.addr) # ('Ann Submitter', 'ann@example.com'), or None print(trailer.msgid) # which message offered it first print(trailer.date) # and when summary.person_trailers # endorsements: Reviewed-by, Acked-by, Tested-by ... summary.utility_trailers # bookkeeping: Fixes, Link, Closes ... summary.trailer_names # frozenset of every lowercased name seen ``` A reply's endorsement counts only if the sender signs it themselves: you may tag on your own behalf, not on somebody else's. This is b4's rule, and it keeps a quoted tag or a pasted device-tree snippet from being read as a review. Patches are not subject to it -- a patch collects everybody's tags, and the person sending it signs for them. This is **not** a signature check. liblore does no DKIM by design, so a trailer is reported as *claiming* to come from the person it names. If you need proof, that is what b4's attestation is for. #### Asking About People Two spellings of one address are recognised -- an extended local part, and a domain one label deeper -- which covers most of what a mailing list does to an address: ```python from liblore.series import email_matches, name_matches email_matches('ann+kernel@example.com', 'ann@example.com') # True email_matches('ann@linux.intel.com', 'ann@intel.com') # True name_matches('Amara Okonkwo', 'Okonkwo, Amara') # True name_matches('Bob Reviewer (Arm)', 'Bob Reviewer') # True ``` Knowing that two *unrelated* addresses belong to one person needs a directory -- a MAINTAINERS file, a config entry, a mapping you keep yourself -- so `Identity` is where you hand that in: ```python from liblore.series import Identity me = Identity( addrs=('ann@example.com', 'ann@corp.example.net'), names=('Ann Submitter',), ) summary.messages_by(me) # every message this person sent summary.trailers_by(me) # every trailer attributed to them me.matches('ann@example.com') # True ``` `messages_by()` and `trailers_by()` also take a bare address or a `(name, email)` pair, in which case only that spelling is matched. `participants` stays literal: one entry per distinct From address, so somebody who wrote from two addresses appears twice. That is what the thread contains, and collapsing them is your decision, not ours. #### Many Threads at Once A search result is a flat bag of messages from many threads. `summarize_threads()` splits it up and summarises each one: ```python from liblore.series import summarize_threads with LoreNode() as node: msgs = node.get_thread_by_query('s:"net: fix"', months=1) for summary in summarize_threads(msgs): print(summary.subject, summary.count) ``` Grouping walks the In-Reply-To and References headers, so a thread is found even when its root is missing from the result. If you want the groups without the summaries, use `group_by_thread()`, which returns a list of message lists. #### JSON Both summaries serialise to plain JSON-safe dictionaries, stamped with a schema version so stored records can be read back later: ```python import json from liblore.series import SCHEMA_VERSION print(json.dumps(summary.to_dict(), indent=2)) print(SCHEMA_VERSION) ``` Timestamps come out as ISO 8601 strings, and the key set is fixed: a field with nothing in it is present and empty rather than missing. #### Parsing a Subject on Its Own If all you have is a subject line, `parse_patch_subject()` decomposes the brackets: ```python from liblore.series import parse_patch_subject parsed = parse_patch_subject('[PATCH RFC v3 2/5] net: fix the thing') parsed.title # 'net: fix the thing' parsed.version # 3 parsed.counter # 2 parsed.expected # 5 parsed.prefixes # ('RFC',) parsed.is_patch # True parsed.is_cover # False -- 0/5 would be True parsed.is_reply # True for a 'Re:' subject parsed.version_inferred # False here: the subject said v3 parsed.counters_inferred # False here: the subject said 2/5 ``` The `*_inferred` flags let you tell a stated `1/1` from an assumed one, which matters when a lone patch and a one-patch series need handling differently. ### Utility Functions The `liblore.utils` module provides lower-level helpers for parsing and inspecting email messages. #### Header Handling ```python from liblore.utils import clean_header, get_clean_msgid # Decode RFC 2047 encoded headers decoded = clean_header('=?utf-8?q?Re=3A_Some_Subject?=') # Extract a clean message ID (without angle brackets) from a message msgid = get_clean_msgid(msg) # reads Message-Id by default msgid = get_clean_msgid(msg, 'In-Reply-To') # or any other header ``` #### Parsing Messages ```python from liblore.utils import parse_message # Parse raw email bytes into an EmailMessage msg = parse_message(raw_bytes) ``` #### Extracting Message Content ```python from liblore.utils import ( msg_get_subject, msg_get_author, msg_get_inbody_author, msg_get_payload, msg_get_recipients, strip_reply_prefixes, ) # Get the decoded subject line subject = msg_get_subject(msg) # Strip [PATCH v3 2/5] and Re: prefixes to get the bare subject. # A subsystem prefix such as "mm:" is part of the subject and stays. bare = msg_get_subject(msg, strip_prefixes=True) # Or remove only the reply markers from a subject line you already have rest, is_reply = strip_reply_prefixes('Re: mm: fix the widget') # -> ('mm: fix the widget', True) # Get the author as a (name, email) tuple name, addr = msg_get_author(msg) # Lists that rewrite From to survive DMARC keep the original sender in # X-Original-From, which is preferred over the rewritten header -- so # this reports the person, not the mailing list. Note that the header # is unauthenticated: it says who a message claims to be from. # Prefer a git-style in-body "From:" line, which is what git am would # record as the author of a patch sent on somebody else's behalf name, addr = msg_get_author(msg, use_inbody=True) # Or read that in-body header on its own; None if the message has none author = msg_get_inbody_author(msg) # Get the plain-text body, stripping the signature body = msg_get_payload(msg) # Get the body without quoted lines or signature body = msg_get_payload(msg, strip_quoted=True, strip_signature=True) # Get all recipient addresses (To + Cc + From), as a lowercased set recipients = msg_get_recipients(msg) ``` #### Patch Bodies and Trailers A patch body has structure: in-body git headers at the top, the commit message, a trailing block of `Name: value` trailers, then everything below the `---` separator. `split_body_parts()` takes it apart, and both it and `find_trailers()` are ports of b4's logic, so the corner cases b4 has learned about are handled the same way. ```python from liblore.utils import body_has_diff, find_trailers, split_body_parts # Is this a patch or just a reply? body_has_diff(body) parts = split_body_parts(body) parts.githeaders # [('From', 'Ann Submitter '), ...] parts.message # the commit message, without the headers or trailers parts.trailers # [('Signed-off-by', 'Ann Submitter ')] parts.basement # everything below the '---': diffstat and diff parts.signature # text below a conformant '-- ' marker # Scan any block of text for trailers. Returns the trailers it found and # the lines it could not read as one. trailers, others = find_trailers(text) # In followup mode the rules are stricter, for scanning a reply that has # no patch structure to lean on trailers, others = find_trailers(reply_body, followup=True) ``` Trailers may be indented, since some maintainers send them that way, but one behind a `>` quoting marker is somebody else's and does not count. For most purposes you want `liblore.series` instead, which uses these and gives you classified `Trailer` objects with the sender check already applied. #### Email Serialization These functions replace Python's buggy `as_bytes()` with battle-tested serialization that correctly handles RFC 2047 header encoding, line wrapping, and non-ASCII display names. ```python from liblore.utils import format_addrs, wrap_header, get_msg_as_bytes # Format (name, email) pairs into an RFC 5322 address string formatted = format_addrs( [ ('', 'foo@example.com'), ('Foo Bar', 'bar@example.com'), ] ) # -> 'foo@example.com, Foo Bar ' # Wrap and RFC 2047-encode a header for SMTP hdr_bytes = wrap_header(('Subject', 'Hello world')) # Serialize a full message to bytes with proper encoding msg_bytes = get_msg_as_bytes(msg) # \n line endings (dry-run) msg_bytes = get_msg_as_bytes(msg, nl='\r\n') # \r\n for SMTP ``` #### Sorting and Threading ```python from liblore.utils import sort_msgs_by_received, get_strict_thread # Sort messages by their Received timestamp (falls back to Date) sorted_msgs = sort_msgs_by_received(msgs) # Filter a list of messages to only those in a specific thread thread = get_strict_thread(msgs, '20250101-example@kernel.org') # Break the thread at msgid, ignoring its parent references thread = get_strict_thread(msgs, msgid, noparent=True) ``` #### Thread Minimization ```python from liblore.utils import minimize_thread # Strip excessive quoting and non-essential headers for compact display minimized = minimize_thread(msgs) # Customize which headers to keep minimized = minimize_thread(msgs, keep_headers=('From', 'Subject', 'Date')) # Aggressively reduce long quotes to just the last paragraph minimized = minimize_thread(msgs, reduce_quote_context=True) ``` `minimize_thread()` creates lightweight copies of each message: it keeps only essential headers (From, To, Cc, Subject, Date, Message-ID, Reply-To, In-Reply-To by default), strips multi-level quotes and trailing quoted blocks, and drops messages that become empty after processing. Messages containing diffs or diffstats are preserved as-is. When `reduce_quote_context=True`, long quoted blocks preceding a reply are trimmed to just the last paragraph, with earlier content replaced by a `> [... skip NN lines ...]` marker. This only applies when more than 5 lines would be skipped. #### Mbox Splitting ```python from liblore.utils import split_mbox, split_and_dedupe # Split mboxrd bytes into a list of EmailMessage objects msgs = split_mbox(mbox_bytes) # Split and deduplicate by Message-ID (first occurrence wins) msgs = split_and_dedupe(mbox_bytes) ``` When you need raw message bytes without the cost of parsing, use the `_as_bytes` variants: ```python from liblore.utils import split_mbox_as_bytes, split_and_dedupe_as_bytes # Split mboxrd bytes into a list of raw message byte strings chunks = split_mbox_as_bytes(mbox_bytes) # Split, deduplicate, and return raw bytes (no email parsing) chunks = split_and_dedupe_as_bytes(mbox_bytes) ``` The `_as_bytes` functions perform mboxrd unescaping and (for dedupe) Message-ID/List-Id extraction directly on raw bytes, so they skip the email parser entirely. The regular `split_mbox` and `split_and_dedupe` are thin wrappers that parse the results. #### URL Helpers ```python from liblore.utils import get_msgid_from_url # Extract a message ID from a lore URL msgid = get_msgid_from_url('https://lore.kernel.org/all/20250101-example@kernel.org/') # -> "20250101-example@kernel.org" # Also works with bare message IDs msgid = get_msgid_from_url('<20250101-example@kernel.org>') # -> "20250101-example@kernel.org" ``` ### Exceptions All exceptions inherit from `LibloreError`, so you can catch them broadly or handle specific cases: ```python from liblore import LibloreError, NotOnMirrorError, RemoteError, PublicInboxError try: msgs = node.get_thread_by_msgid('nonexistent@example.com') except NotOnMirrorError: # A partial mirror doesn't have it, and its upstream archive could not # be reached (see "Partial Mirrors"). Also a RemoteError. ... except RemoteError as ex: # HTTP request failed (server error, network issue, etc.) if ex.status_code == 404: ... # the server answered: it does not have this elif ex.status_code is None: ... # no answer at all: connection error or timeout ... except PublicInboxError: # Something went wrong with the public-inbox operation ... except LibloreError: # Catch-all for any liblore error ... ``` ## Development Install with development dependencies: ```shell uv sync --all-extras --all-groups ``` Run the test suite: ```shell uv run --all-extras --all-groups pytest ``` Type checking: ```shell uv run --all-extras --all-groups ty check uv run --all-extras --all-groups mypy . uv run --all-extras --all-groups pyright ``` Linting: ```shell uv run --all-extras --all-groups ruff format --check uv run --all-extras --all-groups ruff check ``` ## Bug Reports Send bug reports and patches to [tools@kernel.org](mailto:tools@kernel.org). ## Licence GPL-2.0-or-later. See [LICENSES/GPL-2.0-or-later.txt](LICENSES/GPL-2.0-or-later.txt) for the full text. Copyright The Linux Foundation. python-liblore-0.10.1/ci-matrix.sh000077500000000000000000000041751526032434100170030ustar00rootroot00000000000000#!/usr/bin/env sh set -eu # Run an import smoke check and pytest under every interpreter version in the # supported range (see `requires-python` in pyproject.toml). This is not part # of ci.sh because materialising a venv per version is slow; invoke this # before releases and after changes that could depend on version-specific # runtime behaviour (import-time annotations, stdlib APIs, etc.). # # Missing interpreters are pulled automatically from uv's managed Python # cache (python-build-standalone), so no system packages or sudo are needed. # # Override the version list: PYTHONS="3.9 3.14" ./ci-matrix.sh # # The newest version may be a pre-release, so that it is tested before it # ships. uv keeps using an installed pre-release until it is upgraded: run # `uv python install --upgrade 3.15` once the final release is out. PYTHONS="${PYTHONS:-3.9 3.10 3.11 3.12 3.13 3.14 3.15}" # Install any requested interpreters that are missing. This is an explicit # step because a dev may have set UV_PYTHON_DOWNLOADS=manual globally to # prevent `uv sync` from reaching out to the network mid-workflow; running # it once up front scopes the network access to this script. # shellcheck disable=SC2086 uv python install $PYTHONS # Collect failures so the run reports a complete matrix instead of bailing on # the first broken interpreter. failed="" for py in $PYTHONS; do printf '\n=== Python %s ===\n' "$py" # Each version gets its own project environment so switching interpreters # does not thrash the default .venv and each sync is incremental. UV_PROJECT_ENVIRONMENT=".venv-$py" export UV_PROJECT_ENVIRONMENT if ! uv sync --all-extras --all-groups --python "$py"; then failed="$failed $py(sync)" continue fi if ! uv run python -c 'import liblore, sys; print("import liblore OK on", sys.version.split()[0])'; then failed="$failed $py(import)" continue fi if ! uv run pytest --durations=20; then failed="$failed $py(pytest)" continue fi done if [ -n "$failed" ]; then printf '\nFAILURES:%s\n' "$failed" exit 1 fi printf '\nAll interpreters passed: %s\n' "$PYTHONS" python-liblore-0.10.1/ci.sh000077500000000000000000000002221526032434100154660ustar00rootroot00000000000000#!/usr/bin/env sh set -eu uv run ruff format --check uv run ruff check uv run ty check uv run mypy . uv run pyright uv run pytest --durations=0 python-liblore-0.10.1/pyproject.toml000066400000000000000000000040421526032434100174540ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] name = "liblore" version = "0.10.1" description = "Shared library for public-inbox / lore.kernel.org access" readme = "README.md" requires-python = ">=3.9" license = "GPL-2.0-or-later" license-files = ["LICENSES/*.txt"] authors = [ { name = "Konstantin Ryabitsev", email = "konstantin@linuxfoundation.org" } ] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Topic :: Communications :: Email", "Topic :: Communications :: Email :: Mailing List Servers", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ "requests>=2.31", ] [project.optional-dependencies] auth = ["authheaders>=0.15"] [project.urls] Homepage = "https://git.kernel.org/pub/scm/utils/liblore/liblore.git" Repository = "https://git.kernel.org/pub/scm/utils/liblore/liblore.git" [dependency-groups] dev = [ "authheaders>=0.15", "build", "mypy", "pyright", "pytest", "pytest-asyncio", "responses", "ruff", "ty", "types-requests", "typing-extensions", ] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "--tb=short" asyncio_default_fixture_loop_scope = "function" [tool.mypy] strict = true [tool.pyright] # pyright automatically ignores virtual environments and other directories, but # once we specify `exclude`, we're on our own. The matrix in ci-matrix.sh # creates .venv- environments, so match them all. See # https://github.com/microsoft/pyright/issues/9057#issuecomment-2366938099. exclude = [".venv*"] typeCheckingMode = "strict" executionEnvironments = [ # We're testing private APIs quite a bit. { root = "tests", reportPrivateUsage = false }, ] [tool.ty.rules] all = "error" [tool.ruff.lint] extend-select = ["ARG", "I"] [tool.ruff.format] quote-style = "single" python-liblore-0.10.1/requirements.txt000066400000000000000000000364631526032434100200400ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile --generate-hashes --python-version 3.9 --output-file=requirements.txt pyproject.toml certifi==2026.7.22 \ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via requests charset-normalizer==3.5.2 \ --hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e \ --hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf \ --hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 \ --hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 \ --hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 \ --hash=sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848 \ --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 \ --hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 \ --hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 \ --hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 \ --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 \ --hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e \ --hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 \ --hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 \ --hash=sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787 \ --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 \ --hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 \ --hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 \ --hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 \ --hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 \ --hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d \ --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f \ --hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 \ --hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 \ --hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f \ --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 \ --hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 \ --hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d \ --hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 \ --hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 \ --hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 \ --hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 \ --hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd \ --hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 \ --hash=sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc \ --hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 \ --hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 \ --hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac \ --hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 \ --hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c \ --hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 \ --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \ --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 \ --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 \ --hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \ --hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa \ --hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 \ --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \ --hash=sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2 \ --hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 \ --hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e \ --hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd \ --hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 \ --hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 \ --hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c \ --hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c \ --hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab \ --hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 \ --hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 \ --hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 \ --hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 \ --hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be \ --hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b \ --hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 \ --hash=sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2 \ --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a \ --hash=sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a \ --hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a \ --hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c \ --hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 \ --hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 \ --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e \ --hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 \ --hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 \ --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 \ --hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e \ --hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 \ --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c \ --hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b \ --hash=sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0 \ --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \ --hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a \ --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \ --hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 \ --hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 \ --hash=sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac \ --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc \ --hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 \ --hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf \ --hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d \ --hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f \ --hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c \ --hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc \ --hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 \ --hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 \ --hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade \ --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 \ --hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 \ --hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 \ --hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 \ --hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 \ --hash=sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4 \ --hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 \ --hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 \ --hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 \ --hash=sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03 \ --hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e \ --hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 \ --hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 \ --hash=sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849 \ --hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 \ --hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a \ --hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 \ --hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 \ --hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 \ --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 \ --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e \ --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 \ --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 \ --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 \ --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 \ --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 \ --hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 \ --hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f \ --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c \ --hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d \ --hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad \ --hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 \ --hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb \ --hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c \ --hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc \ --hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c \ --hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 \ --hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf \ --hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 \ --hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f \ --hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 \ --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a \ --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d \ --hash=sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a \ --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 \ --hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 \ --hash=sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f \ --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e \ --hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 \ --hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 \ --hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 \ --hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc \ --hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 \ --hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd \ --hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 \ --hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d \ --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 \ --hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c \ --hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 \ --hash=sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50 \ --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 \ --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 \ --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 \ --hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 \ --hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 \ --hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd \ --hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 \ --hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d \ --hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 \ --hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 \ --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 \ --hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd \ --hash=sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341 \ --hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 \ --hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d \ --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f # via requests idna==3.20 \ --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c # via requests requests==2.32.5 \ --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf # via liblore (pyproject.toml) urllib3==2.6.3 \ --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 # via requests python-liblore-0.10.1/src/000077500000000000000000000000001526032434100153275ustar00rootroot00000000000000python-liblore-0.10.1/src/liblore/000077500000000000000000000000001526032434100167575ustar00rootroot00000000000000python-liblore-0.10.1/src/liblore/__init__.py000066400000000000000000000055661526032434100211040ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2025-2026 The Linux Foundation """liblore — shared library for public-inbox / lore.kernel.org access.""" from __future__ import annotations import email.charset import email.policy import enum from email.message import EmailMessage __version__ = '0.10.1' # Email policy used for parsing and serialising messages emlpolicy = email.policy.EmailPolicy( utf8=True, cte_type='8bit', max_line_length=None, message_factory=EmailMessage, ) # Disable base64 encoding for utf-8 content email.charset.add_charset('utf-8', email.charset.SHORTEST, None, 'utf-8') class LibloreError(Exception): """Base exception for all liblore errors.""" class RemoteError(LibloreError): """Raised when a remote HTTP request fails. :attr:`status_code` is the HTTP status the server answered with, or ``None`` when no answer arrived at all (connection error, timeout). It lets callers tell "the server says it doesn't have this" (404) apart from "the server could not be reached". """ def __init__(self, message: str, *, status_code: int | None = None) -> None: super().__init__(message) self.status_code = status_code class NotOnMirrorError(RemoteError): """A partial mirror doesn't have it, and its upstream could not be reached. Raised only by a node that knows it is a partial mirror (``X-Archive-Coverage: partial``). The thing asked for may well exist: the mirror doesn't have it, and the upstream archive that would have it did not answer. :attr:`status_code` is the upstream's status, or ``None`` when it did not answer at all. """ class Source(enum.Enum): """Where an answer came from, as :attr:`LoreNode.last_source` reports it. ``LOCAL`` Answered by this node: its server, or its cache. On a node that is not a partial mirror, such as lore.kernel.org itself, every answer is ``LOCAL``: the word means "this node", not "a local mirror". ``UPSTREAM`` Answered by the upstream archive of a partial mirror, because the mirror didn't have it or could not be reached. ``DEGRADED`` Answered by a partial mirror because its upstream archive could not be reached, so it may be incomplete. Only upstream-first searches (``partial_search='upstream'``) give it. """ LOCAL = 'local' UPSTREAM = 'upstream' DEGRADED = 'degraded' class PublicInboxError(LibloreError): """Raised when a public-inbox operation fails.""" class OperationCancelledError(LibloreError): """Raised when an in-flight or pending request was cancelled by the caller.""" from liblore.node import LoreNode __all__ = [ 'LibloreError', 'LoreNode', 'NotOnMirrorError', 'OperationCancelledError', 'PublicInboxError', 'RemoteError', 'Source', '__version__', 'emlpolicy', ] python-liblore-0.10.1/src/liblore/node.py000066400000000000000000003006551526032434100202670ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2025-2026 The Linux Foundation """LoreNode — primary API for interacting with public-inbox servers.""" from __future__ import annotations import concurrent.futures import contextlib import functools import gzip import hashlib import json import logging import os import re import secrets import subprocess import threading import time import urllib.parse import warnings from datetime import datetime, timezone from email.message import EmailMessage from typing import TYPE_CHECKING, Protocol, TypedDict, cast import requests from liblore import ( LibloreError, NotOnMirrorError, OperationCancelledError, RemoteError, Source, ) from liblore.utils import ( get_strict_thread, sort_msgs_by_received, split_and_dedupe, ) logger = logging.getLogger(__name__) if TYPE_CHECKING: from collections.abc import Callable, Generator from typing import TypeVar from typing_extensions import ParamSpec, Self, Unpack _P = ParamSpec('_P') _R = TypeVar('_R') _T = TypeVar('_T') class _LoreNodeInitKwargs(TypedDict, total=False): fallback_urls: list[str] | None auto_probe: bool probe_timeout: float probe_ttl: int request_timeout: tuple[float, float] | float | None add_auth_headers: bool cache_dir: str | None cache_ttl: int follow_upstream: bool partial_search: str upstream_holdoff: float allowed_upstreams: list[str] | None class _AuthenticateMessage(Protocol): def __call__( self, msg: bytes, authserv_id: str, *, dkim: bool = ..., dmarc: bool = ..., arc: bool = ..., spf: bool = ..., ) -> str: ... def _get_config_from_git( regexp: str, multivals: list[str] | None = None, ) -> dict[str, str | list[str]]: """Read git config keys matching *regexp* in one shot. Uses ``git config -z --get-regexp`` to fetch all matching keys with NUL-separated output (safe for values containing newlines). Single-valued keys are stored as strings. Keys listed in *multivals* are collected into lists. Returns an empty dict on any failure (git not installed, not in a repo, no matching keys, etc.). """ if multivals is None: multivals = [] try: result = subprocess.run( ['git', 'config', '-z', '--get-regexp', regexp], capture_output=True, text=True, timeout=5, check=False, ) if result.returncode != 0: return {} except Exception: # noqa: BLE001 return {} config: dict[str, str | list[str]] = {} for entry in result.stdout.split('\x00'): if not entry: continue if '\n' in entry: key, value = entry.split('\n', 1) else: key, value = entry, 'true' # Extract the last component: "lore.fallback" → "fallback" cfgkey = key.rsplit('.', maxsplit=1)[-1].lower() if cfgkey in multivals: existing = config.get(cfgkey) if not isinstance(existing, list): existing = [] config[cfgkey] = existing existing.append(value) else: config[cfgkey] = value return config def _get_subsection_config( section: str, subsection: str, multivals: list[str] | None = None, ) -> dict[str, str | list[str]]: """Read git config keys from ``[section "subsection"]``. Like :func:`_get_config_from_git`, but handles subsections whose names contain dots (e.g. URLs). The variable name is extracted by stripping the known ``section.subsection.`` prefix rather than splitting on the last dot. Returns an empty dict on any failure. """ if multivals is None: multivals = [] prefix = f'{section}.{subsection}.' escaped = re.escape(prefix) try: result = subprocess.run( ['git', 'config', '-z', '--get-regexp', f'^{escaped}'], capture_output=True, text=True, timeout=5, check=False, ) if result.returncode != 0: return {} except Exception: # noqa: BLE001 return {} config: dict[str, str | list[str]] = {} for entry in result.stdout.split('\x00'): if not entry: continue if '\n' in entry: key, value = entry.split('\n', 1) else: key, value = entry, 'true' varname = key[len(prefix) :].lower() if varname in multivals: existing = config.get(varname) if not isinstance(existing, list): existing = [] config[varname] = existing existing.append(value) else: config[varname] = value return config def _git_bool(value: str) -> bool | None: """Read a git config boolean the way git does. ``true``/``yes``/``on`` and ``false``/``no``/``off`` in any case, an empty value (false), or an integer (true unless 0). A bare key with no ``=`` is already turned into ``'true'`` by the config readers. Returns ``None`` for anything else, so the caller keeps its default. """ val = value.strip().lower() if val in ('true', 'yes', 'on'): return True if val in ('false', 'no', 'off', ''): return False try: return int(val) != 0 except ValueError: return None # Upstreams followed without being listed in allowupstream (section 2.4 of # the partial mirrors spec). _BUILTIN_UPSTREAMS = frozenset({'https://lore.kernel.org'}) # Where a partial node sends whole-archive searches first. _PARTIAL_SEARCH_MODES = ('local', 'upstream') # Hosts already warned about, so each untrusted upstream is logged once # per process, not once per response. _warned_untrusted: set[str] = set() # Outages already warned about, as (kind, node URL). _warned_outage: set[tuple[str, str]] = set() _warned_lock = threading.Lock() def _warn_outage_once(kind: str, url: str, msg: str, *args: object) -> None: """Log warning *msg* the first time this process sees *kind* for *url*.""" with _warned_lock: first = (kind, url) not in _warned_outage _warned_outage.add((kind, url)) if first: logger.warning(msg, *args) # How bad each source is: an operation reports the worst one it saw. _SOURCE_RANK = {Source.LOCAL: 0, Source.UPSTREAM: 1, Source.DEGRADED: 2} class _OpState(threading.local): """Per-thread state of the operation in flight (see :meth:`LoreNode._operation`). The class attributes are the defaults every thread starts with. """ depth: int = 0 generation: int | None = None source: Source = Source.LOCAL as_of: float | None = None last_source: Source | None = None last_as_of: float | None = None def _unreachable(ex: RemoteError) -> bool: """Whether *ex* means the server gave no usable answer. That is no answer at all (connection error, timeout), or a 5xx: a mirror answering 503 while it restarts is as good as a stopped one. """ return ex.status_code is None or ex.status_code >= 500 class _HeldOffError(RemoteError): """The upstream was not asked: it was unreachable *ago* seconds ago.""" def __init__(self, netloc: str, ago: float) -> None: super().__init__( f'{netloc} was unreachable {ago:.0f} s ago, not trying it again yet' ) self.netloc = netloc self.ago = ago class _Miss(Exception): """This partial mirror doesn't have what was asked (see :meth:`LoreNode._answer`). *upstream* is the node to ask instead. *answer* and *as_of* are what the mirror did answer, when there was an answer worth keeping should the upstream turn out to be unreachable: a whole-archive search finds some of the hits, or none. *missing* starts the :class:`~liblore.NotOnMirrorError` message, when the default ``' is not'`` doesn't read well. """ def __init__( self, upstream: LoreNode, answer: object = None, as_of: float | None = None, *, missing: str | None = None, ) -> None: super().__init__(f'not on the mirror, asking {upstream.url}') self.upstream = upstream self.answer = answer self.as_of = as_of self.missing = missing def _normalize_url(url: str) -> str: """Normalize *url* for comparison. Lower-cases the scheme and host, drops the default port (``:80`` for http, ``:443`` for https) and any trailing ``/``. The path keeps its case. Raises :class:`ValueError` for a URL without a host, with an invalid port, or with user info (which would otherwise be lost). """ parsed = urllib.parse.urlsplit(url.strip()) scheme = parsed.scheme.lower() host = parsed.hostname if not scheme or not host: raise ValueError(f'not an absolute URL: {url!r}') if parsed.username is not None or parsed.password is not None: raise ValueError(f'URL has user info: {url!r}') if ':' in host: # IPv6 literal: urlsplit() dropped the brackets host = f'[{host}]' port = parsed.port if port is not None and (scheme, port) not in (('http', 80), ('https', 443)): host = f'{host}:{port}' return urllib.parse.urlunsplit( (scheme, host, parsed.path.rstrip('/'), parsed.query, parsed.fragment) ) def _url_origin(url: str) -> str: """Return ``scheme://host[:port]`` of an already normalized *url*.""" parsed = urllib.parse.urlsplit(url) return f'{parsed.scheme}://{parsed.netloc}' def _parse_coverage(value: str) -> tuple[str, dict[str, str]]: """Split an ``X-Archive-Coverage`` value into its token and parameters. ``'partial; updated=1790000000'`` gives ``('partial', {'updated': '1790000000'})``. Names are lower-cased; a parameter without ``=`` is skipped. """ token, *parts = value.split(';') params: dict[str, str] = {} for part in parts: name, sep, val = part.partition('=') if sep: params[name.strip().lower()] = val.strip() return token.strip().lower(), params def _header(resp: requests.Response, name: str) -> str | None: """Return response header *name*, or ``None`` when it is not a string.""" value: object = resp.headers.get(name) return value if isinstance(value, str) else None def _partial(resp: requests.Response) -> bool: """Whether *resp* comes from a partial mirror.""" coverage = _header(resp, 'X-Archive-Coverage') return coverage is not None and _parse_coverage(coverage)[0] == 'partial' # An ``updated`` time further ahead of our clock than this is junk, not # clock skew. _UPDATED_MAX_SKEW = 300 def _updated(resp: requests.Response) -> float | None: """Return the ``updated`` time of a partial mirror's answer, or ``None``. It is when the mirror last caught up with its upstream, as a Unix time. ``None`` when *resp* is not from a partial mirror, or the mirror doesn't say; a value that is not a non-negative integer, or is in the future, is ignored. """ coverage = _header(resp, 'X-Archive-Coverage') if coverage is None: return None token, params = _parse_coverage(coverage) value = params.get('updated') if token != 'partial' or value is None: return None # isdigit() alone takes other scripts' digits, which int() reads too. if not (value.isascii() and value.isdigit()): logger.debug('Ignoring updated=%r from %s', value, resp.url) return None updated = int(value) if updated > time.time() + _UPDATED_MAX_SKEW: logger.debug('Ignoring updated=%r from %s: in the future', value, resp.url) return None return float(updated) def _answer_as_of(resp: requests.Response, sent: float) -> float: """How far the answer in *resp*, asked for at *sent*, reaches. A partial mirror's answer reaches its ``updated`` time; any other answer reaches the time the request was sent. Never later than *sent*, so a server clock ahead of ours can't make it skip mail. """ updated = _updated(resp) if updated is None: return sent return min(updated, sent) def _date_string_epoch(since: str) -> float | None: """The Unix time of a ``YYYYMMDDHHMMSS`` *since* string, if it is one. public-inbox accepts looser forms too (``dt:2024..``); those give ``None``, and the answer is then not said to reach back to them. """ try: return ( datetime.strptime(since, '%Y%m%d%H%M%S') .replace(tzinfo=timezone.utc) .timestamp() ) except ValueError: return None def _operation_scope(func: Callable[_P, _R]) -> Callable[_P, _R]: """Run the decorated LoreNode method as one logical operation. Everything the method does — however many HTTP requests it takes — shares a single cancellation scope, so a :meth:`LoreNode.cancel_active` that lands anywhere in the middle (including between requests) stops the whole thing. See :meth:`LoreNode._operation`. """ @functools.wraps(func) def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: node = cast('LoreNode', args[0]) with node._operation(): # pyright: ignore[reportPrivateUsage] return func(*args, **kwargs) return wrapper # A temporary file _write_atomic() left behind when its process was # killed. Versions before 0.10 named it .tmp. _ORPHAN_TMP_RE = re.compile(r'.+\.lore\.(?:cache|upstream)(?:\.[0-9a-f]{16})?\.tmp') # A temporary file younger than this may still be in use by a running # process, so clear_cache() leaves it alone. _ORPHAN_TMP_AGE = 3600 def _write_atomic(path: str, data: bytes) -> None: """Write *data* to *path* so that readers see the old or the new file. The data goes to a temporary file next to *path*, which then replaces it. The temporary name is unique, so processes that write the same file at the same time don't write into each other's file. Unlike :func:`tempfile.mkstemp`, the file gets the same mode as :func:`open` would give it (``0o666`` minus the umask). Raises :class:`OSError`; the temporary file is removed first. """ tmp_path = f'{path}.{secrets.token_hex(8)}.tmp' fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666) try: with os.fdopen(fd, 'wb') as f: f.write(data) os.replace(tmp_path, path) except OSError: try: os.unlink(tmp_path) except OSError: pass raise def _orphan_tmp(dirname: str, entry: str, now: float) -> bool: """Whether *entry* in *dirname* is a stale temporary file of ours.""" if not _ORPHAN_TMP_RE.fullmatch(entry): return False try: mtime = os.stat(os.path.join(dirname, entry)).st_mtime except OSError: return False return now - mtime > _ORPHAN_TMP_AGE class LoreNode: """A connection to a single public-inbox endpoint. Encapsulates HTTP session management and all operations against a public-inbox server. Use as a context manager for automatic resource cleanup:: with LoreNode('https://lore.kernel.org/all') as node: msgs = node.get_thread_by_msgid('test@example.com') """ def __init__( self, url: str = 'https://lore.kernel.org/all', *, fallback_urls: list[str] | None = None, auto_probe: bool = False, probe_timeout: float = 5.0, probe_ttl: int = 3600, request_timeout: tuple[float, float] | float | None = (5.0, 30.0), add_auth_headers: bool = False, cache_dir: str | None = None, cache_ttl: int = 600, follow_upstream: bool = True, partial_search: str = 'local', upstream_holdoff: float = 300.0, allowed_upstreams: list[str] | None = None, ) -> None: self._url = url.rstrip('/') self._session: requests.Session | None = None self._owns_session = False self._request_timeout = request_timeout self._cancel_lock = threading.Lock() self._cancel_generation = 0 self._shutdown_event = threading.Event() self._op_local = _OpState() self._user_agent_plus: str | None = None self._user_agent: str = f'liblore/{__import__("liblore").__version__}' self._cache_dir = cache_dir self._cache_ttl = cache_ttl self._auto_probe = auto_probe self._probe_timeout = probe_timeout self._probe_ttl = probe_ttl self._probe_done = False # Parse the canonical URL into origin (scheme+host) and path suffix parsed = urllib.parse.urlparse(self._url) self._canonical_origin = f'{parsed.scheme}://{parsed.netloc}' # Build the ordered list of origins to try self._all_origins: list[str] = [] for fb in fallback_urls or []: fb = fb.rstrip('/') fb_parsed = urllib.parse.urlparse(fb) if not fb_parsed.scheme or not fb_parsed.netloc: raise LibloreError( f'Invalid fallback URL (expected scheme://host): {fb!r}' ) if fb_parsed.path and fb_parsed.path != '/': raise LibloreError( f'Fallback URL must be a scheme://host origin, ' f'not a full path: {fb!r}. ' f'The path from the primary URL is preserved ' f'automatically.' ) self._all_origins.append(fb) self._all_origins.append(self._canonical_origin) # Partial mirrors: the upstream archive learned from the # X-Archive-* response headers (see _learn_upstream). self._follow_upstream = follow_upstream if partial_search not in _PARTIAL_SEARCH_MODES: raise LibloreError( f'Invalid partial_search (expected local or upstream): ' f'{partial_search!r}' ) self._partial_search = partial_search # Also rejects NaN, which would hold the upstream off forever. if not upstream_holdoff >= 0: raise LibloreError( f'Invalid upstream_holdoff (expected 0 or more seconds): ' f'{upstream_holdoff!r}' ) self._upstream_holdoff = upstream_holdoff trusted = set(_BUILTIN_UPSTREAMS) for au in allowed_upstreams or []: try: norm = _normalize_url(au) except ValueError: norm = '' au_parsed = urllib.parse.urlsplit(norm) if ( au_parsed.scheme not in ('http', 'https') or au_parsed.path or au_parsed.query or au_parsed.fragment ): raise LibloreError( f'Invalid allowed upstream (expected scheme://host): {au!r}' ) trusted.add(norm) self._trusted_upstreams = frozenset(trusted) try: self._norm_url = _normalize_url(self._url) except ValueError: self._norm_url = self._url # Re-entrant: _get_upstream_node() holds it while it calls # _get_upstream_url(), and cancel_active() may run from a signal # handler on a thread that holds it. self._upstream_lock = threading.RLock() self._upstream_url: str | None = None self._upstream_loaded = False # The node for _upstream_url, built on first use. Nodes for an # earlier upstream URL are kept until close(): another thread # may still be using them. self._upstream_node: LoreNode | None = None self._old_upstream_nodes: list[LoreNode] = [] # Held while an upstream node is built, so that two threads # don't build two. Not _upstream_lock: the build runs git, and # cancel_active() must not wait for that. self._upstream_build_lock = threading.Lock() # The upstream URL whose node could not be built, and why. # Guarded by _upstream_lock. self._upstream_build_failed: tuple[str, LibloreError] | None = None # The upstream URL and time.monotonic() of its last unreachable # answer, while it is held off. Guarded by _upstream_lock. self._holdoff_since: tuple[str, float] | None = None # set_user_agent() app and version, passed on to upstream nodes. self._user_agent_app: tuple[str, str] | None = None if cache_dir is not None: os.makedirs(cache_dir, exist_ok=True) self._authenticate_message: _AuthenticateMessage | None = None if add_auth_headers: try: import authheaders self._authenticate_message = authheaders.authenticate_message except ImportError: raise LibloreError( 'authheaders library is required for add_auth_headers. ' 'Install with: pip install liblore[auth]' ) # ----------------------------------------------------------------- # Git config integration # ----------------------------------------------------------------- @classmethod def from_git_config( cls, url: str = 'https://lore.kernel.org/all', **kwargs: Unpack[_LoreNodeInitKwargs], ) -> LoreNode: """Create a :class:`LoreNode` using settings from git config. Looks for per-origin configuration in a ``[liblore ""]`` subsection first, then falls back to ``[lore]`` for lore.kernel.org URLs. This allows different public-inbox servers to have independent mirror and probe settings. Supported keys (in each section): ``fallback`` Multi-valued. Each value is an origin URL prefix (``scheme://host``) to try before the canonical URL. Tried in the order listed. ``autoprobe`` Boolean. When ``true``, automatically probe all origins on the first request and reorder by latency. ``probetimeout`` Float (seconds). Per-origin timeout for probes. Defaults to 5.0. ``probettl`` Integer (seconds). How long cached probe results stay valid. Defaults to 3600. ``requesttimeout`` Float (seconds), or ``connect,read`` for separate connect and read timeouts. Applied to all outgoing HTTP requests. Defaults to ``5.0,30.0``. ``useragentplus`` String. A unique identifier appended to the User-Agent header as ``app/version+IDENTIFIER``. Used by server operators to identify and prioritize known installations. Typically a UUID. Applied automatically when :meth:`set_user_agent` is called without an explicit *plus* argument. ``followupstream`` Boolean. When ``true`` (the default), a server that says it is a partial mirror (``X-Archive-Coverage: partial``) is remembered together with the upstream archive it names in ``X-Archive-Upstream``. ``false`` ignores those headers. Something the partial mirror doesn't have is then fetched from the upstream archive. ``partialsearch`` ``local`` (the default) or ``upstream``. Where a partial mirror sends a whole-archive search first. By default the mirror answers, and the upstream archive is asked only when the mirror finds nothing, so searches stay fast and keep working while the upstream archive is down. A partial mirror can't know what it is missing, so ``upstream`` asks the upstream archive first, for users who want every hit. ``upstreamholdoff`` Seconds (default 300). After the upstream archive of a partial mirror can't be reached, it is not asked again for this long, so a command doesn't wait for a timeout on every call. ``0`` asks it every time. ``allowupstream`` Multi-valued. Each value is an origin (``scheme://host``) to trust as an upstream archive, besides the built-in ``https://lore.kernel.org``. An upstream that is not trusted is ignored, with one warning per process. Lookup order: 1. ``[liblore ""]`` — per-origin subsection 2. ``[lore]`` — only for ``lore.kernel.org`` URLs (backwards compatibility) Any keyword argument passed explicitly takes precedence over the git config value. Failures reading git config (git not installed, not in a repo, keys missing) are silently ignored. Example git config:: # Per-origin configuration (recommended) [liblore "https://lore.kernel.org"] fallback = https://tor.lore.kernel.org fallback = https://sea.lore.kernel.org autoprobe = true useragentplus = 550e8400-e29b-41d4-a716-446655440000 [liblore "https://subspace.kernel.org"] fallback = https://subspace-mirror.kernel.org # Legacy shorthand for [liblore "https://lore.kernel.org"] [lore] fallback = https://tor.lore.kernel.org autoprobe = true Example usage:: with LoreNode.from_git_config() as node: msgs = node.get_thread_by_msgid('test@example.com') """ parsed = urllib.parse.urlparse(url) origin = f'{parsed.scheme}://{parsed.netloc}' # Try [liblore ""] first, fall back to [lore] for # lore.kernel.org. gitcfg = _get_subsection_config( 'liblore', origin, multivals=['fallback', 'allowupstream'], ) if not gitcfg and parsed.netloc == 'lore.kernel.org': gitcfg = _get_config_from_git( r'^lore\.', multivals=['fallback', 'allowupstream'] ) if 'fallback_urls' not in kwargs: fallbacks = gitcfg.get('fallback') if isinstance(fallbacks, list) and fallbacks: kwargs['fallback_urls'] = fallbacks if 'auto_probe' not in kwargs: val = gitcfg.get('autoprobe') if isinstance(val, str): flag = _git_bool(val) if flag is None: logger.debug('Ignoring invalid autoprobe value: %r', val) else: kwargs['auto_probe'] = flag if 'probe_timeout' not in kwargs: val = gitcfg.get('probetimeout') if isinstance(val, str): try: kwargs['probe_timeout'] = float(val) except ValueError: pass if 'probe_ttl' not in kwargs: val = gitcfg.get('probettl') if isinstance(val, str): try: kwargs['probe_ttl'] = int(val) except ValueError: pass if 'request_timeout' not in kwargs: val = gitcfg.get('requesttimeout') if isinstance(val, str): try: if ',' in val: connect, read = val.split(',', 1) kwargs['request_timeout'] = ( float(connect), float(read), ) else: kwargs['request_timeout'] = float(val) except ValueError: pass if 'follow_upstream' not in kwargs: val = gitcfg.get('followupstream') if isinstance(val, str): flag = _git_bool(val) if flag is None: logger.debug('Ignoring invalid followupstream value: %r', val) else: kwargs['follow_upstream'] = flag if 'partial_search' not in kwargs: val = gitcfg.get('partialsearch') if isinstance(val, str): mode = val.strip().lower() if mode in _PARTIAL_SEARCH_MODES: kwargs['partial_search'] = mode else: logger.debug('Ignoring invalid partialsearch value: %r', val) if 'upstream_holdoff' not in kwargs: val = gitcfg.get('upstreamholdoff') if isinstance(val, str): try: holdoff = float(val) except ValueError: holdoff = -1.0 if holdoff >= 0: kwargs['upstream_holdoff'] = holdoff else: logger.debug('Ignoring invalid upstreamholdoff value: %r', val) if 'allowed_upstreams' not in kwargs: allowed = gitcfg.get('allowupstream') if isinstance(allowed, list) and allowed: kwargs['allowed_upstreams'] = allowed node = cls(url, **kwargs) val = gitcfg.get('useragentplus') if isinstance(val, str) and val: node._user_agent_plus = val return node # ----------------------------------------------------------------- # Session management # ----------------------------------------------------------------- @property def user_agent_plus(self) -> str | None: """The ``lore.useragentplus`` value from git config, or None. Only populated when the node was created via :meth:`from_git_config`. Downstream projects can use this to include the same tracking identifier in their own user-agent strings (e.g. for git HTTP or CLI tools). """ return self._user_agent_plus def set_user_agent( self, app_name: str, version: str, plus: str | None = None ) -> None: """Set the User-Agent to ``app_name/version`` (optionally ``+plus``). When *plus* is not provided, the value from ``lore.useragentplus`` in git config is used (if it was loaded via :meth:`from_git_config`). """ self._user_agent = f'{app_name}/{version}' effective_plus = plus or self._user_agent_plus if effective_plus: self._user_agent += f'+{effective_plus}' logger.debug('Set user-agent to: %s', self._user_agent) if self._session is not None and self._owns_session: self._session.headers.update({'User-Agent': self._user_agent}) # The upstream gets no plus: it identifies this installation to # this server's operator, and must not go to another host just # because a mirror named it. with self._upstream_lock: self._user_agent_app = (app_name, version) for node in self._upstream_nodes(): node.set_user_agent(app_name, version) def set_requests_session(self, session: requests.Session) -> None: """Inject a pre-existing :class:`requests.Session`. The session's User-Agent header is **not** overwritten — the caller owns it. """ self._session = session self._owns_session = False logger.debug('Using caller-provided requests session') for node in self._upstream_nodes(): node.set_requests_session(session) def _get_session(self) -> requests.Session: """Return the session, creating one if needed.""" if self._session is None: self._session = requests.Session() self._session.headers.update({'User-Agent': self._user_agent}) self._owns_session = True return self._session def close(self) -> None: """Close the session if we created it (not externally injected). Also closes the upstream nodes of a partial mirror. """ if self._session is not None and self._owns_session: self._session.close() self._session = None self._owns_session = False with self._upstream_lock: nodes = self._upstream_nodes() self._upstream_node = None self._old_upstream_nodes = [] for node in nodes: node.close() def _abort_owned_session(self) -> None: """Close and drop the owned session so blocked socket reads raise. An injected session (``set_requests_session``) is intentionally *not* closed — the caller owns its lifecycle. """ if self._session is not None and self._owns_session: try: self._session.close() except Exception as exc: # noqa: BLE001 logger.debug('Ignoring error closing owned session: %s', exc) self._session = None self._owns_session = False @contextlib.contextmanager def _operation(self) -> Generator[None, None, None]: """Delimit one logical operation for cancellation purposes. On outermost entry, refuses to start when the node is shut down and snapshots the current cancellation generation for this thread. :meth:`cancel_active` bumps the generation, so an operation that was in flight when it landed sees the mismatch at its next request and stays cancelled for the rest of its run, while operations started afterwards proceed normally. Re-entrant per thread: nested scopes join the outermost operation. The outermost operation also collects where its answers came from and how far they reach, and publishes the result as :attr:`last_source` and :attr:`last_as_of` when it succeeds. """ depth = self._op_local.depth if depth == 0: self._op_local.last_source = None self._op_local.last_as_of = None if self._shutdown_event.is_set(): raise OperationCancelledError('Node is shut down') self._op_local.generation = self._cancel_generation self._op_local.source = Source.LOCAL self._op_local.as_of = None self._op_local.depth = depth + 1 try: yield finally: self._op_local.depth = depth if depth == 0: self._op_local.generation = None # Only reached when the operation succeeded. if depth == 0: self._op_local.last_source = self._op_local.source self._op_local.last_as_of = self._op_local.as_of def _record_source(self, source: Source) -> None: """Note that the current operation got an answer from *source*.""" if _SOURCE_RANK[source] > _SOURCE_RANK[self._op_local.source]: self._op_local.source = source def _record_as_of(self, as_of: float | None) -> None: """Note that the current operation got an answer reaching *as_of*. The operation reaches only as far as its oldest answer. """ if as_of is None: return current = self._op_local.as_of if current is None or as_of < current: self._op_local.as_of = as_of def _last_as_of(self) -> float | None: """:attr:`last_as_of` as a Unix time.""" return self._op_local.last_as_of def _cancelled(self) -> bool: """Whether the current thread's operation has been cancelled.""" if self._shutdown_event.is_set(): return True generation = self._op_local.generation if generation is None: return False return generation != self._cancel_generation def cancel_active(self) -> None: """Cancel all operations currently in flight. Closes the owned session so a thread blocked in a socket read raises immediately, and marks every operation currently in progress as cancelled: each raises :class:`~liblore.OperationCancelledError` at its next request and stays cancelled for the rest of its run. Operations started after this call are unaffected — there is no sticky flag to reset. Thread-safe; safe to call from a thread other than the ones issuing requests. """ with self._cancel_lock: self._cancel_generation += 1 self._abort_owned_session() for node in self._upstream_nodes(): node.cancel_active() def shutdown(self) -> None: """Cancel active operations and refuse all new ones. The terminal state for application exit: everything in flight is cancelled as in :meth:`cancel_active`, and any operation started afterwards raises :class:`~liblore.OperationCancelledError` immediately, so racing workers fail fast instead of opening new connections. Cannot be undone. Thread-safe. """ self._shutdown_event.set() self._abort_owned_session() for node in self._upstream_nodes(): node.shutdown() @property def is_shutdown(self) -> bool: """Whether :meth:`shutdown` has been called.""" return self._shutdown_event.is_set() def cancel(self) -> None: """Deprecated alias for :meth:`cancel_active`. .. deprecated:: 0.9 Cancellation is operation-scoped and no longer sticky; use :meth:`cancel_active`, or :meth:`shutdown` on application exit. """ warnings.warn( 'cancel() is deprecated; use cancel_active() or shutdown()', DeprecationWarning, stacklevel=2, ) self.cancel_active() def reset_cancel(self) -> None: """Deprecated no-op. .. deprecated:: 0.9 Cancellation is operation-scoped: :meth:`cancel_active` only affects operations already in flight, so there is no sticky flag to reset before starting a new one. """ warnings.warn( 'reset_cancel() is deprecated and does nothing; ' 'cancellation is operation-scoped and needs no reset', DeprecationWarning, stacklevel=2, ) def __enter__(self) -> Self: return self def __exit__(self, *_args: object) -> None: self.close() @property def url(self) -> str: """The base URL of this public-inbox endpoint.""" return self._url @property def hostname(self) -> str: """The hostname extracted from the URL, for logging and display.""" return urllib.parse.urlparse(self._url).hostname or self._url @property def last_source(self) -> Source | None: """Where the answer to this thread's last call came from. Set when a call on this node returns, for the calling thread only, so each thread sees its own calls. ``None`` before the first call, and after a call that raised: the exception says what happened. A call that got several answers, such as a batch, reports the worst: :attr:`~liblore.Source.DEGRADED`, then :attr:`~liblore.Source.UPSTREAM`, then :attr:`~liblore.Source.LOCAL`. On a node that is not a partial mirror, every answer is :attr:`~liblore.Source.LOCAL`. """ return self._op_local.last_source @property def last_as_of(self) -> datetime | None: """How far the answer to this thread's last call reaches (UTC). Mail that the archive received before this time is in the answer. For a partial mirror's answer, this is when the mirror last caught up with its upstream (the ``updated`` time it sends). For any other answer, it is when the request was sent. A call that got several answers, such as a batch, reports the oldest. An answer from the cache reaches when it was cached. Set by the same rules as :attr:`last_source`: per thread, ``None`` before the first call and after a call that raised. Always set by :meth:`get_thread_updates_since`, and by the other methods that fetch messages; ``None`` after :meth:`request`. A poller should pass it as the next *since* of :meth:`get_thread_updates_since`, instead of its own clock. """ as_of = self._last_as_of() if as_of is None: return None return datetime.fromtimestamp(as_of, timezone.utc) @property def upstream_url(self) -> str | None: """The upstream archive this partial mirror named, or ``None``. ``None`` for a node that is not a partial mirror, or with ``follow_upstream=False``. """ return self._get_upstream_url() @property def _netloc(self) -> str: """``host[:port]`` of the URL, for messages.""" return urllib.parse.urlsplit(self._url).netloc or self._url @property def origins(self) -> list[str]: """All configured origins in current probe order. The list includes fallback origins followed by the canonical origin. After :meth:`probe_origins` runs, the list is reordered fastest-first. Returns a copy to prevent external mutation. """ return list(self._all_origins) @property def canonical_origin(self) -> str: """The scheme://host origin extracted from the primary URL. This is the origin that ``request()`` URLs must use — the failover mechanism rewrites this portion when trying fallback origins. """ return self._canonical_origin # ----------------------------------------------------------------- # URL fallback # ----------------------------------------------------------------- def _rewrite_url(self, url: str, origin: str) -> str: """Replace the canonical origin in *url* with *origin*.""" return origin + url[len(self._canonical_origin) :] def _probe_cache_key(self) -> str: """Cache key for probe results, stable regardless of current order.""" origins = '\0'.join(sorted(self._all_origins)) return hashlib.sha256( f'probe\0{origins}'.encode(), ).hexdigest() def _probe_cache_read(self) -> list[str] | None: """Return cached origin order if fresh, or None.""" if self._cache_dir is None: return None key = self._probe_cache_key() path = os.path.join(self._cache_dir, f'{key}.lore.cache') try: st = os.stat(path) except FileNotFoundError: return None age = int(time.time() - st.st_mtime) if age > self._probe_ttl: logger.debug('Probe cache expired (%ds > %ds)', age, self._probe_ttl) try: os.unlink(path) except OSError: pass return None with open(path, 'rb') as f: try: data: object = json.loads(f.read()) except (json.JSONDecodeError, ValueError): return None # A corrupt cache must not break the node: it must hold a list of strings. if not isinstance(data, list): return None origins: list[str] = [] for origin in cast('list[object]', data): if not isinstance(origin, str): return None origins.append(origin) # Validate: must contain exactly the same origins we know about if set(origins) != set(self._all_origins): return None logger.debug('Probe cache hit (%ds old)', age) return origins def _probe_cache_write(self, origins: list[str]) -> None: """Write probe results to cache.""" if self._cache_dir is None: return key = self._probe_cache_key() path = os.path.join(self._cache_dir, f'{key}.lore.cache') try: _write_atomic(path, json.dumps(origins).encode()) except OSError: logger.debug('Failed to write probe cache: %s', path) # ----------------------------------------------------------------- # Partial mirrors # ----------------------------------------------------------------- def _upstream_path(self) -> str | None: """Path of the file that remembers the learned upstream, or None. The suffix is not ``.lore.cache``, so :meth:`clear_cache` keeps it: the saved upstream is what lets a stopped mirror fall back. """ if self._cache_dir is None: return None key = hashlib.sha256(f'upstream\0{self._url}'.encode()).hexdigest() return os.path.join(self._cache_dir, f'{key}.lore.upstream') def _usable_upstream(self, value: str) -> str | None: """Return upstream URL *value*, normalized, if this node may follow it. It must be an absolute http or https URL, must not be this node itself, and its origin must be trusted: built in, or listed in *allowed_upstreams*. An untrusted origin is warned about once per process. """ try: norm = _normalize_url(value) except ValueError: norm = '' parsed = urllib.parse.urlsplit(norm) if parsed.scheme not in ('http', 'https') or parsed.query or parsed.fragment: logger.debug('Ignoring invalid upstream URL: %r', value) return None if norm == self._norm_url: return None origin = _url_origin(norm) if origin not in self._trusted_upstreams: with _warned_lock: first = origin not in _warned_untrusted _warned_untrusted.add(origin) if first: logger.warning( 'Not following untrusted upstream %s named by %s ' '(add it to allowupstream to trust it)', origin, self._canonical_origin, ) return None return norm def _load_upstream(self) -> None: """Read the saved upstream, once. The caller holds _upstream_lock.""" if self._upstream_loaded: return self._upstream_loaded = True path = self._upstream_path() if path is None: return try: with open(path, 'rb') as f: data: object = json.loads(f.read()) except FileNotFoundError: return except (OSError, ValueError): logger.debug('Ignoring unreadable upstream file: %s', path) return if not isinstance(data, dict): return saved = cast('dict[str, object]', data) upstream = saved.get('upstream') if saved.get('url') != self._url or not isinstance(upstream, str): return # Check it again: allowupstream may have changed since it was saved. self._upstream_url = self._usable_upstream(upstream) def _save_upstream(self) -> None: """Write or delete the saved upstream. The caller holds _upstream_lock.""" path = self._upstream_path() if path is None: return if self._upstream_url is None: try: os.unlink(path) except FileNotFoundError: pass except OSError: logger.debug('Failed to remove upstream file: %s', path) return data = {'url': self._url, 'upstream': self._upstream_url} try: _write_atomic(path, json.dumps(data).encode()) except OSError: logger.debug('Failed to write upstream file: %s', path) def _learn_upstream(self, resp: requests.Response) -> None: """Learn from *resp* whether this node is a partial mirror. A partial mirror sends ``X-Archive-Coverage: partial`` and names the archive it mirrors in ``X-Archive-Upstream``. A usable upstream is remembered. Any other answer forgets it, but only for a 2xx, 3xx or 404: a 5xx without the headers may come from a proxy in front of the mirror, not from the mirror. """ if not self._follow_upstream: return upstream = None if _partial(resp): value = _header(resp, 'X-Archive-Upstream') if value is not None: upstream = self._usable_upstream(value) status = resp.status_code if upstream is None and not (200 <= status < 400 or status == 404): return with self._upstream_lock: self._load_upstream() if upstream == self._upstream_url: return if upstream is not None: logger.debug('%s is a partial mirror of %s', self._url, upstream) else: logger.debug('%s is no longer a partial mirror', self._url) self._upstream_url = upstream self._save_upstream() def _get_upstream_url(self) -> str | None: """Return the learned upstream archive URL, or None for a full node.""" if not self._follow_upstream: return None with self._upstream_lock: self._load_upstream() return self._upstream_url def _upstream_nodes(self) -> list[LoreNode]: """Return every upstream node built so far, current one last.""" with self._upstream_lock: nodes = list(self._old_upstream_nodes) if self._upstream_node is not None: nodes.append(self._upstream_node) return nodes def _get_upstream_node(self) -> LoreNode | None: """Return the node for the learned upstream, building it on first use. Returns ``None`` for a full node, and once this node is shut down: there is no upstream fallback after :meth:`shutdown`. The upstream node reads the user's git config for its own origin, so its fallbacks, probing, timeouts and ``useragentplus`` apply. It never follows an upstream itself (one hop only), and adds no authentication headers: this node does that, once, for the messages it returns. Raises :class:`~liblore.LibloreError` when that git config can't be used. The error is remembered, so the config is not read again for the same upstream. """ if self._shutdown_event.is_set(): return None with self._upstream_lock: url = self._get_upstream_url() if url is None: return None node = self._upstream_node if node is not None and node.url == url: return node with self._upstream_build_lock: with self._upstream_lock: # Another thread may have built it while this one waited. node = self._upstream_node if node is not None and node.url == url: return node failed = self._upstream_build_failed if failed is not None and failed[0] == url: raise failed[1].with_traceback(None) try: node = LoreNode.from_git_config( url, follow_upstream=False, cache_dir=self._cache_dir, cache_ttl=self._cache_ttl, add_auth_headers=False, ) except LibloreError as ex: with self._upstream_lock: self._upstream_build_failed = (url, ex) raise if self._session is not None and not self._owns_session: node.set_requests_session(self._session) with self._upstream_lock: if self._user_agent_app is not None: node.set_user_agent(*self._user_agent_app) if self._shutdown_event.is_set(): # shutdown() ran while the node was being built. node.shutdown() if self._upstream_node is not None: self._old_upstream_nodes.append(self._upstream_node) self._upstream_node = node logger.debug('Built upstream node for %s', url) return node def _fallback_node(self) -> LoreNode | None: """Return the upstream node to retry a local miss on, or ``None``. Raises :class:`OperationCancelledError` when this node's operation was cancelled: never fall back after a cancel. """ upstream = self._get_upstream_node() if upstream is not None and self._cancelled(): raise OperationCancelledError('Request cancelled') return upstream def _outage_node(self, ex: RemoteError) -> LoreNode | None: """Return the upstream node to use because this node failed with *ex*. Returns ``None`` when *ex* is a real answer (see :func:`_unreachable`) or no upstream is known. The upstream may come from the saved file of an earlier run: an unreachable node sends no headers to learn from. """ if not _unreachable(ex): return None upstream = self._fallback_node() if upstream is not None: logger.debug( '%s unreachable (status %s), asking %s', self._url, ex.status_code, upstream.url, ) _warn_outage_once( 'local', self._url, 'Local mirror %s unreachable (%s), using %s', self._netloc, ex, upstream._netloc, ) return upstream def _warn_degraded(self, upstream: LoreNode, ex: RemoteError) -> None: _warn_outage_once( 'degraded', upstream.url, '%s unreachable (%s), searching the local mirror %s only; ' 'results may be incomplete', upstream._netloc, ex, self._netloc, ) def _not_on_mirror( self, upstream: LoreNode, missing: str, ex: RemoteError ) -> NotOnMirrorError: """Build the error for *missing* locally with *upstream* failing with *ex*. *missing* reads as the start of a sentence, for example ``' is not'``. """ if isinstance(ex, _HeldOffError): why = f'{ex.netloc} was unreachable {ex.ago:.0f} s ago' else: why = f'{upstream._netloc} could not be reached: {ex}' return NotOnMirrorError( f'{missing} on the local mirror ({self._netloc}), and {why}', status_code=ex.status_code, ) def _ask_upstream(self, upstream: LoreNode, remote: Callable[[LoreNode], _T]) -> _T: """Return ``remote(upstream)``, unless *upstream* is held off. After *upstream* is unreachable, it is not asked again for ``upstream_holdoff`` seconds: every request would wait for the timeout. Meanwhile this raises an unreachable :class:`RemoteError` at once. A real answer, an error included, ends the hold-off; a cancel changes nothing. """ ago = self._held_off_for(upstream) if ago is not None: logger.debug('Not asking %s, unreachable %.0f s ago', upstream.url, ago) raise _HeldOffError(upstream._netloc, ago) try: result = remote(upstream) except RemoteError as ex: if _unreachable(ex): self._start_holdoff(upstream, ex) else: self._end_holdoff(upstream) raise self._end_holdoff(upstream) self._record_source(Source.UPSTREAM) # remote() ran an operation of its own on the upstream node. self._record_as_of(upstream._last_as_of()) return result def _held_off_for(self, upstream: LoreNode) -> float | None: """Seconds since *upstream* was unreachable, while it is held off.""" with self._upstream_lock: since = self._holdoff_since if since is None or since[0] != upstream.url: return None ago = time.monotonic() - since[1] if ago >= self._upstream_holdoff: return None return ago def _start_holdoff(self, upstream: LoreNode, ex: RemoteError) -> None: if self._upstream_holdoff <= 0: return with self._upstream_lock: # Another thread's request may have started it already. if self._held_off_for(upstream) is not None: return self._holdoff_since = (upstream.url, time.monotonic()) logger.warning( '%s unreachable (%s), not trying it again for %.0f s', upstream._netloc, ex, self._upstream_holdoff, ) def _end_holdoff(self, upstream: LoreNode) -> None: with self._upstream_lock: since = self._holdoff_since if since is None or since[0] != upstream.url: return self._holdoff_since = None logger.debug('%s answered, hold-off ended', upstream.url) def _answer( self, what: str, local: Callable[[], tuple[_T, float | None]], remote: Callable[[LoreNode], _T], *, upstream_first: bool = False, on_outage: Callable[[RemoteError], _T] | None = None, ) -> _T: """Answer from this node, or from the upstream archive. This is the one place the rules of section 4.5 of the partial mirrors spec live. *what* names the thing asked for, for log and error messages. *local* asks this node. It returns its answer and how far the answer reaches (see :attr:`last_as_of`), which is recorded here; or it raises :class:`_Miss` when this partial mirror doesn't have *what*; or :class:`~liblore.RemoteError` when the node failed. *remote* asks the upstream node the same thing. A miss goes to the upstream. When the upstream is unreachable too, the answer the mirror did give is returned as :attr:`~liblore.Source.DEGRADED` if there is one, and :class:`~liblore.NotOnMirrorError` is raised if not. When this node is unreachable and an upstream is known, the upstream answers. With no upstream, *on_outage* answers for the node when given, and the error is raised when not. With *upstream_first*, the upstream is asked before this node, and this node only when the upstream is unreachable: its answer is then degraded. """ if upstream_first: upstream = self._fallback_node() if upstream is not None: logger.debug('Asking %s first for %s', upstream.url, what) try: return self._ask_upstream(upstream, remote) except RemoteError as ex: if not _unreachable(ex): raise return self._degraded(upstream, what, local, ex) try: answer, as_of = local() except _Miss as miss: return self._after_miss(miss, what, remote) except RemoteError as ex: upstream = self._outage_node(ex) if upstream is None: if on_outage is not None and _unreachable(ex): return on_outage(ex) raise try: return self._ask_upstream(upstream, remote) except _HeldOffError as held: # Say which failure the caller is looking at: the # hold-off alone reads as if the mirror had answered. raise RemoteError( f'{what}: the local mirror ({self._netloc}) is unreachable ' f'({ex}), and {held}' ) from held self._record_as_of(as_of) return answer def _after_miss( self, miss: _Miss, what: str, remote: Callable[[LoreNode], _T] ) -> _T: """Ask the upstream for *what*, which this partial mirror doesn't have. An unreachable upstream leaves the mirror's own answer, if *miss* carries one, as the degraded answer; otherwise it raises :class:`~liblore.NotOnMirrorError`. The upstream's real answers, a 404 included, are raised as they are. """ upstream = miss.upstream logger.debug('%s not on %s, asking %s', what, self._url, upstream.url) try: return self._ask_upstream(upstream, remote) except RemoteError as ex: if not _unreachable(ex): raise if not miss.answer: missing = miss.missing or f'{what} is not' raise self._not_on_mirror(upstream, missing, ex) from ex self._warn_degraded(upstream, ex) self._record_source(Source.DEGRADED) self._record_as_of(miss.as_of) return cast('_T', miss.answer) def _degraded( self, upstream: LoreNode, what: str, local: Callable[[], tuple[_T, float | None]], upstream_error: RemoteError, ) -> _T: """Ask this partial mirror for *what* because *upstream* is unreachable. The answer may be incomplete, so the caller must not cache it. When the mirror has nothing, that says nothing about the archive: raise :class:`~liblore.NotOnMirrorError` instead. """ missing = None try: answer, as_of = local() except _Miss as miss: answer, as_of, missing = cast('_T', miss.answer), miss.as_of, miss.missing if not answer: missing = missing or f'{what} is not' raise self._not_on_mirror( upstream, missing, upstream_error ) from upstream_error self._warn_degraded(upstream, upstream_error) self._record_source(Source.DEGRADED) self._record_as_of(as_of) return answer def _known_locally(self, msgid: str) -> bool: """Ask this node whether it has the message *msgid*. A thread-scoped search for a message-id the server doesn't know searches the whole archive instead (public-inbox drops the thread limit), so a partial node checks before searching. Raises :class:`RemoteError` when this node is unreachable. A partial mirror that doesn't say when it last caught up with its upstream (no ``updated`` time) can't be trusted to have the thread's new mail, so this returns ``False`` for it too. """ raw_url = f'{self._url}/{urllib.parse.quote_plus(msgid)}/raw' resp = self._request('HEAD', raw_url, allow_redirects=False) resp.close() if resp.status_code >= 500: raise RemoteError( f'Server returned an error: {resp.status_code}', status_code=resp.status_code, ) if resp.status_code == 404: return False if _partial(resp) and _updated(resp) is None: logger.debug('%s sends no updated time, not searching it', self._url) return False return True def _probe_one(self, origin: str) -> tuple[str, float]: """HEAD manifest.js.gz on *origin*, return (origin, elapsed). Uses a throwaway session to be thread-safe. Returns ``float('inf')`` for unreachable or erroring origins. """ url = f'{origin}/manifest.js.gz' try: start = time.monotonic() resp = requests.head( url, headers={'User-Agent': self._user_agent}, timeout=self._probe_timeout, ) elapsed = time.monotonic() - start if resp.status_code >= 400: logger.debug('Probe %s returned %d', origin, resp.status_code) return origin, float('inf') logger.debug('Probe %s: %.3fs', origin, elapsed) return origin, elapsed except Exception as exc: # noqa: BLE001 logger.debug('Probe %s failed: %s', origin, exc) return origin, float('inf') @_operation_scope def probe_origins(self, nocache: bool = False) -> list[tuple[str, float]]: """Probe all origins concurrently and reorder by response time. Sends a ``HEAD`` request to ``/manifest.js.gz`` on each origin (a lightweight resource present on all public-inbox instances) and sorts :attr:`_all_origins` fastest-first. Unreachable origins are moved to the end rather than removed, so they can recover on subsequent requests. Results are cached to *cache_dir* (when set) for *probe_ttl* seconds so repeated calls are cheap. Pass *nocache=True* to skip the cache and always perform a live probe (results are still written to cache afterward). Returns a list of ``(origin, elapsed_seconds)`` pairs in the new order. Unreachable origins have ``float('inf')`` as their elapsed time. """ if len(self._all_origins) <= 1: self._probe_done = True return [(self._all_origins[0], 0.0)] if self._all_origins else [] # Try cached results first if not nocache: cached = self._probe_cache_read() if cached is not None: self._all_origins = cached self._probe_done = True return [(o, 0.0) for o in cached] # Probe all origins concurrently with concurrent.futures.ThreadPoolExecutor( max_workers=len(self._all_origins), ) as pool: results = list(pool.map(self._probe_one, self._all_origins)) results.sort(key=lambda x: x[1]) self._all_origins = [origin for origin, _ in results] self._probe_done = True # Log the results for origin, elapsed in results: if elapsed == float('inf'): logger.info('Probe result: %s — unreachable', origin) else: logger.info('Probe result: %s — %.3fs', origin, elapsed) self._probe_cache_write(self._all_origins) return results def request( self, method: str, url: str, **kwargs: object, ) -> requests.Response: """Execute an HTTP request with origin failover. The *url* must use the canonical origin (the URL passed to ``__init__``). On retriable failures — connection errors, timeouts, and 5xx responses — each configured fallback origin is tried in order. 4xx responses are returned immediately (not retriable). Raises :class:`RemoteError` when every origin has been exhausted. Any extra *kwargs* are forwarded to the underlying ``requests.Session`` method (e.g. ``timeout``, ``headers``). """ return self._request(method, url, raise_on_error=True, **kwargs) @_operation_scope def _request( self, method: str, url: str, *, raise_on_error: bool = True, **kwargs: object, ) -> requests.Response: """Execute an HTTP request with fallback URL rotation. Tries each origin in :attr:`_all_origins`. On retriable failures (connection errors, timeouts, 5xx responses), logs a warning and tries the next origin. When *raise_on_error* is ``False``, returns the last response even when every origin failed (used by :meth:`_fetch_thread_since` which returns ``[]`` on error). """ if self._auto_probe and not self._probe_done: self.probe_origins() # Always apply a timeout so a stalled socket cannot hang forever, # but let an explicit per-call timeout override the default. kwargs.setdefault('timeout', self._request_timeout) session = self._get_session() last_exc: Exception | None = None last_resp: requests.Response | None = None for origin in self._all_origins: if self._cancelled(): raise OperationCancelledError('Request cancelled') request_url = self._rewrite_url(url, origin) logger.debug('Trying %s %s', method, request_url) try: resp = cast( 'requests.Response', getattr(session, method.lower())(request_url, **kwargs), ) except (requests.ConnectionError, requests.Timeout) as exc: # cancel_active() and shutdown() close the session # mid-flight, which surfaces here as a ConnectionError. # Distinguish that from a real origin failure: if the # caller cancelled, do NOT fail over. if self._cancelled(): raise OperationCancelledError('Request cancelled') from exc logger.warning( 'Request to %s failed (%s), trying next host', origin, exc, ) last_exc = exc continue self._learn_upstream(resp) if resp.status_code >= 500: logger.warning( 'Request to %s returned %d, trying next host', origin, resp.status_code, ) last_resp = resp continue # Success or 4xx — not retriable return resp # All origins exhausted if not raise_on_error and last_resp is not None: return last_resp if last_exc is not None: raise RemoteError(f'All hosts failed for {url}: {last_exc}') from last_exc # last_resp must be a 5xx from the final origin assert last_resp is not None return last_resp # ----------------------------------------------------------------- # Cache # ----------------------------------------------------------------- def _cache_key(self, namespace: str, *parts: str) -> str: """Build a hex cache key from a namespace and variable parts.""" canonical = '\0'.join([self._url, namespace] + list(parts)) return hashlib.sha256(canonical.encode()).hexdigest() def _cache_read(self, key: str) -> bytes | None: """Return cached bytes if fresh, or None.""" if self._cache_dir is None: return None path = os.path.join(self._cache_dir, f'{key}.lore.cache') try: st = os.stat(path) except FileNotFoundError: return None age = int(time.time() - st.st_mtime) if age > self._cache_ttl: logger.debug( 'Cache expired (%ds > %ds): %s', age, self._cache_ttl, key[:12] ) try: os.unlink(path) except OSError: pass return None logger.debug('Cache hit (%ds old): %s', age, key[:12]) with open(path, 'rb') as f: data = f.read() self._record_as_of(st.st_mtime) return data def _cache_write(self, key: str, data: bytes) -> None: """Write data to cache. Errors are logged but never raised.""" if self._cache_dir is None: return path = os.path.join(self._cache_dir, f'{key}.lore.cache') try: _write_atomic(path, data) except OSError: logger.debug('Failed to write cache file: %s', path) def clear_cache(self) -> None: """Remove all cache files from *cache_dir*. Also clears the cache of the upstream nodes of a partial mirror. The saved upstream (``*.lore.upstream``) is kept: it is what this node learned, not a cached answer. Temporary files left by a killed process are removed too, once they are old enough that no running write can still own them. """ # Today the upstream nodes share this node's cache_dir, so this # clears nothing more; it keeps working if they ever don't. for node in self._upstream_nodes(): node.clear_cache() if self._cache_dir is None: return now = time.time() for entry in os.listdir(self._cache_dir): if entry.endswith('.lore.cache') or _orphan_tmp( self._cache_dir, entry, now ): try: os.unlink(os.path.join(self._cache_dir, entry)) except OSError: pass # ----------------------------------------------------------------- # Authentication # ----------------------------------------------------------------- def _authenticate_msgs(self, msgs: list[EmailMessage]) -> None: """Add Authentication-Results headers via authheaders.""" if self._authenticate_message is None: return for msg in msgs: msg_bytes = msg.as_bytes() auth_result = self._authenticate_message( msg_bytes, 'liblore', dkim=True, dmarc=True, arc=True, spf=False, ) if auth_result: # authheaders returns the full header line, so strip # the name prefix before adding to the message. msg['Authentication-Results'] = auth_result.removeprefix( 'Authentication-Results: ' ) # ----------------------------------------------------------------- # Primary API — raw mbox # ----------------------------------------------------------------- @_operation_scope def get_mbox_by_msgid(self, msgid: str, *, nocache: bool = False) -> bytes: """Fetch a thread mbox by message ID and return the raw bytes. On a 404, falls back to a HEAD request against the bare server origin to discover the correct list path via redirect. This handles public-inbox servers where the configured URL does not include the archive path (e.g. ``https://lore.kernel.org`` instead of ``https://lore.kernel.org/all``). On a partial mirror, a thread the mirror doesn't have comes from the upstream archive. :class:`~liblore.NotOnMirrorError` means the upstream could not be reached either. """ key = self._cache_key('mbox_by_msgid', msgid) if not nocache: cached = self._cache_read(key) if cached is not None: return cached # The upstream node caches its own answers, under its own keys. # A named function, not a lambda: mypy can't infer a lambda # that calls this decorated method from inside its own body. def remote(upstream: LoreNode) -> bytes: return upstream.get_mbox_by_msgid(msgid, nocache=nocache) return self._answer( msgid, lambda: self._local_mbox_by_msgid(msgid, key), remote ) def _local_mbox_by_msgid(self, msgid: str, key: str) -> tuple[bytes, float]: """Fetch the thread mbox from this node, for :meth:`get_mbox_by_msgid`. Raises :class:`_Miss` when this partial mirror doesn't have the thread. """ qmsgid = urllib.parse.quote_plus(msgid) mbox_url = f'{self._url}/{qmsgid}/t.mbox.gz' sent = time.time() resp = self._request('GET', mbox_url) if resp.status_code == 404: upstream = self._fallback_node() if upstream is not None: # Skip the redirect lookup below: it drops the base # path, and the upstream archive has the whole thing. resp.close() raise _Miss(upstream) # The message may live under a different list path. Try a # HEAD against the bare origin and follow redirects to # discover the canonical location. resp = self._resolve_msgid_via_head(qmsgid) if resp.status_code != 200: raise RemoteError( f'Server returned an error: {resp.status_code}', status_code=resp.status_code, ) t_mbox = gzip.decompress(resp.content) resp.close() self._cache_write(key, t_mbox) return t_mbox, _answer_as_of(resp, sent) def _resolve_msgid_via_head(self, qmsgid: str) -> requests.Response: """HEAD the bare origin to discover the list path, then GET the mbox. Public-inbox servers redirect ``/{msgid}/`` to ``/{list}/{msgid}/`` when the message-id is found. This method follows that redirect and fetches the mbox from the discovered location. Returns the response from the final GET (or the failed HEAD response if the message-id wasn't found). Both requests go through :meth:`_request`, so they fail over, honour a cancel, and raise :class:`RemoteError` when no host answers. """ head_url = f'{self._canonical_origin}/{qmsgid}/' logger.debug('Trying HEAD %s for redirect discovery', head_url) head_resp = self._request('HEAD', head_url, allow_redirects=True) if head_resp.status_code != 200: return head_resp # The answer may come from a fallback origin, and _request() # expects URLs on the canonical one, so keep only the path. # Without a redirect this is head_url's own path. resolved = urllib.parse.urlsplit(head_resp.url).path.rstrip('/') mbox_url = f'{self._canonical_origin}{resolved}/t.mbox.gz' logger.debug('Resolved via redirect: %s', mbox_url) return self._request('GET', mbox_url) @_operation_scope def get_mbox_by_query( self, query: str, *, full_threads: bool = False, nocache: bool = False, local_sufficient: bool = False, ) -> bytes: """POST a search query and return the raw mbox bytes. When *full_threads* is ``True``, the server expands results to include the full thread for every matching message (public-inbox ``t=1`` parameter). On a partial mirror the mirror answers, and the upstream archive is asked only when the mirror finds nothing. A hit the mirror doesn't have is then missed. With ``partial_search='upstream'`` the upstream archive is asked first, and the mirror only when the upstream archive can't be reached. With *local_sufficient*, the mirror's answer is final when the mirror answers, even when it finds nothing: the upstream archive is not asked, and ``partial_search='upstream'`` is ignored. Use it when you already know the mirror tracks what you search for, for example because it just answered for the thread the search is about. "No results" is then the server's 404 (:class:`~liblore.RemoteError`), as on a full node. When the mirror can't be reached, the upstream archive still answers. """ # A named function, not a lambda: mypy can't infer a lambda # that calls this decorated method from inside its own body. def remote(upstream: LoreNode) -> bytes: return upstream.get_mbox_by_query( query, full_threads=full_threads, nocache=nocache ) key = self._cache_key('mbox_by_query', query, str(full_threads)) upstream_first = self._partial_search == 'upstream' def local() -> tuple[bytes, float | None]: if not nocache: cached = self._cache_read(key) if cached is not None: return cached, None try: t_mbox, as_of = self._local_search(query, full_threads) except RemoteError as ex: if local_sufficient: raise # This answer may be the first to say the node is partial. upstream = self._fallback_node() if upstream is None or ex.status_code != 404: raise raise _Miss(upstream, missing=f'No results for {query!r}') from ex if local_sufficient: # Sufficient for the caller, but the mirror's answer may still # be incomplete: cache only what the default would. partial = self._get_upstream_url() is not None if not (partial and (upstream_first or not t_mbox)): self._cache_write(key, t_mbox) return t_mbox, as_of upstream = self._fallback_node() if upstream is not None and (upstream_first or not t_mbox): # The mirror's answer, which may be incomplete: never # cached. raise _Miss( upstream, t_mbox, as_of, missing=f'No results for {query!r}' ) self._cache_write(key, t_mbox) return t_mbox, as_of return self._answer( f'Search {query!r}', local, remote, upstream_first=upstream_first and not local_sufficient, ) def _local_search(self, query: str, full_threads: bool) -> tuple[bytes, float]: """POST a whole-archive search to this node. Returns the mbox, and how far it reaches (see :attr:`last_as_of`) for the caller to record if it uses the answer. Raises :class:`RemoteError` for any answer but a 200. """ t_param = '&t=1' if full_threads else '' query_url = ( self._url + '/?x=m' + t_param + '&q=' + urllib.parse.quote_plus(query) ) sent = time.time() resp = self._request('POST', query_url, data='x=m') if resp.status_code != 200: raise RemoteError( f'Server returned an error: {resp.status_code}', status_code=resp.status_code, ) t_mbox = gzip.decompress(resp.content) resp.close() return t_mbox, _answer_as_of(resp, sent) # ----------------------------------------------------------------- # Primary API — parsed messages # ----------------------------------------------------------------- @_operation_scope def get_thread_by_msgid( self, msgid: str, *, strict: bool = True, sort: bool = False, since: str | None = None, ) -> list[EmailMessage]: """Fetch a thread from the public-inbox server. When *since* is given (a ``YYYYMMDDHHMMSS`` date string), only thread messages with a ``Date:`` header after that timestamp are returned. This uses the per-message search endpoint (``/{msgid}/?x=m&q=dt:{since}..``) which scopes the query to the thread identified by *msgid* and filters individual messages by date. Without *since*, the full thread mbox is fetched via ``/{msgid}/t.mbox.gz``. When *strict* is ``True`` (default), the results are filtered to only messages belonging to the thread rooted at *msgid*. When *sort* is ``True``, messages are sorted by Received date. Raises :class:`LookupError` when no messages match. """ if since: msgs = self._fetch_thread_since( msgid, f'dt:{since}..', since=_date_string_epoch(since) ) if not msgs: raise LookupError(f'No messages found for msgid={msgid} since={since}') else: # Full thread: GET /{msgid}/t.mbox.gz t_mbox = self.get_mbox_by_msgid(msgid) if not t_mbox: raise LookupError(f'No messages found for msgid={msgid}') msgs = split_and_dedupe(t_mbox) if strict: strict_msgs = get_strict_thread(msgs, msgid) if not isinstance(strict_msgs, list) or not len(strict_msgs): raise LookupError(f'No messages found for msgid={msgid}') msgs = strict_msgs if sort: msgs = sort_msgs_by_received(msgs) self._authenticate_msgs(msgs) return msgs def _fetch_thread_since( self, msgid: str, query_fragment: str, since: float | None = None, ) -> list[EmailMessage]: """Fetch thread messages matching a date-range query fragment. *query_fragment* is a ready-to-use search term such as ``dt:20240101..`` or ``rt:1704067200..``. Uses the per-message search endpoint: the ``/{msgid}/`` path scopes the query to the thread. POSTs with an empty body so that thread expansion is NOT triggered — only messages matching the range are returned. Returns an empty list when the server finds no matches. On a partial mirror, :meth:`_known_locally` runs first. For a thread the mirror doesn't have, the search would cover the whole mirror instead of one thread, so it goes to the upstream archive and the mirror is not searched. For a thread the mirror has, its answer is final, even when it is "nothing new". When the partial mirror is unreachable, the upstream archive answers. When the mirror doesn't have the thread and the upstream is unreachable, :class:`~liblore.NotOnMirrorError` is raised: "nothing new" would be a guess. *since* is the Unix time *query_fragment* starts at, if it is a received time. When the node is unreachable and the answer is an empty "nothing new", the answer reaches no further than it. """ def remote(upstream: LoreNode) -> list[EmailMessage]: return self._search_thread_upstream(upstream, msgid, query_fragment) def local() -> tuple[list[EmailMessage], float | None]: upstream = self._fallback_node() if upstream is not None and not self._known_locally(msgid): raise _Miss(upstream) msgs, as_of = self._search_thread(msgid, query_fragment) if upstream is None: # This answer may be the first to say the node is # partial, and then it can't be trusted without the # check. upstream = self._fallback_node() if upstream is not None and not self._known_locally(msgid): raise _Miss(upstream) return msgs, as_of def on_outage(ex: RemoteError) -> list[EmailMessage]: if ex.status_code is None: raise ex # Every origin answered 5xx: a full node has always reported # that as "nothing new", and it has told us nothing past # *since*. self._record_as_of(since) return [] return self._answer(f'Thread {msgid}', local, remote, on_outage=on_outage) def _search_thread( self, msgid: str, query_fragment: str ) -> tuple[list[EmailMessage], float]: """Run the thread-scoped search on this node. Returns the messages, an empty list when the server finds no matches, and how far the answer reaches (see :attr:`last_as_of`) for the caller to record if it uses it. Raises :class:`RemoteError` when the server is unreachable, 5xx included. """ qmsgid = urllib.parse.quote_plus(msgid) query = urllib.parse.quote_plus(query_fragment) search_url = f'{self._url}/{qmsgid}/?x=m&q={query}' sent = time.time() resp = self._request('POST', search_url, raise_on_error=False, data='') if resp.status_code >= 500: resp.close() raise RemoteError( f'Server returned an error: {resp.status_code}', status_code=resp.status_code, ) as_of = _answer_as_of(resp, sent) if resp.status_code != 200: return [], as_of t_mbox = gzip.decompress(resp.content) resp.close() if not t_mbox: return [], as_of return split_and_dedupe(t_mbox), as_of def _search_thread_upstream( self, upstream: LoreNode, msgid: str, query_fragment: str, ) -> list[EmailMessage]: """Run :meth:`_search_thread` on *upstream*, as one operation. The upstream is a full archive, so it needs no known-thread check, and its 5xx answers raise instead of reading as "nothing new". """ with upstream._operation(): msgs, as_of = upstream._search_thread(msgid, query_fragment) upstream._record_as_of(as_of) return msgs @_operation_scope def get_thread_updates_since( self, msgid: str, since: datetime, *, strict: bool = True, sort: bool = False, ) -> list[EmailMessage]: """Check a thread for messages newer than *since*. Uses the ``rt:`` (Received-date) search prefix, which filters by the server-set ``Received:`` header rather than the client-set ``Date:`` header, making it more reliable. Accepts a :class:`~datetime.datetime` (converted to a UTC epoch timestamp internally) and returns an empty list when there are no updates, making it easy to poll:: from datetime import datetime, timedelta, timezone cutoff = datetime.now(timezone.utc) - timedelta(hours=24) updates = node.get_thread_updates_since(msgid, cutoff) if updates: print(f'{len(updates)} new message(s)') When *strict* is ``True`` (default), results are filtered to only messages belonging to the thread rooted at *msgid*. When *sort* is ``True``, messages are sorted by Received date. To poll, pass :attr:`last_as_of` as the next *since*, not your own clock. A mirror is always a little behind the archive it mirrors, and its "nothing new" is only true up to its last catch-up. With your own clock, mail can fall into the gap: 1. At 12:05 you poll with ``since=12:00``. The mirror last caught up at 11:58, so it has nothing new. 2. A reply reached the archive at 12:01. The mirror gets it at 12:08. 3. At 12:10 you poll with ``since=12:05``. The reply was received at 12:01, before 12:05: it is never reported. With ``since=node.last_as_of`` the second poll starts at 11:58 and finds the reply. Polls then overlap a little, so skip messages you have already seen, by Message-ID:: updates = node.get_thread_updates_since(msgid, since) since = node.last_as_of """ epoch = int(since.astimezone(timezone.utc).timestamp()) msgs = self._fetch_thread_since(msgid, f'rt:{epoch}..', since=epoch) if strict and msgs: strict_msgs = get_strict_thread(msgs, msgid) if isinstance(strict_msgs, list) and len(strict_msgs): msgs = strict_msgs else: return [] if sort and msgs: msgs = sort_msgs_by_received(msgs) self._authenticate_msgs(msgs) return msgs @_operation_scope def get_thread_by_query( self, query: str, *, full_threads: bool = False, local_sufficient: bool = False, ) -> list[EmailMessage]: """POST a search query and return deduplicated messages. When *full_threads* is ``True``, the server expands results to include the full thread for every matching message. *local_sufficient* is passed on to :meth:`get_mbox_by_query`. """ t_mbox = self.get_mbox_by_query( query, full_threads=full_threads, local_sufficient=local_sufficient ) if not t_mbox: raise LookupError(f'No results for query: {query}') msgs = split_and_dedupe(t_mbox) self._authenticate_msgs(msgs) return msgs @_operation_scope def get_message_by_msgid(self, msgid: str, *, nocache: bool = False) -> bytes: """Fetch a single raw email message by message ID. On a partial mirror, a message the mirror doesn't have comes from the upstream archive. :class:`~liblore.NotOnMirrorError` means the upstream could not be reached either. """ key = self._cache_key('message_by_msgid', msgid) if not nocache: cached = self._cache_read(key) if cached is not None: return cached # A named function, not a lambda: mypy can't infer a lambda # that calls this decorated method from inside its own body. def remote(upstream: LoreNode) -> bytes: return upstream.get_message_by_msgid(msgid, nocache=nocache) return self._answer( msgid, lambda: self._local_message_by_msgid(msgid, key), remote ) def _local_message_by_msgid(self, msgid: str, key: str) -> tuple[bytes, float]: """Fetch the raw message from this node, for :meth:`get_message_by_msgid`. Raises :class:`_Miss` when this partial mirror doesn't have the message. """ raw_url = f'{self._url}/{urllib.parse.quote_plus(msgid)}/raw' def failed(ex: Exception) -> RemoteError: status = None if isinstance(ex, requests.HTTPError) and ex.response is not None: status = ex.response.status_code return RemoteError( f'Failed to fetch message from {raw_url}: {ex}', status_code=status, ) sent = time.time() try: response = self._request('GET', raw_url) except LibloreError: # RemoteError, and OperationCancelledError: a cancel is not # a server failure. raise except Exception as ex: raise failed(ex) from ex if response.status_code == 404: upstream = self._fallback_node() if upstream is not None: response.close() raise _Miss(upstream) try: response.raise_for_status() data = cast('bytes', response.content) except Exception as ex: raise failed(ex) from ex self._cache_write(key, data) return data, _answer_as_of(response, sent) # ----------------------------------------------------------------- # Batch API # ----------------------------------------------------------------- @_operation_scope def batch_get_thread_by_msgid( self, msgids: list[str], *, strict: bool = True, sort: bool = False, since: str | None = None, ) -> list[list[EmailMessage]]: """Fetch threads for multiple message IDs with rate limiting. Calls :meth:`get_thread_by_msgid` for each *msgid* with a 100 ms cooldown between requests (no sleep before the first). Returns a list of results in the same order as the input. :attr:`last_source` then reports the worst source of them all; to know where each thread came from, call :meth:`get_thread_by_msgid` in a loop and read :attr:`last_source` after each call. """ results: list[list[EmailMessage]] = [] for i, msgid in enumerate(msgids): if i > 0: time.sleep(0.1) results.append( self.get_thread_by_msgid(msgid, strict=strict, sort=sort, since=since) ) return results @_operation_scope def batch_get_thread_by_query( self, queries: list[str], *, full_threads: bool = False, local_sufficient: bool = False, ) -> list[list[EmailMessage]]: """Run multiple queries with rate limiting. Calls :meth:`get_thread_by_query` for each query with a 100 ms cooldown between requests (no sleep before the first). When *full_threads* is ``True``, the server expands results to include the full thread for every matching message. *local_sufficient* is passed on to :meth:`get_mbox_by_query`. Returns a list of results in the same order as the input. :attr:`last_source` then reports the worst source of them all; to know where each result came from, call :meth:`get_thread_by_query` in a loop and read :attr:`last_source` after each call. """ results: list[list[EmailMessage]] = [] for i, query in enumerate(queries): if i > 0: time.sleep(0.1) results.append( self.get_thread_by_query( query, full_threads=full_threads, local_sufficient=local_sufficient ) ) return results @_operation_scope def validate(self) -> None: """Validate the URL as a public-inbox endpoint. Sends a HEAD request to ``{url}/_/text/help/`` and checks for a 200 response. Raises :class:`~liblore.RemoteError` when the URL does not appear to be a public-inbox server. """ help_url = f'{self._url}/_/text/help/' logger.debug('Validating public-inbox URL: %s', help_url) try: session = self._get_session() resp = session.head(help_url, timeout=self._request_timeout) except Exception as ex: # cancel_active() and shutdown() close the session mid-flight. if self._cancelled(): raise OperationCancelledError('Request cancelled') from ex raise RemoteError( f'Failed to reach public-inbox at {help_url}: {ex}' ) from ex if resp.status_code != 200: raise RemoteError( f'URL does not appear to be a public-inbox server ' f'(HEAD {help_url} returned {resp.status_code})', status_code=resp.status_code, ) python-liblore-0.10.1/src/liblore/py.typed000066400000000000000000000000001526032434100204440ustar00rootroot00000000000000python-liblore-0.10.1/src/liblore/series.py000066400000000000000000001114641526032434100206320ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2025-2026 The Linux Foundation """Descriptive summaries of mail threads and patch series. This module answers *what is in this thread* and nothing more. It reports facts -- how many messages, when they arrived, who took part, what the ``[PATCH]`` brackets said, which trailers appeared -- and leaves every judgement to the caller. Deciding whether a series is complete, whether it has been applied, or what to do about it is deliberately somebody else's job. Everything here is pure: no network, no git, no subprocess. Feed it messages, get back frozen dataclasses that serialise cleanly via :meth:`ThreadSummary.to_dict`. Summaries nest: a :class:`ThreadSummary` carries a :class:`MessageSummary` per message, and each of those carries the trailers that message offered. Aggregate questions ("who reviewed this?") are answered from the thread's own fields; ordered ones ("did my reply arrive after that review?") need the per-message records, which is why both are reported and not just the aggregates. Questions about people go through :class:`Identity`, so that every caller answers "is this the same person?" the same way. Knowing that two unrelated addresses belong to one human needs a directory, which is the caller's to supply; what this module knows on its own is documented on :func:`email_matches` and :func:`name_matches`. """ from __future__ import annotations import datetime import email.utils import re from collections.abc import Iterable, Sequence from dataclasses import dataclass from email.message import EmailMessage from typing import Any from liblore.utils import ( body_has_diff, clean_header, find_trailers, format_addrs, get_clean_msgid, msg_get_author, msg_get_date, msg_get_payload, msg_get_subject, split_body_parts, strip_reply_prefixes, ) # Version of the serialised shape produced by to_dict(). Consumers that # persist or transport these records -- the MCP server first among them -- # pin against it, so bump it whenever a key changes meaning or leaves. # Adding a key is not a bump; every key is always present, so a reader # that does not know a new one can ignore it. SCHEMA_VERSION = 1 # One leading [bracketed] prefix group. _RE_BRACKET = re.compile(r'^\s*\[([^]]*)]\s*') # "[PATCHv3]" and "[RFC PATCHv2]" -- written without the space often # enough that it is worth repairing before tokenising. _RE_GLUED_VERSION = re.compile(r'(patch)(v\d+)', re.IGNORECASE) # A "v3" style version token. _RE_VERSION = re.compile(r'^v(\d+)$', re.IGNORECASE) # An "n/m" counter token, after whitespace around the slash is removed. # Bounded, so a date or a version range cannot pose as a counter. _RE_COUNTER = re.compile(r'^(\d{1,4})/(\d{1,4})$') # Trailers that record a fact about the patch rather than a person's # endorsement of it. Taken from b4, which has the better-researched # vocabulary here. _UTILITY_TRAILERS = frozenset( { 'base-commit', 'based-on', 'buglink', 'change-id', 'closes', 'fixes', 'link', 'message-id', 'obsoleted-by', } ) # A "Name
" trailer value, parsed by hand: email.utils.parseaddr # applies RFC 2822 semantics that mangle display names holding quotes, # commas or parentheses, and a trailer is plain text, not a header. _RE_TRAILER_ADDR = re.compile(r'^(.*?)\s*<([^>]+)>\s*$') # Inserted by mail clients that back-convert from HTML mail. _RE_MAILTO = re.compile(r']+>') # A value that names an email address somewhere inside it. _RE_HAS_ADDR = re.compile(r'\S+@\S+\.\S+') # An extended local part, as in foo+kernel@example.com. _RE_EXTENDED_LOCAL = re.compile(r'\+[^@]+@') # One word of a display name, punctuation and spacing left out. _RE_NAME_WORD = re.compile(r'\w+') # An aside in a display name, as in "Bob Reviewer (Arm)" -- usually the # employer, sometimes a nickname, never part of the name itself. _RE_NAME_ASIDE = re.compile(r'\([^)]*\)') # Patch and commit metadata that is not review feedback. A reply that # quotes a patch's own headers should not be read as having offered # them, so these are dropped from replies (b4 does the same). _REPLY_IGNORES = frozenset( { 'author', 'base-commit', 'cc', 'change-id', 'date', 'from', 'message-id', 'subject', 'subscribe', 'to', 'unsubscribe', } ) def _isoformat(when: datetime.datetime | None) -> str | None: """Render a timestamp for JSON, passing ``None`` straight through.""" return when.isoformat() if when is not None else None def email_matches(one: str, two: str) -> bool: """Whether two addresses name the same mailbox. Beyond a case-insensitive comparison this forgives the two ways one mailbox routinely appears under two spellings on a mailing list: an extended local part, so ``foo+kernel@example.com`` matches ``foo@example.com``, and a domain that is one label deeper than the other, so ``foo@linux.intel.com`` matches ``foo@intel.com``. It does not attempt to know that two genuinely different addresses belong to one person -- that needs a directory, which is a caller's to supply. :class:`Identity` is how to hand one in. """ one, two = one.lower(), two.lower() if one == two: return True if '@' not in one or '@' not in two: return False one = _RE_EXTENDED_LOCAL.sub('@', one) two = _RE_EXTENDED_LOCAL.sub('@', two) if one == two: return True olocal, odomain = one.split('@', maxsplit=1) tlocal, tdomain = two.split('@', maxsplit=1) if olocal != tlocal: return False return abs(odomain.count('.') - tdomain.count('.')) == 1 and ( odomain.endswith(f'.{tdomain}') or tdomain.endswith(f'.{odomain}') ) def _clean_name(name: str) -> str: """Lowercase a display name and drop anything in parentheses. Kernel mail routinely carries the sender's employer in the From line -- "Bob Reviewer (Arm)" -- while the tag they sign is written plainly, or the other way round. The aside is not part of anybody's name, so it plays no part in comparing two of them. """ return ' '.join(_RE_NAME_ASIDE.sub(' ', name).lower().split()) def _name_parts_match(whole: str, listed: str) -> bool: """Whether *listed*, written "Surname, Given", names *whole*'s person. Every word of *listed* has to be a whole word of *whole*, so "Okonkwo, Amara" matches "Amara Okonkwo" but "Smith, Alex" does not match "Alexander Smithson". Extra words on the *whole* side are fine: a middle name or an initial does not stop the match. """ chunks = [chunk.strip() for chunk in listed.split(',') if chunk.strip()] if len(chunks) < 2: return False words = _RE_NAME_WORD.findall(listed) if not words: return False return set(words) <= set(_RE_NAME_WORD.findall(whole)) def name_matches(one: str, two: str) -> bool: """Whether two display names are the same person's, as far as we can tell. Case and surrounding space are ignored, an aside in parentheses is dropped so "Bob Reviewer (Arm)" is still Bob Reviewer, and a name written "Okonkwo, Amara" matches the same name written out straight. Unlike the comparison b4 makes internally, this is symmetric: it does not matter which of the two is the comma-separated one. Two people who share a name are indistinguishable here, so this is a weaker signal than :func:`email_matches` and should not be the only thing a decision rests on. An empty name matches nothing, not even another empty one. """ one, two = _clean_name(one), _clean_name(two) if not one or not two: return False if one == two: return True return _name_parts_match(one, two) or _name_parts_match(two, one) @dataclass(frozen=True) class Identity: """One person, and the names and addresses they are known by. Everything in this module that asks "is this the same person?" has to answer it the same way, or two callers looking at one thread will disagree about who reviewed it. This is that one way, and it is the seam where a caller's own knowledge goes: liblore can tell that ``foo+list@example.com`` and ``foo@example.com`` are one mailbox, but only a directory -- a MAINTAINERS file, a config entry, a mapping the maintainer wrote by hand -- knows that two unrelated addresses belong to one human. Build an Identity from what that directory says. A match on any one address or any one name is a match. Address matching is the strong signal; see :func:`name_matches` for why the name half is weaker. """ addrs: tuple[str, ...] = () names: tuple[str, ...] = () @classmethod def from_addr(cls, addr: tuple[str, str]) -> Identity: """Build an identity from one parsed ``(name, email)`` pair.""" name, email_addr = addr return cls( addrs=(email_addr,) if email_addr else (), names=(name,) if name else (), ) def matches_addr(self, addr: str) -> bool: """Whether *addr* is one of this person's mailboxes.""" return any(email_matches(addr, known) for known in self.addrs) def matches_name(self, name: str) -> bool: """Whether *name* is one of the names this person goes by.""" return any(name_matches(name, known) for known in self.names) def matches(self, who: str | tuple[str, str] | Identity) -> bool: """Whether *who* is this person. Accepts a bare address, a parsed ``(name, email)`` pair, or another identity. A pair matches on either half, so a participant whose address we have never seen is still recognised by name -- and vice versa, which is the usual case. """ if isinstance(who, Identity): return any(self.matches_addr(a) for a in who.addrs) or any( self.matches_name(n) for n in who.names ) if isinstance(who, str): return self.matches_addr(who) name, addr = who return self.matches_addr(addr) or self.matches_name(name) def to_dict(self) -> dict[str, Any]: """Return a JSON-serialisable view of this identity.""" return {'addrs': list(self.addrs), 'names': list(self.names)} def _as_identity(who: str | tuple[str, str] | Identity) -> Identity: """Coerce the several ways of naming a person into an Identity.""" if isinstance(who, Identity): return who if isinstance(who, str): return Identity(addrs=(who,)) return Identity.from_addr(who) @dataclass(frozen=True) class Trailer: """A ``Name: value`` trailer line seen somewhere in a thread. *type* is one of ``person`` (an endorsement by somebody, with *addr* holding their parsed name and email), ``utility`` (a fact about the patch, such as ``Fixes:`` or ``base-commit:``) or ``unknown`` (a trailer we could make no sense of). The split follows b4's. *msgid* and *date* say which message offered this trailer and when. Without them a trailer can only be placed in a thread, not in its timeline, and "was this reviewed before or after the last reply?" has no answer. Both are ``None`` for a trailer parsed on its own, outside any message. """ name: str value: str type: str = 'unknown' addr: tuple[str, str] | None = None msgid: str | None = None date: datetime.datetime | None = None @classmethod def parse( cls, name: str, value: str, *, msgid: str | None = None, date: datetime.datetime | None = None, ) -> Trailer: """Classify a raw ``name``/``value`` pair into a trailer. A person trailer's value is normalised while we are here, so that a stray space inside the brackets or a ```` tag left behind by an HTML mail client does not make two copies of the same endorsement look like two different ones. *msgid* and *date* are recorded as given; this does not go looking for them, since the caller is the one holding the message. """ origin: dict[str, Any] = {'msgid': msgid, 'date': date} if name.lower() in _UTILITY_TRAILERS or '://' in value: return cls(name=name, value=value, type='utility', **origin) if not _RE_HAS_ADDR.search(value): return cls(name=name, value=value, **origin) value = _RE_MAILTO.sub('', value).strip() match = _RE_TRAILER_ADDR.match(value) if match: addr = (match.group(1).strip(), match.group(2).strip()) else: addr = email.utils.parseaddr(value) if not addr[0] and not addr[1]: return cls(name=name, value=value, **origin) return cls( name=name, value=format_addrs([addr], clean=False), type='person', addr=addr, **origin, ) @property def key(self) -> str: """The trailer name lowercased, for case-insensitive matching.""" return self.name.lower() @property def is_utility(self) -> bool: """Whether this records a fact rather than somebody's endorsement.""" return self.type == 'utility' @property def is_person(self) -> bool: """Whether this is an endorsement by an identifiable person.""" return self.type == 'person' def to_dict(self) -> dict[str, Any]: """Return a JSON-serialisable view of this trailer.""" return { 'name': self.name, 'value': self.value, 'type': self.type, 'email': self.addr[1] if self.addr else None, 'msgid': self.msgid, 'date': _isoformat(self.date), } @dataclass(frozen=True) class PatchSubject: """What a subject line's ``[PATCH ...]`` brackets decompose into.""" title: str version: int = 1 counter: int = 1 expected: int = 1 prefixes: tuple[str, ...] = () is_patch: bool = False is_reply: bool = False version_inferred: bool = True counters_inferred: bool = True @property def is_cover(self) -> bool: """Whether this is a ``0/n`` cover letter.""" return self.is_patch and not self.counters_inferred and self.counter == 0 @dataclass(frozen=True) class MessageSummary: """What one message is, who sent it, and when. A thread's aggregate fields say *that* something happened somewhere in it. These say *where*, which is what any question about order needs: whose message came last, whether a review landed before or after a reroll, which patch in a series a tag was attached to. *author* is the sender, preferring an in-body ``From:`` when the message carries one, so a patch sent on somebody's behalf is attributed to whoever wrote it. """ msgid: str subject: str author: tuple[str, str] date: datetime.datetime | None patch: PatchSubject has_diff: bool = False trailers: tuple[Trailer, ...] = () @property def is_submission(self) -> bool: """Whether this message is a patch rather than a reply to one. A ``Re:`` carries the brackets of whatever it answers, so the subject alone cannot tell the two apart; this applies the rule that it is a submission only when it is not a reply. """ return self.patch.is_patch and not self.patch.is_reply def to_dict(self) -> dict[str, Any]: """Return a JSON-serialisable view of this message.""" return { 'msgid': self.msgid, 'subject': self.subject, 'author': {'name': self.author[0], 'email': self.author[1]}, 'date': _isoformat(self.date), 'is_submission': self.is_submission, 'is_reply': self.patch.is_reply, 'is_cover': self.patch.is_cover, 'counter': self.patch.counter, 'expected': self.patch.expected, 'version': self.patch.version, 'has_diff': self.has_diff, 'trailers': [trailer.to_dict() for trailer in self.trailers], } @dataclass(frozen=True) class ThreadSummary: """Descriptive facts about a single thread. ``msgids`` lists every message in the thread, the root first and the rest oldest-first, so a caller can go back to any individual message without re-deriving the grouping. It runs parallel to ``messages``: ``msgids[i]`` is ``messages[i].msgid``, and both are ``count`` long. A message that carries no Message-Id -- rare, but it happens -- still holds its place, as an empty string. The patch fields describe what the submissions in the thread *said about themselves*. In particular ``patch_count`` is how many patches were seen and ``expected`` is how many the brackets claimed there would be; comparing the two is the caller's business, not ours. ``author`` is who opened the thread and ``participants`` is everybody who spoke in it, the author included. The two are worth keeping apart: being in ``participants`` says you sent something, not that you answered anybody, and for the person who posted the series in the first place those are very different claims. ``participants`` is literal: it holds one entry per distinct From address, so somebody who wrote from two addresses appears twice. That is what the thread contains, and deciding that two addresses are one person is the caller's to do -- see :class:`Identity`. ``messages`` holds the same thread message by message, in the order of ``msgids``. The aggregate fields are all derivable from it and are kept because they are what most callers actually want. """ msgid: str subject: str count: int first: datetime.datetime | None last: datetime.datetime | None msgids: tuple[str, ...] = () is_patch_series: bool = False patch_count: int = 0 expected: int = 0 version: int = 1 has_cover: bool = False prefixes: tuple[str, ...] = () author: tuple[str, str] | None = None participants: tuple[tuple[str, str], ...] = () trailers: tuple[Trailer, ...] = () messages: tuple[MessageSummary, ...] = () @property def trailer_names(self) -> frozenset[str]: """Every trailer name seen in the thread, lowercased.""" return frozenset(trailer.key for trailer in self.trailers) @property def person_trailers(self) -> tuple[Trailer, ...]: """Trailers that are somebody's endorsement of the patches.""" return tuple(trailer for trailer in self.trailers if trailer.is_person) @property def utility_trailers(self) -> tuple[Trailer, ...]: """Trailers that record a fact, such as ``Fixes:`` or ``Link:``.""" return tuple(trailer for trailer in self.trailers if trailer.is_utility) @property def repliers(self) -> tuple[tuple[str, str], ...]: """Participants other than the author, in the order they spoke. The author is dropped however they spelled themselves, so somebody who posted the series from one address and answered a review from another is still not counted as having replied to it -- they are the thread's author either way. Which of the remaining people count as a real answer is, as ever, the caller's call. """ if self.author is None: return self.participants person = Identity.from_addr(self.author) return tuple(other for other in self.participants if not person.matches(other)) def messages_by( self, who: str | tuple[str, str] | Identity ) -> tuple[MessageSummary, ...]: """Messages sent by *who*, in thread order. *who* may be a bare address, a parsed ``(name, email)`` pair or an :class:`Identity`. A bare address is compared as an address only, so pass an Identity when one person's several unrelated addresses have to count as one. """ person = _as_identity(who) return tuple( message for message in self.messages if person.matches(message.author) ) def trailers_by(self, who: str | tuple[str, str] | Identity) -> tuple[Trailer, ...]: """Trailers naming *who*, in the order they were first offered. These are the endorsements attributed *to* that person, which is not the same as the trailers carried by messages they sent: a submitter collects a reviewer's tag into the next revision, and the tag is still the reviewer's. """ person = _as_identity(who) return tuple( trailer for trailer in self.trailers if trailer.addr is not None and person.matches(trailer.addr) ) def to_dict(self) -> dict[str, Any]: """Return a JSON-serialisable view of this summary. The key set is fixed and every key is always present, so a consumer never has to probe for optional fields. ``schema`` is :data:`SCHEMA_VERSION`, for a consumer that stores these. """ return { 'schema': SCHEMA_VERSION, 'msgid': self.msgid, 'subject': self.subject, 'count': self.count, 'msgids': list(self.msgids), 'first': _isoformat(self.first), 'last': _isoformat(self.last), 'is_patch_series': self.is_patch_series, 'patch_count': self.patch_count, 'expected': self.expected, 'version': self.version, 'has_cover': self.has_cover, 'prefixes': list(self.prefixes), 'author': ( None if self.author is None else {'name': self.author[0], 'email': self.author[1]} ), 'participants': [ {'name': name, 'email': addr} for name, addr in self.participants ], 'trailers': [trailer.to_dict() for trailer in self.trailers], 'messages': [message.to_dict() for message in self.messages], } def parse_patch_subject(subject: str) -> PatchSubject: """Decompose a subject line into its bracketed prefix parts. Handles the shapes that show up on kernel lists: ``[PATCH]``, ``[PATCH v3 2/7]``, ``[RFC PATCH net-next 0/4]``, the spaceless ``[PATCHv3]``, split groups like ``[RFC] [PATCH 1/2]``, nested or unbalanced brackets, and any of those behind a ``Re:``. Tokens that are not a ``PATCH`` marker, a version, or an ``n/m`` counter are kept in *prefixes* in the order they appeared. An unnumbered ``[PATCH]`` reports ``counter=1``, ``expected=1`` with ``counters_inferred`` set, since a lone patch is a one-patch series; the flags are there for a caller that needs to tell a stated ``1/1`` from an assumed one. The corner cases here follow b4's ``LoreSubject``, which has had them beaten out of it against the real lore corpus. """ remainder = re.sub(r'\s+', ' ', clean_header(subject)).strip() remainder, is_reply = strip_reply_prefixes(remainder) version = 1 counter = 1 expected = 1 version_inferred = True counters_inferred = True prefixes: list[str] = [] is_patch = False while True: match = _RE_BRACKET.match(remainder) if not match: break remainder = remainder[match.end() :] group = match.group(1) # Collapse a nested or unbalanced group such as "[[PATCH] v2" down # to its innermost content before tokenising it. while '[' in group: group = group[group.rindex('[') + 1 :] # "[PATCHv3]" -> "[PATCH v3]". group = _RE_GLUED_VERSION.sub(r'\1 \2', group) # Normalise "2 / 7" to "2/7" so the counter survives tokenising. group = re.sub(r'\s*/\s*', '/', group) for token in group.split(): # Subject prefixes get separated by stray punctuation often # enough to be worth cleaning up. token = token.strip(',;') if not token: continue lowered = token.lower() if lowered.find('patch') == 0: is_patch = True continue vmatch = _RE_VERSION.match(token) if vmatch: version = int(vmatch.group(1)) version_inferred = False continue cmatch = _RE_COUNTER.match(token) if cmatch: counter = int(cmatch.group(1)) expected = int(cmatch.group(2)) counters_inferred = False continue # Anything else -- "RFC", "net-next", "RESEND" -- is a # prefix, kept verbatim in the order it appeared. prefixes.append(token) return PatchSubject( title=remainder.strip(), version=version, counter=counter, expected=expected, prefixes=tuple(prefixes), is_patch=is_patch, is_reply=is_reply, version_inferred=version_inferred, counters_inferred=counters_inferred, ) def _is_self_attested(trailer: Trailer, author: tuple[str, str]) -> bool: """Whether a reply's person trailer was offered by the sender. b4 accepts a follow-up trailer only from the person it names: you may tag on your own behalf, not on somebody else's. Trailers that name nobody -- ``Fixes:``, ``Link:`` and the like -- are not subject to this, since there is no identity to compare. *author* is expected to come from the message headers. This is a check on who sent the mail, and nothing a sender writes in the body may answer that. It is also not a signature check: liblore does no DKIM, so this says a message claims to come from the person it tags, not that it provably does. """ if trailer.addr is None: return True return Identity.from_addr(author).matches(trailer.addr) def extract_trailers(msg: EmailMessage) -> list[Trailer]: """Return every trailer line found in a message body, in order. How a message is read depends on what it is, which is what b4 does too. A message carrying a diff is a patch, so its trailers are taken from the last paragraph of the message proper -- above the ``---``, below the commit message. That is what keeps an ordinary commit subject such as ``usb: fix the thing`` from being read as a trailer named ``usb``: it lives in the first paragraph, not the trailing block. Anything else is a reply, and reviewers put their tags wherever they like, so the whole body is scanned. The stricter follow-up rules apply there -- a person trailer must name a person, a URL only belongs in a link trailer -- and patch metadata such as ``From:`` or ``base-commit:`` is dropped, since a reply quoting a patch's headers has not thereby offered them. A reply's person trailer counts only when the sender named himself or herself in it, which is how b4 keeps a quoted or pasted address from being read as a fresh endorsement. A trailer may be indented -- some maintainers send them that way -- but one behind a ``>`` quoting marker is a copy of a trailer somebody wrote elsewhere and does not count here. """ return _read_trailers(msg)[0] def _read_trailers(msg: EmailMessage) -> tuple[list[Trailer], bool]: """Return a message's trailers and whether it carried a diff. The two come out together because deciding which is which is the same decision: reading the body once answers both, and a caller that wants both should not pay to parse it twice. """ body = msg_get_payload(msg, strip_signature=False) msgid = get_clean_msgid(msg) date = msg_get_date(msg) if body_has_diff(body): found = split_body_parts(body).trailers return [ Trailer.parse(name, value, msgid=msgid, date=date) for name, value in found ], True found, _others = find_trailers(body, followup=True) found = [(n, v) for n, v in found if n.lower() not in _REPLY_IGNORES] # Who sent the mail is a question only the headers can answer. An # in-body From: says who wrote a patch, which is a different # question and is body text the sender chose, so it takes no part in # deciding whether they may sign a tag. author = msg_get_author(msg) return [ trailer for trailer in ( Trailer.parse(name, value, msgid=msgid, date=date) for name, value in found ) if _is_self_attested(trailer, author) ], False def summarize_message(msg: EmailMessage) -> MessageSummary: """Summarise a single message on its own terms. Useful by itself, and what :func:`summarize_thread` builds its ``messages`` out of, so the two can never disagree about what a given message is. """ subject = msg_get_subject(msg) trailers, has_diff = _read_trailers(msg) return MessageSummary( msgid=get_clean_msgid(msg) or '', subject=subject, author=msg_get_author(msg, use_inbody=True), date=msg_get_date(msg), patch=parse_patch_subject(subject), has_diff=has_diff, trailers=tuple(trailers), ) def _thread_parents(msg: EmailMessage) -> set[str]: """Return the message IDs this message claims as ancestors.""" parents: set[str] = set() for header in ('In-Reply-To', 'References'): raw = msg.get(header) if not raw: continue parents.update(re.findall(r'<([^>]+)>', str(raw))) own = get_clean_msgid(msg) if own is not None: # A message that references itself is not its own ancestor. parents.discard(own) return parents def group_by_thread(msgs: Sequence[EmailMessage]) -> list[list[EmailMessage]]: """Partition a flat bag of messages into threads. A query against a public-inbox server returns messages from many different threads at once, so something has to split them up before they can be summarised. Messages are grouped into connected components of the reference graph, which keeps a thread together even when its root is missing from the input. Each returned thread is sorted oldest first, and the threads themselves are ordered by their earliest message. """ ids: list[str] = [] by_id: dict[str, EmailMessage] = {} for msg in msgs: msgid = get_clean_msgid(msg) if msgid is None or msgid in by_id: continue ids.append(msgid) by_id[msgid] = msg # Referenced ancestors join the graph even when the message itself is # absent from the input, so two replies whose common parent was not # returned by the query still land in the same thread. parent: dict[str, str] = {msgid: msgid for msgid in ids} for msg in by_id.values(): for ancestor in _thread_parents(msg): parent.setdefault(ancestor, ancestor) def find(msgid: str) -> str: while parent[msgid] != msgid: parent[msgid] = parent[parent[msgid]] msgid = parent[msgid] return msgid def union(left: str, right: str) -> None: lroot, rroot = find(left), find(right) if lroot != rroot: parent[rroot] = lroot for msgid in ids: for ancestor in _thread_parents(by_id[msgid]): union(msgid, ancestor) groups: dict[str, list[EmailMessage]] = {} for msgid in ids: groups.setdefault(find(msgid), []).append(by_id[msgid]) threads = [_sort_by_date(group) for group in groups.values()] threads.sort(key=lambda thread: _sort_key(_first_date(thread))) return threads def _first_date(msgs: Sequence[EmailMessage]) -> datetime.datetime | None: dates = [d for d in (msg_get_date(msg) for msg in msgs) if d is not None] return min(dates) if dates else None def _sort_key(when: datetime.datetime | None) -> datetime.datetime: """Sort undated items last, and make naive/aware datetimes comparable.""" if when is None: return datetime.datetime.max.replace(tzinfo=datetime.timezone.utc) if when.tzinfo is None: return when.replace(tzinfo=datetime.timezone.utc) return when def _patch_order(msg: EmailMessage) -> int: """Where a patch sits in its series, for breaking ties on the date. ``git send-email`` stamps a whole series within the same second, so dates alone leave the patches in whatever order they arrived in. """ parsed = parse_patch_subject(msg_get_subject(msg)) if not parsed.is_patch or parsed.is_reply or parsed.counters_inferred: return -1 return parsed.counter def _sort_by_date(msgs: Sequence[EmailMessage]) -> list[EmailMessage]: return sorted( msgs, key=lambda msg: (_sort_key(msg_get_date(msg)), _patch_order(msg)) ) def _pick_root(msgs: Sequence[EmailMessage]) -> EmailMessage: """Return the thread root: the oldest message with no parent present.""" present = {msgid for msgid in (get_clean_msgid(msg) for msg in msgs) if msgid} for msg in msgs: if not _thread_parents(msg) & present: return msg return msgs[0] def summarize_thread(msgs: Sequence[EmailMessage]) -> ThreadSummary: """Summarise one thread's worth of messages. The input is expected to be a single thread; use :func:`group_by_thread` first if it might hold more than one. Messages are considered in date order, so participants come back in the order they first spoke. """ if not msgs: raise ValueError('Cannot summarize an empty thread') ordered = _sort_by_date(msgs) root = _pick_root(ordered) summaries = [summarize_message(msg) for msg in ordered] # The aggregates below are accumulated in date order, so participants # still come back in the order they first spoke. Only the reported # messages tuple is re-ordered root first: a cover letter can be # stamped a second after the first patch, and messages[0] should be # the root. Moved by position rather than by message-id, so a # message that has none is not dropped on the way. in_thread_order = list(summaries) root_at = next(i for i, msg in enumerate(ordered) if msg is root) in_thread_order.insert(0, in_thread_order.pop(root_at)) dates = [s.date for s in summaries if s.date is not None] participants: list[tuple[str, str]] = [] seen_addrs: set[str] = set() trailers: list[Trailer] = [] seen_trailers: set[tuple[str, str]] = set() counters: set[int] = set() unnumbered = 0 prefixes: list[str] = [] seen_prefixes: set[str] = set() version = 1 expected = 0 is_patch_series = False has_cover = False for summary in summaries: name, addr = summary.author if addr.lower() not in seen_addrs: seen_addrs.add(addr.lower()) participants.append((name, addr)) # Deduped on name and value, so the thread-level tuple keeps one # copy of each distinct endorsement -- carrying the message-id # and date of the first message to offer it. Every occurrence is # still on the message that made it, in messages[]. for trailer in summary.trailers: dedupe = (trailer.key, trailer.value) if dedupe not in seen_trailers: seen_trailers.add(dedupe) trailers.append(trailer) parsed = summary.patch # A "Re:" carries the brackets of whatever it replies to, so it # must not be counted as another submission. if not summary.is_submission: continue is_patch_series = True version = max(version, parsed.version) expected = max(expected, parsed.expected) if parsed.is_cover: has_cover = True elif parsed.counters_inferred: # Nothing said "n/m", so this patch can only count as itself. unnumbered += 1 else: counters.add(parsed.counter) for prefix in parsed.prefixes: if prefix.lower() not in seen_prefixes: seen_prefixes.add(prefix.lower()) prefixes.append(prefix) patch_count = len(counters) + unnumbered return ThreadSummary( msgid=get_clean_msgid(root) or '', subject=msg_get_subject(root), count=len(ordered), msgids=tuple(summary.msgid for summary in in_thread_order), first=min(dates) if dates else None, last=max(dates) if dates else None, is_patch_series=is_patch_series, patch_count=patch_count, expected=expected, version=version, has_cover=has_cover, prefixes=tuple(prefixes), author=in_thread_order[0].author, participants=tuple(participants), trailers=tuple(trailers), messages=tuple(in_thread_order), ) def summarize_threads(msgs: Iterable[EmailMessage]) -> list[ThreadSummary]: """Group a flat bag of messages into threads and summarise each one. This is the entry point for a query result, which mixes messages from many threads together. Summaries come back ordered by their thread's earliest message. """ return [summarize_thread(thread) for thread in group_by_thread(list(msgs))] python-liblore-0.10.1/src/liblore/utils.py000066400000000000000000001235151526032434100205000ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2025-2026 The Linux Foundation """Message parsing, email utilities, threading, and mbox splitting.""" from __future__ import annotations import datetime import email.header import email.parser import email.quoprimime import email.utils import fnmatch import logging import re import textwrap import urllib.parse from collections.abc import Sequence from email.message import EmailMessage from typing import NamedTuple, cast from liblore import emlpolicy logger = logging.getLogger(__name__) # Matches the "From " separator line that starts each message in an mbox. # Mirrors git's is_from_line(): "From " + address + timestamp with a # colon surrounded by digits (HH:MM:SS) and a year > 90. _FROM_RE = re.compile( rb'^From \S+.*\d\d:\d\d:\d\d.*\d{4}', ) # Matches one or more ">" followed by "From " — the mboxrd escaping # that needs one level of ">" stripped. Mirrors git's is_gtfrom(). _GTFROM_RE = re.compile(rb'^>+From ') # Leading reply and forward markers in the languages mail clients # commonly use, including the counted "Re[2]:" form. Only real # markers: two or three letters and a colon is not enough, because # "mm:" and "net:" are kernel subsystems. _RE_REPLY = re.compile( r'^\s*(?:(?:re|aw|fwd?|sv|vs|antw|wg|tr|odp|ref)\s*(?:\[\d+])?\s*:\s*)+', re.IGNORECASE, ) # b4's catch-all for a reply marker in a language we do not know: two or # three letters and a colon, directly in front of a [bracketed] prefix. # A subsystem prefix is never followed by a bracket, so this is safe. _RE_REPLY_FOREIGN = re.compile(r'^\w{2,3}:\s*\[') # The contents of the first <...> pair in a msg-id header. _MSGID_RE = re.compile(r'<([^>]+)>') # ===================================================================== # Header and message-ID utilities # ===================================================================== def clean_header(hdrval: str | None) -> str: """Decode an RFC 2047 encoded header and normalise whitespace. Handles encoded words, email addresses with encoded display names, and the CPython formataddr quoting bug (bpo-100900). """ if hdrval is None: return '' if hdrval.find('=?') >= 0: # If the header contains email addresses, decode them individually if re.search(r'<\S+@\S+>', hdrval, flags=re.IGNORECASE | re.MULTILINE): newaddrs: list[str] = [] for addr in email.utils.getaddresses([hdrval]): if addr[0].find('=?') >= 0: addr = (clean_header(addr[0]), addr[1]) # Work around https://github.com/python/cpython/issues/100900 if re.search(r'[^\w\s]', addr[0]): newaddrs.append(f'"{addr[0]}" <{addr[1]}>') else: newaddrs.append(email.utils.formataddr(addr)) return ', '.join(newaddrs) decoded = '' for hstr, hcs in email.header.decode_header(hdrval): if hcs is None: hcs = 'utf-8' try: decoded += hstr.decode(hcs, errors='replace') except LookupError: decoded += hstr.decode('utf-8', errors='replace') except (UnicodeDecodeError, AttributeError): decoded += hstr else: decoded = hdrval new_hdrval = re.sub(r'\n?\s+', ' ', decoded) return new_hdrval.strip() def get_clean_msgid(msg: EmailMessage, header: str = 'Message-Id') -> str | None: """Extract a clean message ID (without angle brackets) from a header. Message-IDs are always ASCII and enclosed in angle brackets, and RFC 2047 forbids encoded words in msg-id headers (Message-Id, In-Reply-To, References). We therefore extract straight from the raw value and deliberately avoid :func:`clean_header`, whose address-parsing path would mangle a bare message-ID when the header also carries an encoded-word comment (e.g. Gnus-style In-Reply-To). "Raw" means the value as it appeared in the source message, taken from :meth:`~email.message.Message.raw_items`. The parsed value from ``msg.get()`` is not good enough: the policy's Message-ID parser reads ``(`` as the start of a comment and stops at a stray ``"``, so ```` comes back as ```` do we fall back to the parsed value, which decodes a (nonconformant) RFC 2047-encoded ID. This mirrors :func:`_clean_msgid_raw`, which does the same on raw message bytes. """ wanted = header.lower() raw = next((str(v) for n, v in msg.raw_items() if n.lower() == wanted), None) for value in (raw, msg.get(header)): if value: matches = _MSGID_RE.search(str(value)) if matches: return cast('str', matches.group(1)) return None def parse_message(raw: bytes) -> EmailMessage: """Parse raw email bytes into an EmailMessage using our policy.""" msg: EmailMessage = email.parser.BytesParser( policy=emlpolicy, ).parsebytes(raw) return msg # ===================================================================== # Email formatting and extraction # ===================================================================== def strip_reply_prefixes(subject: str) -> tuple[str, bool]: """Remove leading reply markers from *subject*. Returns the rest of the subject and whether it was a reply. The markers are ``Re:``, ``Fwd:`` and their forms in other languages (``Aw:``, ``Sv:``, ``Antw:``, ``Odp:`` and so on), also counted (``Re[2]:``). Two or three letters and a colon right before a ``[bracketed]`` prefix count too, as in b4, so a reply marker in another language is still removed from a patch subject. A subsystem prefix such as ``mm:`` or ``net:`` is kept:: >>> strip_reply_prefixes('Re: mm: fix the widget') ('mm: fix the widget', True) >>> strip_reply_prefixes('net: fix the socket') ('net: fix the socket', False) """ rest, count = _RE_REPLY.subn('', subject) if count: return rest, True if _RE_REPLY_FOREIGN.match(rest): return _RE_REPLY_FOREIGN.sub('[', rest, count=1), True return rest, False def msg_get_subject(msg: EmailMessage, strip_prefixes: bool = False) -> str: """Return the decoded Subject header. When *strip_prefixes* is ``True``, any leading ``[bracketed]`` prefixes (e.g. ``[PATCH v3 2/5]``, ``[RFC]``) and reply markers (see :func:`strip_reply_prefixes`) are removed, leaving just the bare subject text. A subsystem prefix such as ``mm:`` is kept. """ raw = msg.get('Subject', '') subject = clean_header(raw) if not strip_prefixes: return subject subject, _ = strip_reply_prefixes(subject) # Strip all leading [bracketed] prefixes subject = re.sub(r'^(\s*\[[^]]*]\s*)+', '', subject) return subject.strip() # Detecting real diff content, so a bare "---" in a free-form reply is # not mistaken for a patch separator. Both mirror b4, which has had the # corner cases beaten out of it against the real lore corpus. _RE_DIFF = re.compile( r'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', flags=re.MULTILINE | re.IGNORECASE, ) _RE_DIFFSTAT = re.compile( r'^\s*\d+ file.*\d+ (insertion|deletion)', flags=re.MULTILINE | re.IGNORECASE, ) # A trailer line, as b4 matches it: at the start of a line, optionally # indented, because some maintainers do send their trailers with leading # whitespace. The name must begin with a word character, which is what # keeps a quoted "> Reviewed-by:" out without having to parse quoting # styles at all. _RE_TRAILER_LINE = re.compile(r'^\s*(\w\S+):\s+(\S.*)$') # An "[for the foo bits]" line continuing the trailer above it. _RE_TRAILER_EXTINFO = re.compile(r'^\s*\[[^]]+]\s*$') # Values that look like trailers but never are. _TRAILER_IGNORES = frozenset( {'phone', 'mail', 'email', 'e-mail', 'prerequisite-message-id'} ) # Only meaningful as in-body git headers, and only before any other text. _TRAILER_HEADERS = frozenset({'subject', 'date', 'from'}) # Trailers whose value is a URL rather than a person. _TRAILER_LINKS = frozenset({'link', 'buglink', 'closes'}) # Trailers that legitimately carry no email address. _TRAILER_NONPERSON = _TRAILER_LINKS | { 'fixes', 'subject', 'date', 'obsoleted-by', 'change-id', 'base-commit', } _RE_VALUE_EMAIL = re.compile(r'\S+@\S+\.\S+') _RE_VALUE_LINK = re.compile(r'https?://') class BodyParts(NamedTuple): """The pieces a patch or reply body splits into. *githeaders* are the in-body ``From:``/``Subject:``/``Date:`` headers git recognises at the very start of a patch, *trailers* are the trailing ``Name: value`` block, *basement* is everything below the ``---`` separator (diffstat and diff), and *signature* is the text below a conformant ``-- `` marker. """ githeaders: list[tuple[str, str]] message: str trailers: list[tuple[str, str]] basement: str signature: str def find_trailers( text: str, followup: bool = False ) -> tuple[list[tuple[str, str]], list[str]]: """Find ``Name: value`` trailers in a block of text. A trailer starts at the beginning of a line and may be indented, since some maintainers send theirs with leading whitespace. A quoted one is still excluded, because a quoting marker such as ``>`` is not a word character. With *followup* set, the much stricter rules b4 applies to replies are used: the name must be ASCII, the value must contain an email address unless the trailer is a kind that legitimately has none, and a URL is only allowed in a link trailer. That strictness is what makes it safe to scan a whole reply rather than just its last paragraph, which is how a ``Reviewed-by:`` written in the middle of a reply still gets found. Returns ``(trailers, other_lines)``. Mail clients like to wrap long trailers, so a ``Fixes:`` hash comment or a bare ``
`` that spilled onto the next line is rejoined before scanning. Trailing ``# extra info`` is stripped from values, which also means the same trailer with and without a comment deduplicates to one entry. """ # Ignore everything below the standard email signature marker. text = text.split('\n-- \n', 1)[0].strip() + '\n' # Fixes: abcd0123 (foo bar # baz quux) text = re.sub( r'^(\S+:\s+[\da-f]+\s+\([^)]+)\n([^\n]+\))', r'\1 \2', text, flags=re.MULTILINE ) # Signed-off-by: Long Name # text = re.sub(r'^(\S+:\s+[^<]+)\n(<[^>]+>)$', r'\1 \2', text, flags=re.MULTILINE) trailers: list[tuple[str, str]] = [] others: list[str] = [] was_trailer = False for line in text.split('\n'): line = line.strip('\r') match = _RE_TRAILER_LINE.match(line) if match: name, value = match.group(1), match.group(2) lname = name.lower() if lname in _TRAILER_IGNORES: continue if others and lname in _TRAILER_HEADERS: # A git header only counts before any other content. continue if followup: if not lname.isascii(): continue if ( not _RE_VALUE_EMAIL.search(value) and lname not in _TRAILER_NONPERSON ): # A person trailer has to name a person. continue if _RE_VALUE_LINK.search(value) and lname not in _TRAILER_LINKS: continue if lname == 'fixes' and not re.match(r'^[0-9a-f]{6,}', value): continue extinfo = re.search(r'(.*\S+)(\s+#[^#]+)$', value) if extinfo: value = extinfo.group(1) was_trailer = True trailers.append((name, value.strip())) continue if len(line) > 2 and was_trailer and _RE_TRAILER_EXTINFO.match(line): # Continuation of the trailer above, not new content. was_trailer = False continue was_trailer = False others.append(line) return trailers, others def body_has_diff(body: str) -> bool: """Whether a body carries actual diff content, as opposed to quoting it.""" return bool(_RE_DIFF.search(body) or _RE_DIFFSTAT.search(body)) def split_body_parts(body: str) -> BodyParts: """Split a message body into git headers, message, trailers and diff. Trailers are taken from the *last* paragraph of the message and in-body git headers from the *first*, which is what keeps an ordinary commit subject such as ``usb: fix the thing`` from being read as a trailer named ``usb`` -- it sits in the first paragraph, not the trailing block. """ body = body.replace('\r', '').strip('\n') signature = '' sparts = body.rsplit('\n-- \n', 1) if len(sparts) > 1: signature = sparts[1] body = sparts[0] basement = '' parts = [body] # Only split on "---" for messages that actually carry a diff; in a # free-form reply it is just a visual separator. if body_has_diff(body): parts = re.split(r'^---\s*\n', body, maxsplit=1, flags=re.MULTILINE) if len(parts) == 2: basement = parts[1] elif body.find('\ndiff ') >= 0: parts = body.split('\ndiff ', 1) if len(parts) == 2: parts[1] = 'diff ' + parts[1] basement = parts[1] elif body.find('\n--- a/') >= 0: # Patches generated by some really peculiar tools. parts = body.split('\n--- a/', 1) if len(parts) == 2: parts[1] = '--- a/' + parts[1] basement = parts[1] paragraphs = parts[0].strip('\n').split('\n\n') githeaders: list[tuple[str, str]] = [] mparts: list[str] = [] head, other = find_trailers(paragraphs[0]) if [line for line in other if line.strip()]: # Not every line was a header, so it is ordinary content. mparts.append(paragraphs[0]) else: githeaders = head trailers, nlines = find_trailers(paragraphs[-1]) if len(paragraphs) == 1: if githeaders == trailers: # A message that consists of nothing but trailers. githeaders = [] return BodyParts(githeaders, '\n'.join(nlines), trailers, basement, signature) if len(paragraphs) > 2: mparts += paragraphs[1:-1] if nlines: mparts.append('\n'.join(nlines)) return BodyParts(githeaders, '\n\n'.join(mparts), trailers, basement, signature) def msg_get_inbody_author(msg: EmailMessage) -> tuple[str, str] | None: """Return ``(name, email)`` from a git-style in-body ``From:`` line. ``git mailinfo`` lets a patch name its real author by starting the body with a ``From:`` line, which overrides the header. This is how a patch keeps correct authorship when it is relayed through a remailer or a mailing list that rewrites the ``From:`` header, so the header alone cannot be trusted to name the author. Returns ``None`` when there is no in-body ``From:``. """ parts = split_body_parts(msg_get_payload(msg, strip_signature=False)) for name, value in parts.githeaders: if name.lower() == 'from': addrs = email.utils.getaddresses([clean_header(value)]) if addrs and addrs[0][1]: return addrs[0] return None def msg_get_author(msg: EmailMessage, use_inbody: bool = False) -> tuple[str, str]: """Return ``(name, email)`` for whoever sent the message. Lists that rewrite From to survive DMARC keep the original sender in ``X-Original-From``, which is preferred over the rewritten header so that the person is reported rather than the mailing list. The header is unauthenticated -- anybody can set it, and liblore does no DKIM by design -- so this reports who the message claims to be from. Code that needs proof wants b4's attestation, which checks the header against a signature before believing it. When *use_inbody* is ``True``, a git-style in-body ``From:`` line takes precedence over both, matching what ``git am`` would record as the author. See :func:`msg_get_inbody_author`. """ if use_inbody: inbody = msg_get_inbody_author(msg) if inbody is not None: return inbody author = ('', 'missing@address.local') for header in ('x-original-from', 'from'): fh = msg.get(header) if fh: addrs = email.utils.getaddresses([clean_header(fh)]) if addrs and addrs[0][1]: return addrs[0] return author def msg_get_payload( msg: EmailMessage, strip_quoted: bool = False, strip_signature: bool = True, ) -> str: """Return the plain-text body of a message. Optionally strips the email signature (``-- `` marker) and/or quoted lines (lines starting with ``> ``). """ mp = msg.get_body(preferencelist=('plain',)) if mp is None: return '' bbody = mp.get_payload(decode=True) if bbody is None: return '' cs = mp.get_content_charset() if not cs: cs = 'utf-8' if isinstance(bbody, bytes): try: cpay = bbody.decode(cs, errors='replace') except LookupError: # A charset Python has no codec for: ``charset="yes"`` and # friends do turn up on the lists. ``errors='replace'`` is no # help here, because it is the codec name that fails to # resolve, before any byte is looked at. Fall back to utf-8 # and hope for the best, the way b4 does for message bodies # and :func:`clean_header` already does for headers. cpay = bbody.decode('utf-8', errors='replace') else: cpay = str(bbody) if strip_signature: chunks = cpay.rsplit('\n-- \n', maxsplit=1) cbody = chunks[0] else: cbody = cpay if not strip_quoted: return cbody stripped: list[str] = [] for line in cbody.splitlines(): if not line.startswith('> '): stripped.append(line) return '\n'.join(stripped) # Adapted from email._parseaddr — RFC 5322 specials that require quoting _QSPECIALS = re.compile(r'[()<>@,:;.\"\[\]]') def format_addrs(pairs: list[tuple[str, str]], clean: bool = True) -> str: """Format ``(name, email)`` pairs into an RFC 5322 address string. Works around CPython issue #100900 where ``formataddr`` mis-quotes display names containing RFC 5322 special characters. When *clean* is ``True`` (the default), RFC 2047 encoded names are decoded first via :func:`clean_header`. """ addrs: list[str] = [] for name, addr in pairs: if not name or name == addr: addrs.append(addr) continue if clean: name = clean_header(name) # Work around https://github.com/python/cpython/issues/100900 if ( not name.startswith('=?') and not name.startswith('"') and _QSPECIALS.search(name) ): addrs.append(f'"{email.utils.quote(name)}" <{addr}>') continue if name.isascii(): addrs.append(email.utils.formataddr((name, addr))) else: # Avoid email.utils.formataddr re-encoding unicode to RFC 2047 addrs.append(f'{name} <{addr}>') return ', '.join(addrs) def wrap_header(hdr: tuple[str, str], width: int = 75, nl: str = '\n') -> bytes: """Wrap and RFC 2047-encode a single header for outbound email. Address headers (To, Cc, From, X-Original-From) are parsed and each address is encoded individually. Non-address headers are word-wrapped (ASCII) or RFC 2047 quoted-printable encoded (non-ASCII). *nl* controls the line ending: ``'\\n'`` for dry-run display, ``'\\r\\n'`` for SMTP transmission. """ hname, hval = hdr _parts: list[str] = [] if hname.lower() in ('to', 'cc', 'from', 'x-original-from'): _parts.append(f'{hname}: ') first = True for addr in email.utils.getaddresses([hval]): if not addr[0].isascii(): enc_name = email.quoprimime.header_encode( addr[0].encode(), charset='utf-8' ) qp = format_addrs([(enc_name, addr[1])], clean=False) else: qp = format_addrs([addr], clean=False) if first: _parts[-1] += qp first = False continue if len(_parts[-1] + ', ' + qp) > width: _parts[-1] += ', ' _parts.append(qp) continue _parts[-1] += ', ' + qp else: hdata = f'{hname}: {hval}' if hval.isascii(): if len(hdata) <= width: return hdata.encode() # Trick: replace ': ' with ':_' so textwrap doesn't break there hdata = hdata.replace(': ', ':_', 1) wrapped = textwrap.wrap( hdata, break_long_words=False, break_on_hyphens=False, subsequent_indent=' ', width=width, ) return nl.join(wrapped).replace(':_', ': ', 1).encode() # Non-ASCII: encode as RFC 2047 quoted-printable qp = f'{hname}: ' + email.quoprimime.header_encode( hval.encode(), charset='utf-8' ) if len(qp) <= width: return qp.encode() while len(qp) > width: wrapat = width - 2 if len(_parts): wrapat -= 1 # Don't break in the middle of a =XX escape sequence while '=' in qp[wrapat - 2 : wrapat]: wrapat -= 1 _parts.append(qp[:wrapat] + '?=') qp = '=?utf-8?q?' + qp[wrapat:] _parts.append(qp) return f'{nl} '.join(_parts).encode() def get_msg_as_bytes(msg: EmailMessage, nl: str = '\n') -> bytes: """Serialize a message with proper header encoding for SMTP. Uses :func:`wrap_header` for RFC 2047 encoding and line wrapping instead of Python's buggy ``as_bytes()``. *nl* controls the line ending: ``'\\n'`` for dry-run display, ``'\\r\\n'`` for SMTP transmission. """ bdata = b'' for hname, hval in msg.items(): bdata += wrap_header((hname, str(hval)), nl=nl) + nl.encode() bdata += nl.encode() payload = msg.get_payload(decode=True) if not isinstance(payload, bytes): payload = str(msg.get_payload() or '').encode('utf-8', errors='replace') for bline in payload.split(b'\n'): bdata += re.sub(rb'[\r\n]*$', b'', bline) + nl.encode() return bdata def msg_get_recipients(msg: EmailMessage) -> set[str]: """Return the set of lowercased email addresses from To, Cc, and From.""" pairs: list[tuple[str, str]] = [] if msg.get('To'): pairs += email.utils.getaddresses([str(x) for x in msg.get_all('to', [])]) if msg.get('Cc'): pairs += email.utils.getaddresses([str(x) for x in msg.get_all('cc', [])]) if msg.get('From'): pairs += email.utils.getaddresses([str(x) for x in msg.get_all('from', [])]) return {x[1].lower() for x in pairs} def msg_get_date(msg: EmailMessage) -> datetime.datetime | None: """Return the message timestamp, preferring Received over Date. Uses the latest ``Received`` header timestamp when one is present, because those are stamped by the receiving servers rather than by the sender, and falls back to the client-set ``Date`` header. Returns ``None`` when neither yields a parsable timestamp. """ latest_rdt: datetime.datetime | None = None for rhdr in msg.get_all('Received', []): # Received headers generally end with "; datetimeinfo" chunks = str(rhdr).rsplit(';', maxsplit=1) if len(chunks) < 2: continue rdate = chunks[1].strip() try: rdt = email.utils.parsedate_to_datetime(rdate) except (ValueError, TypeError): continue if not latest_rdt or latest_rdt < rdt: latest_rdt = rdt if not latest_rdt and msg.get('Date'): try: latest_rdt = email.utils.parsedate_to_datetime(str(msg.get('Date'))) except (ValueError, TypeError): pass return latest_rdt def sort_msgs_by_received(msgs: list[EmailMessage]) -> list[EmailMessage]: """Sort messages by latest Received header timestamp, falling back to Date.""" tosort: list[tuple[datetime.datetime, EmailMessage]] = [] for msg in msgs: latest_rdt = msg_get_date(msg) if not latest_rdt: logger.debug('Message without a date: %s!', msg.get('Message-ID')) continue tosort.append((latest_rdt, msg)) return [msg for _rdt, msg in sorted(tosort, key=lambda x: x[0])] # ===================================================================== # Thread filtering # ===================================================================== def get_strict_thread( msgs: list[EmailMessage], msgid: str, noparent: bool = False, ) -> list[EmailMessage] | None: """Walk the reference graph and return only messages belonging to a thread. Starting from *msgid*, follows In-Reply-To and References headers to collect all messages that are part of the same thread. Messages outside the thread are discarded. When *noparent* is ``True``, the parent references of *msgid* are ignored, effectively breaking the thread at that point. This is useful for standalone patches posted in the middle of an unrelated discussion. Returns ``None`` when no messages match. """ # Build msgid -> message map msgid_map: dict[str, EmailMessage] = {} for msg in msgs: c_msgid = get_clean_msgid(msg) if c_msgid is not None: msgid_map[c_msgid] = msg want: set[str] = {msgid} ignore: set[str] = set() got: set[str] = set() seen: set[str] = set() maybe: dict[str, set[str]] = {} strict: list[EmailMessage] = [] while True: for c_msgid, msg in msgid_map.items(): if c_msgid in ignore: continue seen.add(c_msgid) if c_msgid in got: continue refs: set[str] = set() raw_refs: list[str] = [] for hdr_name in ('in-reply-to', 'references'): for hval in msg.get_all(hdr_name, []): raw_refs += re.findall(r'<([^>]+)>', str(hval)) # Some tooling emits messages that reference their own Message-ID. # A self-reference is never meaningful for threading, so drop it # before it can be mistaken for a parent. if c_msgid in raw_refs: logger.debug('Ignoring self-reference in %s', c_msgid) raw_refs = [ref for ref in raw_refs if ref != c_msgid] # If noparent, pretend our target message has no references if noparent and msgid == c_msgid: logger.info('Breaking thread to remove parents of %s', msgid) ignore = set(raw_refs) raw_refs = [] for ref in set(raw_refs): if ref in ignore: continue if ref in got or ref in want: want.add(c_msgid) elif len(ref): refs.add(ref) if c_msgid not in want: if ref not in maybe: maybe[ref] = set() maybe[ref].add(c_msgid) if c_msgid in want: strict.append(msg) got.add(c_msgid) want.update(refs) want.discard(c_msgid) if c_msgid in maybe: want.update(maybe[c_msgid]) maybe.pop(c_msgid) for ref in refs: if ref in maybe: want.update(maybe[ref]) maybe.pop(ref) # Remove entries that are missing or already collected for c_msgid in set(want): if c_msgid not in seen or c_msgid in got: want.remove(c_msgid) if not want: break if not strict: return None if len(msgs) > len(strict): logger.debug( 'Reduced thread to requested matches only (%s->%s)', len(msgs), len(strict), ) return strict # ===================================================================== # Thread minimization # ===================================================================== # Detect diff and diffstat content — messages containing these are # preserved verbatim by minimize_thread(). DIFF_RE = re.compile(r'^diff\s', re.MULTILINE) DIFFSTAT_RE = re.compile(r'^\s*\d+ file.*changed', re.MULTILINE) # Default set of headers to keep when minimizing messages. MINIMIZE_KEEP_HEADERS: tuple[str, ...] = ( 'From', 'To', 'Cc', 'Subject', 'Date', 'Message-ID', 'Reply-To', 'In-Reply-To', ) def minimize_thread( msgs: list[EmailMessage], keep_headers: Sequence[str] | None = None, reduce_quote_context: bool = False, ) -> list[EmailMessage]: """Reduce thread messages to essential headers and clean up excessive quoting. Creates lightweight copies of each message, keeping only the headers listed in *keep_headers* (defaults to :data:`MINIMIZE_KEEP_HEADERS`). For non-patch messages the body is processed to remove: - Multi-level quotes (``>>`` and deeper) - Empty quote-only lines (bare ``>``) - Trailing quoted blocks at the bottom of the message When *reduce_quote_context* is ``True``, quoted blocks that precede a reply are further reduced to just the last paragraph, with earlier paragraphs replaced by a ``> [... skip NN lines ...]`` marker. This only kicks in when more than 5 lines would be skipped, so short quotes are left untouched. Compliant email signatures (``-- `` marker) are split off before quote processing and re-appended afterwards, so that a trailing quoted block followed only by a signature is correctly recognised as a bottom quote and dropped. Messages containing diffs or diffstats are preserved as-is. Messages that become empty after minimization are dropped. """ if keep_headers is None: keep_headers = MINIMIZE_KEEP_HEADERS mmsgs: list[EmailMessage] = [] for msg in msgs: mmsg = EmailMessage() for hdr in keep_headers: val = clean_header(msg[hdr]) if val: mmsg[hdr] = val body = msg_get_payload(msg, strip_signature=False) if ( not re.search(r'^>', body, re.MULTILINE) or DIFF_RE.search(body) or DIFFSTAT_RE.search(body) ): mmsg.set_payload(body, charset='utf-8') mmsgs.append(mmsg) continue # Split off the signature before quote processing so trailing # quoted blocks before the sig are recognised as bottom quotes. sig = '' sig_parts = body.rsplit('\n-- \n', maxsplit=1) if len(sig_parts) == 2: body = sig_parts[0] sig = '\n-- \n' + sig_parts[1] # Split lines into alternating quoted / unquoted chunks chunks: list[tuple[bool, list[str]]] = [] chunk: list[str] = [] current: bool | None = None for line in body.rstrip().splitlines(): quoted = line.startswith('>') if current is None: current = quoted if current == quoted: if quoted and re.search(r'^>\s*>', line): # Strip multi-level quotes continue if quoted and not chunk and line.strip() == '>': # Strip leading empty quote lines continue chunk.append(line) continue # Flush previous chunk, trimming trailing empty quote lines if current: while chunk and chunk[-1].strip() == '>': chunk.pop(-1) if chunk: chunks.append((current, chunk)) chunk = [] if not (quoted and line.strip() == '>'): chunk.append(line) current = quoted if current is None: current = False # Don't append trailing quoted chunks if chunk and not current: chunks.append((current, chunk)) # Reduce long quoted blocks to just the last paragraph if reduce_quote_context: for idx, (is_quoted, qchunk) in enumerate(chunks): if not is_quoted: continue # Only reduce quoted chunks followed by an unquoted reply if idx + 1 >= len(chunks) or chunks[idx + 1][0]: continue # Find the last paragraph boundary (empty quote line) last_break = -1 for li, line in enumerate(qchunk): if line.strip() in ('>', ''): last_break = li if last_break <= 0: continue skipped = last_break if skipped <= 5: continue last_para = qchunk[last_break + 1 :] chunks[idx] = ( True, [ f'> [... skip {skipped} lines ...]', '>', ] + last_para, ) parts: list[str] = [] for _quoted, chunk in chunks: parts.append('\n'.join(chunk)) body = '\n'.join(parts).strip() + '\n' if not body.strip(): continue # Re-append the signature if sig: body = body.rstrip('\n') + sig mmsg.set_payload(body, charset='utf-8') mmsgs.append(mmsg) return mmsgs # ===================================================================== # Mbox splitting and deduplication # ===================================================================== def _is_from_line(line: bytes) -> bool: """Check whether *line* is an mbox ``From `` separator.""" return len(line) >= 20 and _FROM_RE.match(line) is not None def _get_raw_header(raw: bytes, name: str) -> str | None: """Extract a header value from raw message bytes without full parsing. Performs a case-insensitive scan of the header block (up to the first blank line). Handles folded headers (continuation lines starting with whitespace). """ prefix = name.lower().encode() + b':' hdr_end = raw.find(b'\n\n') header_block = raw[:hdr_end] if hdr_end >= 0 else raw value: bytes | None = None for line in header_block.split(b'\n'): if value is not None and line and line[:1] in (b' ', b'\t'): value += b' ' + line.strip() continue if value is not None: break if line.lower().startswith(prefix): value = line[len(prefix) :].strip() if value is not None: decoded: str = value.decode('ascii', errors='replace') return decoded return None def _clean_msgid_raw(raw: bytes, header: str = 'message-id') -> str | None: """Extract a clean message ID (without angle brackets) from raw bytes.""" val = _get_raw_header(raw, header) if val: match = _MSGID_RE.search(val) if match: return cast('str', match.group(1)) return None def split_mbox_as_bytes(mbox_bytes: bytes) -> list[bytes]: """Split mboxrd bytes into a list of raw message byte strings. Handles the mboxrd variant used by public-inbox: for every body line that matches ``>+From ``, one leading ``>`` is stripped. """ if not mbox_bytes: return [] msgs: list[bytes] = [] current: list[bytes] = [] in_header = False for raw_line in mbox_bytes.split(b'\n'): line = raw_line + b'\n' if _is_from_line(line): # Flush the previous message if current: msgs.append(b''.join(current)) current = [] in_header = True continue if in_header and line == b'\n': # End of headers, start of body in_header = False current.append(line) continue if not in_header and _GTFROM_RE.match(line): # Undo one level of mboxrd >From escaping line = line[1:] current.append(line) # Flush the last message if current: msgs.append(b''.join(current)) return msgs def split_mbox(mbox_bytes: bytes) -> list[EmailMessage]: """Split mboxrd bytes into a list of parsed EmailMessage objects. Handles the mboxrd variant used by public-inbox: for every body line that matches ``>+From ``, one leading ``>`` is stripped. """ return [parse_message(raw) for raw in split_mbox_as_bytes(mbox_bytes)] # Default List-Id preference order. Sources listed first are less # likely to mangle messages (e.g. rewrite From: or add footers), so # they are preferred when the same message arrives from multiple lists. DEFAULT_LISTID_PREFERENCE: list[str] = [ '*.feeds.kernel.org', '*.linux.dev', '*.kernel.org', '*', ] def _listid_preference_index( listid: str | None, listid_preference: list[str], ) -> int: """Return the preference index for a List-Id value. Lower is better. Falls back to the ``*`` wildcard position, or one past the end if no wildcard is present. """ if listid: for idx, pattern in enumerate(listid_preference): if fnmatch.fnmatch(listid, pattern): return idx try: return listid_preference.index('*') except ValueError: return len(listid_preference) def get_preferred_duplicate( msg1: EmailMessage, msg2: EmailMessage, listid_preference: list[str] | None = None, ) -> EmailMessage: """Return the preferred copy of a duplicate message. When the same message (same Message-ID) arrives from multiple mailing lists, some lists mangle it (Mailman 2 rewrites From:, adds footers, etc.). This function picks the copy from the source least likely to mangle, based on the ``List-Id`` header and the *listid_preference* glob patterns. Patterns earlier in the list are preferred. The default order matches b4's ``listid-preference`` setting. """ if listid_preference is None: listid_preference = DEFAULT_LISTID_PREFERENCE idx1 = _listid_preference_index(get_clean_msgid(msg1, 'List-Id'), listid_preference) idx2 = _listid_preference_index(get_clean_msgid(msg2, 'List-Id'), listid_preference) if idx1 <= idx2: logger.debug( 'Picked duplicate from preferred source: %s', get_clean_msgid(msg1, 'List-Id'), ) return msg1 logger.debug( 'Picked duplicate from preferred source: %s', get_clean_msgid(msg2, 'List-Id') ) return msg2 def split_and_dedupe_as_bytes( mbox_bytes: bytes, listid_preference: list[str] | None = None, ) -> list[bytes]: """Split mboxrd bytes and deduplicate by Message-ID, returning raw bytes. Like :func:`split_and_dedupe` but avoids parsing messages into :class:`~email.message.EmailMessage` objects. Header inspection is done directly on raw bytes. When duplicate Message-IDs are found, the copy from the preferred source is kept (based on ``List-Id`` header matching against *listid_preference* patterns). Pass an empty list to disable preference logic and keep the first occurrence. Messages without a Message-ID are silently dropped. """ if listid_preference is None: listid_preference = DEFAULT_LISTID_PREFERENCE use_preference = len(listid_preference) > 0 chunks = split_mbox_as_bytes(mbox_bytes) deduped: dict[str, bytes] = {} for raw in chunks: msgid = _clean_msgid_raw(raw) if msgid is None: logger.debug( 'No message-id found, ignoring %s', _get_raw_header(raw, 'subject') or '(no subject)', ) continue if msgid in deduped: if use_preference: idx_old = _listid_preference_index( _clean_msgid_raw(deduped[msgid], 'list-id'), listid_preference, ) idx_new = _listid_preference_index( _clean_msgid_raw(raw, 'list-id'), listid_preference, ) if idx_new < idx_old: deduped[msgid] = raw else: logger.debug('Dropping duplicate message-id: %s', msgid) continue deduped[msgid] = raw return list(deduped.values()) def split_and_dedupe( mbox_bytes: bytes, listid_preference: list[str] | None = None, ) -> list[EmailMessage]: """Split mboxrd bytes and deduplicate by Message-ID. When duplicate Message-IDs are found, the copy from the preferred source is kept (based on ``List-Id`` header matching against *listid_preference* patterns). Pass an empty list to disable preference logic and keep the first occurrence. Messages without a Message-ID are silently dropped. """ return [ parse_message(raw) for raw in split_and_dedupe_as_bytes(mbox_bytes, listid_preference) ] # ===================================================================== # Pure helpers # ===================================================================== def get_msgid_from_url(url: str) -> str: """Extract a message ID from a lore.kernel.org URL. If the input is already a bare message ID (no ``://``), it is returned with angle brackets stripped. """ if '://' in url: matches = re.search( r'^https?://[^@]+/([^/]+(?:@|%40)[^/]+)', url, re.IGNORECASE ) if matches: return urllib.parse.unquote(matches.groups()[0]) return url.strip('<>') python-liblore-0.10.1/tests/000077500000000000000000000000001526032434100157025ustar00rootroot00000000000000python-liblore-0.10.1/tests/conftest.py000066400000000000000000000054521526032434100201070ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Shared fixtures for liblore tests.""" from __future__ import annotations import email.utils import textwrap from collections.abc import Iterator from email.message import EmailMessage from typing import Protocol import pytest import responses as responses_ from liblore import emlpolicy @pytest.fixture(autouse=True) def _block_network() -> Iterator[None]: # pyright: ignore[reportUnusedFunction] with responses_.RequestsMock(): yield class MsgFactory(Protocol): def __call__( self, subject: str = ..., from_addr: tuple[str, str] = ..., msgid: str | None = ..., in_reply_to: str | None = ..., references: list[str] | None = ..., body: str = ..., date: str | None = ..., ) -> EmailMessage: ... @pytest.fixture() def make_msg() -> MsgFactory: """Factory fixture that returns a helper callable for test messages.""" counter = 0 def create( subject: str = 'Test message', from_addr: tuple[str, str] = ('Test User', 'test@example.com'), msgid: str | None = None, in_reply_to: str | None = None, references: list[str] | None = None, body: str = 'Hello, world!\n', date: str | None = None, ) -> EmailMessage: nonlocal counter counter += 1 if msgid is None: msgid = f'msg{counter}@example.com' msg = EmailMessage(policy=emlpolicy) msg['Subject'] = subject msg['From'] = email.utils.formataddr(from_addr) msg['Message-Id'] = f'<{msgid}>' if in_reply_to: msg['In-Reply-To'] = f'<{in_reply_to}>' if references: msg['References'] = ' '.join(f'<{r}>' for r in references) if date: msg['Date'] = date # Pass an explicit cte to match emlpolicy's cte_type and to avoid # CPython's auto-CTE heuristic, which crashes under Python < 3.13 # when the policy sets max_line_length=None (as emlpolicy does). msg.set_content(body, cte='8bit') return msg return create @pytest.fixture() def sample_mbox() -> bytes: """Return a minimal two-message mbox as bytes.""" return textwrap.dedent("""\ From test@example.com Mon Jan 1 00:00:00 2024 From: Alice To: bob@example.com Subject: First message Message-Id: Date: Mon, 01 Jan 2024 00:00:00 +0000 Hello from Alice. From test@example.com Mon Jan 1 00:01:00 2024 From: Bob To: alice@example.com Subject: Second message Message-Id: Date: Mon, 01 Jan 2024 00:01:00 +0000 In-Reply-To: Hello from Bob. """).encode() python-liblore-0.10.1/tests/test_auth_headers.py000066400000000000000000000143661526032434100217610ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Tests for optional authheaders integration in LoreNode.""" from __future__ import annotations import gzip import sys from collections.abc import Iterator from email.message import EmailMessage from types import ModuleType from unittest.mock import MagicMock, patch import pytest import responses from liblore import LibloreError from liblore.node import LoreNode, _AuthenticateMessage class _FakeAuthHeaders(ModuleType): authenticate_message: _AuthenticateMessage # ===================================================================== # Import-time validation # ===================================================================== class TestAuthHeadersImport: def test_raises_when_authheaders_missing(self) -> None: with ( patch.dict(sys.modules, {'authheaders': None}), pytest.raises(LibloreError, match='authheaders library is required'), ): LoreNode(add_auth_headers=True) def test_ok_when_authheaders_installed(self) -> None: fake = _FakeAuthHeaders('authheaders') fake.authenticate_message = MagicMock() with patch.dict(sys.modules, {'authheaders': fake}): node = LoreNode(add_auth_headers=True) assert node._authenticate_message is not None node.close() def test_default_is_disabled(self) -> None: node = LoreNode() assert node._authenticate_message is None node.close() # ===================================================================== # _authenticate_msgs # ===================================================================== class TestAuthenticateMsgs: def test_noop_when_disabled(self) -> None: node = LoreNode() msg = EmailMessage() msg['Subject'] = 'Test' node._authenticate_msgs([msg]) assert 'Authentication-Results' not in msg def test_adds_header_when_enabled(self) -> None: fake = _FakeAuthHeaders('authheaders') fake.authenticate_message = MagicMock( return_value='Authentication-Results: liblore; dkim=pass header.d=example.com', ) with patch.dict(sys.modules, {'authheaders': fake}): node = LoreNode(add_auth_headers=True) msg = EmailMessage() msg['From'] = 'test@example.com' msg['Subject'] = 'Test' msg.set_content('Hello\n') node._authenticate_msgs([msg]) assert msg['Authentication-Results'] == ( 'liblore; dkim=pass header.d=example.com' ) fake.authenticate_message.assert_called_once() call_kwargs = fake.authenticate_message.call_args assert call_kwargs[0][1] == 'liblore' assert call_kwargs[1]['dkim'] is True assert call_kwargs[1]['dmarc'] is True assert call_kwargs[1]['arc'] is True assert call_kwargs[1]['spf'] is False node.close() def test_skips_empty_result(self) -> None: fake = _FakeAuthHeaders('authheaders') fake.authenticate_message = MagicMock(return_value='') with patch.dict(sys.modules, {'authheaders': fake}): node = LoreNode(add_auth_headers=True) msg = EmailMessage() msg['Subject'] = 'Test' msg.set_content('Hello\n') node._authenticate_msgs([msg]) assert 'Authentication-Results' not in msg node.close() def test_multiple_messages(self) -> None: fake = _FakeAuthHeaders('authheaders') fake.authenticate_message = MagicMock( side_effect=[ 'liblore; dkim=pass', 'Authentication-Results: liblore; dkim=fail', ], ) with patch.dict(sys.modules, {'authheaders': fake}): node = LoreNode(add_auth_headers=True) msg1 = EmailMessage() msg1['Subject'] = 'Msg 1' msg1.set_content('Body 1\n') msg2 = EmailMessage() msg2['Subject'] = 'Msg 2' msg2.set_content('Body 2\n') node._authenticate_msgs([msg1, msg2]) assert msg1['Authentication-Results'] == 'liblore; dkim=pass' assert msg2['Authentication-Results'] == 'liblore; dkim=fail' node.close() # ===================================================================== # Integration with fetch methods # ===================================================================== class TestAuthInFetchMethods: @pytest.fixture() def auth_node(self) -> Iterator[tuple[LoreNode, responses.RequestsMock]]: fake = _FakeAuthHeaders('authheaders') fake.authenticate_message = MagicMock( return_value='Authentication-Results: liblore; dkim=pass', ) with patch.dict(sys.modules, {'authheaders': fake}): node = LoreNode(add_auth_headers=True) # Patch authheaders into sys.modules for _authenticate_msgs self._fake = fake self._patcher = patch.dict(sys.modules, {'authheaders': fake}) self._patcher.start() with responses.RequestsMock() as rsps: yield node, rsps def teardown_method(self) -> None: if hasattr(self, '_patcher'): self._patcher.stop() def test_get_thread_by_msgid( self, auth_node: tuple[LoreNode, responses.RequestsMock], sample_mbox: bytes, ) -> None: node, rsps = auth_node rsps.add( responses.GET, 'https://lore.kernel.org/all/first%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) msgs = node.get_thread_by_msgid('first@example.com') for msg in msgs: assert msg['Authentication-Results'] == 'liblore; dkim=pass' def test_get_thread_by_query( self, auth_node: tuple[LoreNode, responses.RequestsMock], sample_mbox: bytes, ) -> None: node, rsps = auth_node rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&q=test+query', body=gzip.compress(sample_mbox), status=200, ) msgs = node.get_thread_by_query('test query') for msg in msgs: assert msg['Authentication-Results'] == 'liblore; dkim=pass' python-liblore-0.10.1/tests/test_cache.py000066400000000000000000000342531526032434100203650ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Tests for LoreNode filesystem caching.""" from __future__ import annotations import gzip import os import time from pathlib import Path from unittest import mock import pytest import responses from liblore import LoreNode, RemoteError from liblore.node import _ORPHAN_TMP_AGE, _write_atomic DISK_FULL = OSError('disk full') class TestCacheReadWrite: """Unit tests for _cache_key, _cache_read, _cache_write.""" def test_cache_disabled_by_default(self) -> None: node = LoreNode('https://lore.kernel.org/all') assert node._cache_dir is None assert node._cache_read('anything') is None def test_cache_write_and_read(self, tmp_path: Path) -> None: node = LoreNode(cache_dir=str(tmp_path), cache_ttl=60) key = node._cache_key('test', 'data') node._cache_write(key, b'hello world') assert node._cache_read(key) == b'hello world' def test_cache_miss_returns_none(self, tmp_path: Path) -> None: node = LoreNode(cache_dir=str(tmp_path)) key = node._cache_key('test', 'nonexistent') assert node._cache_read(key) is None def test_cache_expired_returns_none(self, tmp_path: Path) -> None: node = LoreNode(cache_dir=str(tmp_path), cache_ttl=1) key = node._cache_key('test', 'data') node._cache_write(key, b'old data') # Backdate the file to make it stale path = tmp_path / f'{key}.lore.cache' os.utime(path, (0, 0)) assert node._cache_read(key) is None # Stale file should be cleaned up assert not path.exists() def test_cache_write_noop_when_disabled(self) -> None: node = LoreNode() # Should not raise even though cache_dir is None node._cache_write('key', b'data') def test_cache_write_atomic(self, tmp_path: Path) -> None: node = LoreNode(cache_dir=str(tmp_path)) key = node._cache_key('test', 'atomic') node._cache_write(key, b'data') # No .tmp files left behind tmp_files = [f for f in os.listdir(tmp_path) if f.endswith('.tmp')] assert tmp_files == [] def test_cache_key_varies_by_url(self) -> None: node_a = LoreNode('https://lore.kernel.org/all') node_b = LoreNode('https://other.example.com/all') key_a = node_a._cache_key('ns', 'data') key_b = node_b._cache_key('ns', 'data') assert key_a != key_b def test_cache_key_varies_by_namespace(self, tmp_path: Path) -> None: node = LoreNode(cache_dir=str(tmp_path)) key_a = node._cache_key('mbox_by_msgid', 'test@x.com') key_b = node._cache_key('mbox_by_query', 'test@x.com') assert key_a != key_b def test_cache_empty_bytes(self, tmp_path: Path) -> None: """Empty bytes (valid server response) should be cached.""" node = LoreNode(cache_dir=str(tmp_path), cache_ttl=60) key = node._cache_key('test', 'empty') node._cache_write(key, b'') assert node._cache_read(key) == b'' def test_cache_dir_created(self, tmp_path: Path) -> None: cache_dir = str(tmp_path / 'sub' / 'dir') LoreNode(cache_dir=cache_dir) assert os.path.isdir(cache_dir) class TestWriteAtomic: def test_writes(self, tmp_path: Path) -> None: path = tmp_path / 'x.lore.cache' _write_atomic(str(path), b'data') assert path.read_bytes() == b'data' assert os.listdir(tmp_path) == ['x.lore.cache'] def test_two_writers_of_one_file( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A second process writes the file while the first is writing it.""" path = str(tmp_path / 'x.lore.cache') real_replace = os.replace calls = 0 def replace(src: str, dst: str) -> None: nonlocal calls calls += 1 if calls == 1: _write_atomic(path, b'second') real_replace(src, dst) monkeypatch.setattr(os, 'replace', replace) # With a shared temporary name, the second writer would find # the first one's file, and one of the two writes would fail. _write_atomic(path, b'first') assert calls == 2 # The last one to finish wins, and nothing is left behind. assert Path(path).read_bytes() == b'first' assert os.listdir(tmp_path) == ['x.lore.cache'] @pytest.mark.skipif(os.name != 'posix', reason='POSIX file modes') def test_mode_follows_umask(self, tmp_path: Path) -> None: """Like open(), not like mkstemp(), which always gives 0o600.""" path = tmp_path / 'x.lore.cache' old = os.umask(0o022) try: _write_atomic(str(path), b'data') finally: os.umask(old) assert path.stat().st_mode & 0o777 == 0o644 def test_failure_removes_temporary_file( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(os, 'replace', mock.Mock(side_effect=DISK_FULL)) with pytest.raises(OSError, match='disk full'): _write_atomic(str(tmp_path / 'x.lore.cache'), b'data') assert os.listdir(tmp_path) == [] def test_cache_write_failure_is_only_logged( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(os, 'replace', mock.Mock(side_effect=DISK_FULL)) node = LoreNode(cache_dir=str(tmp_path)) node._cache_write(node._cache_key('a', '1'), b'data') assert os.listdir(tmp_path) == [] class TestClearCache: def test_clears_cache_files(self, tmp_path: Path) -> None: node = LoreNode(cache_dir=str(tmp_path)) node._cache_write(node._cache_key('a', '1'), b'data1') node._cache_write(node._cache_key('b', '2'), b'data2') assert len(list(tmp_path.glob('*.lore.cache'))) == 2 node.clear_cache() assert len(list(tmp_path.glob('*.lore.cache'))) == 0 def test_preserves_non_cache_files(self, tmp_path: Path) -> None: node = LoreNode(cache_dir=str(tmp_path)) (tmp_path / 'other.txt').write_text('keep me') node._cache_write(node._cache_key('a', '1'), b'data') node.clear_cache() assert (tmp_path / 'other.txt').exists() def test_noop_when_disabled(self) -> None: node = LoreNode() node.clear_cache() # Should not raise def test_removes_orphaned_temporary_files(self, tmp_path: Path) -> None: """Left by a killed process: nothing else would ever remove them.""" orphans = [ 'a.lore.cache.0123456789abcdef.tmp', 'b.lore.upstream.0123456789abcdef.tmp', # The name that versions before 0.10 used. 'c.lore.cache.tmp', ] kept = ['d.lore.upstream', 'other.tmp', 'e.lore.cache.notours.tmp'] for name in orphans + kept: (tmp_path / name).write_bytes(b'x') old = time.time() - _ORPHAN_TMP_AGE - 1 for name in orphans: os.utime(tmp_path / name, (old, old)) LoreNode(cache_dir=str(tmp_path)).clear_cache() assert sorted(os.listdir(tmp_path)) == sorted(kept) def test_keeps_temporary_files_in_use(self, tmp_path: Path) -> None: """Another process may be writing it right now.""" fresh = tmp_path / 'a.lore.cache.0123456789abcdef.tmp' fresh.write_bytes(b'x') LoreNode(cache_dir=str(tmp_path)).clear_cache() assert os.listdir(tmp_path) == [fresh.name] class TestProperties: def test_url_property(self) -> None: node = LoreNode('https://lore.kernel.org/all/') # Trailing slash is stripped by constructor assert node.url == 'https://lore.kernel.org/all' def test_hostname_property(self) -> None: node = LoreNode('https://lore.kernel.org/all') assert node.hostname == 'lore.kernel.org' def test_hostname_different_host(self) -> None: node = LoreNode('https://inbox.example.com/linux-kernel') assert node.hostname == 'inbox.example.com' class TestCachedMethods: """Integration tests: verify cache hit avoids network, cache miss hits network.""" def _make_node(self, tmp_path: Path, _sample_mbox: bytes) -> LoreNode: node = LoreNode( 'https://lore.kernel.org/all', cache_dir=str(tmp_path), cache_ttl=60 ) return node def test_get_mbox_by_msgid_caches(self, tmp_path: Path, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = self._make_node(tmp_path, sample_mbox) rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) result1 = node.get_mbox_by_msgid('test@example.com') result2 = node.get_mbox_by_msgid('test@example.com') assert result1 == result2 == sample_mbox # Network should only be hit once assert len(rsps.calls) == 1 def test_get_mbox_by_query_caches(self, tmp_path: Path, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = self._make_node(tmp_path, sample_mbox) rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&q=test+query', body=gzip.compress(sample_mbox), status=200, ) result1 = node.get_mbox_by_query('test query') result2 = node.get_mbox_by_query('test query') assert result1 == result2 == sample_mbox assert len(rsps.calls) == 1 def test_get_mbox_by_query_full_threads_separate_key( self, tmp_path: Path, sample_mbox: bytes ) -> None: with responses.RequestsMock() as rsps: node = self._make_node(tmp_path, sample_mbox) rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&q=test', body=gzip.compress(sample_mbox), status=200, ) rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&t=1&q=test', body=gzip.compress(sample_mbox), status=200, ) node.get_mbox_by_query('test', full_threads=False) node.get_mbox_by_query('test', full_threads=True) # Different full_threads values = different cache keys = two network calls assert len(rsps.calls) == 2 def test_get_message_by_msgid_caches(self, tmp_path: Path) -> None: with responses.RequestsMock() as rsps: node = LoreNode( 'https://lore.kernel.org/all', cache_dir=str(tmp_path), cache_ttl=60 ) rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/raw', body=b'raw email bytes', status=200, ) result1 = node.get_message_by_msgid('test@example.com') result2 = node.get_message_by_msgid('test@example.com') assert result1 == result2 == b'raw email bytes' assert len(rsps.calls) == 1 def test_cache_expired_refetches(self, tmp_path: Path, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = self._make_node(tmp_path, sample_mbox) rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) node._cache_ttl = 1 node.get_mbox_by_msgid('test@example.com') assert len(rsps.calls) == 1 # Backdate the cache file for f in tmp_path.glob('*.lore.cache'): os.utime(f, (0, 0)) node.get_mbox_by_msgid('test@example.com') assert len(rsps.calls) == 2 def test_no_cache_no_files(self, tmp_path: Path, sample_mbox: bytes) -> None: """When cache_dir is None, no files are written.""" with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) node.get_mbox_by_msgid('test@example.com') # tmp_path should be empty since we didn't set cache_dir to it assert list(tmp_path.iterdir()) == [] def test_fetch_thread_since_not_cached( self, tmp_path: Path, sample_mbox: bytes ) -> None: """_fetch_thread_since should NOT be cached.""" with responses.RequestsMock() as rsps: node = self._make_node(tmp_path, sample_mbox) rsps.add( responses.POST, 'https://lore.kernel.org/all/test%40example.com/?x=m&q=dt%3A20240101..', body=gzip.compress(sample_mbox), status=200, ) node._fetch_thread_since('test@example.com', 'dt:20240101..') node._fetch_thread_since('test@example.com', 'dt:20240101..') # Both calls should hit the network assert len(rsps.calls) == 2 def test_error_not_cached(self, tmp_path: Path) -> None: """Network errors should not be cached.""" with responses.RequestsMock() as rsps: node = LoreNode( 'https://lore.kernel.org/all', cache_dir=str(tmp_path), cache_ttl=60 ) rsps.add( responses.GET, 'https://lore.kernel.org/all/bad%40example.com/t.mbox.gz', status=404, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/bad%40example.com/', status=404, ) with pytest.raises(RemoteError): node.get_mbox_by_msgid('bad@example.com') # No cache file should be written assert list(tmp_path.glob('*.lore.cache')) == [] python-liblore-0.10.1/tests/test_email_utils.py000066400000000000000000000171321526032434100216260ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Tests for liblore.email_utils.""" from __future__ import annotations from email.message import EmailMessage import pytest from conftest import MsgFactory from liblore import emlpolicy from liblore.utils import ( msg_get_author, msg_get_payload, msg_get_recipients, msg_get_subject, sort_msgs_by_received, strip_reply_prefixes, ) class TestMsgGetSubject: def test_plain_subject(self, make_msg: MsgFactory) -> None: msg = make_msg(subject='Just a plain subject') assert msg_get_subject(msg) == 'Just a plain subject' def test_no_strip(self, make_msg: MsgFactory) -> None: msg = make_msg(subject='[PATCH v3 2/5] subsys: fix thing') assert msg_get_subject(msg) == '[PATCH v3 2/5] subsys: fix thing' def test_strip_patch_prefix(self, make_msg: MsgFactory) -> None: msg = make_msg(subject='[PATCH v3 2/5] subsys: fix thing') assert msg_get_subject(msg, strip_prefixes=True) == 'subsys: fix thing' def test_strip_re_and_prefix(self, make_msg: MsgFactory) -> None: msg = make_msg(subject='Re: [PATCH] Something cool') assert msg_get_subject(msg, strip_prefixes=True) == 'Something cool' def test_strip_multiple_prefixes(self, make_msg: MsgFactory) -> None: msg = make_msg(subject='[RFC PATCH v2 0/3] [net-next] New feature') result = msg_get_subject(msg, strip_prefixes=True) assert result == 'New feature' def test_strip_aw_prefix(self, make_msg: MsgFactory) -> None: msg = make_msg(subject='Aw: [PATCH] German reply') assert msg_get_subject(msg, strip_prefixes=True) == 'German reply' def test_no_subject(self) -> None: msg = EmailMessage(policy=emlpolicy) assert msg_get_subject(msg) == '' def test_strip_no_brackets(self, make_msg: MsgFactory) -> None: msg = make_msg(subject='No brackets here') assert msg_get_subject(msg, strip_prefixes=True) == 'No brackets here' @pytest.mark.parametrize( ('subject', 'bare'), [ # A subsystem prefix looks like a short reply marker, but # it is part of the subject. ('Re: mm: fix the widget', 'mm: fix the widget'), ('net: fix the socket', 'net: fix the socket'), ('bpf: x', 'bpf: x'), ('Re: [PATCH] mm: x', 'mm: x'), # Reply and forward markers, stacked and in other languages. ('Fwd: Re: [PATCH] x', 'x'), ('AW: x', 'x'), ('RE[2]: Odp: x', 'x'), ('Sv: Vs: WG: TR: x', 'x'), # An unknown marker is removed only in front of a bracket. ('Отв: [PATCH v2] x', 'x'), ], ) def test_strip_keeps_subsystem( self, make_msg: MsgFactory, subject: str, bare: str ) -> None: msg = make_msg(subject=subject) assert msg_get_subject(msg, strip_prefixes=True) == bare class TestStripReplyPrefixes: def test_reply(self) -> None: assert strip_reply_prefixes('Re: mm: x') == ('mm: x', True) def test_not_a_reply(self) -> None: assert strip_reply_prefixes('mm: x') == ('mm: x', False) def test_counted_marker(self) -> None: assert strip_reply_prefixes('Re[3]: x') == ('x', True) def test_unknown_marker_keeps_the_bracket(self) -> None: assert strip_reply_prefixes('Отв: [PATCH] x') == ('[PATCH] x', True) def test_unknown_marker_without_bracket_is_kept(self) -> None: # It might be a subsystem; there is no way to tell, so keep it. assert strip_reply_prefixes('xyz: x') == ('xyz: x', False) class TestMsgGetAuthor: def test_normal_from(self, make_msg: MsgFactory) -> None: msg = make_msg(from_addr=('Jane Doe', 'jane@example.com')) name, addr = msg_get_author(msg) assert name == 'Jane Doe' assert addr == 'jane@example.com' def test_missing_from(self) -> None: msg = EmailMessage(policy=emlpolicy) name, addr = msg_get_author(msg) assert name == '' assert addr == 'missing@address.local' def test_empty_name(self) -> None: msg = EmailMessage(policy=emlpolicy) msg['From'] = 'noname@example.com' name, addr = msg_get_author(msg) assert name == '' assert addr == 'noname@example.com' class TestMsgGetPayload: def test_plain_body(self, make_msg: MsgFactory) -> None: msg = make_msg(body='This is the body.\n') assert 'This is the body.' in msg_get_payload(msg) def test_strip_signature(self, make_msg: MsgFactory) -> None: msg = make_msg(body='Body text.\n-- \nMy Sig\n') result = msg_get_payload(msg, strip_signature=True) assert 'Body text.' in result assert 'My Sig' not in result def test_keep_signature(self, make_msg: MsgFactory) -> None: msg = make_msg(body='Body text.\n-- \nMy Sig\n') result = msg_get_payload(msg, strip_signature=False) assert 'My Sig' in result def test_strip_quoted(self, make_msg: MsgFactory) -> None: msg = make_msg(body='My reply.\n> Quoted line.\nMore text.\n') result = msg_get_payload(msg, strip_quoted=True) assert 'My reply.' in result assert 'Quoted line.' not in result assert 'More text.' in result def test_empty_body(self) -> None: msg = EmailMessage(policy=emlpolicy) assert msg_get_payload(msg) == '' def test_unknown_charset(self, make_msg: MsgFactory) -> None: """``charset="yes"`` is a real thing posted to the lists, and Python raises LookupError rather than returning replacement characters: the codec name is what cannot be resolved, so ``errors='replace'`` never gets a say.""" msg = make_msg(body='Body text.\n') msg.set_param('charset', 'yes') assert 'Body text.' in msg_get_payload(msg) def test_unknown_charset_keeps_non_ascii(self, make_msg: MsgFactory) -> None: """Falling back must still decode what it can, rather than discarding the body or mangling every byte above ASCII.""" msg = make_msg(body='Ceci coûte cher.\n') msg.set_param('charset', 'definitely-not-a-codec') assert 'coûte' in msg_get_payload(msg) class TestMsgGetRecipients: def test_to_cc_from(self) -> None: msg = EmailMessage(policy=emlpolicy) msg['To'] = 'alice@example.com' msg['Cc'] = 'bob@example.com' msg['From'] = 'carol@example.com' recips = msg_get_recipients(msg) assert recips == {'alice@example.com', 'bob@example.com', 'carol@example.com'} def test_case_normalisation(self) -> None: msg = EmailMessage(policy=emlpolicy) msg['To'] = 'ALICE@EXAMPLE.COM' msg['From'] = 'alice@example.com' recips = msg_get_recipients(msg) assert recips == {'alice@example.com'} class TestSortMsgsByReceived: def test_sorts_by_date(self, make_msg: MsgFactory) -> None: msg1 = make_msg( msgid='older@x.com', date='Mon, 01 Jan 2024 00:00:00 +0000', ) msg2 = make_msg( msgid='newer@x.com', date='Tue, 02 Jan 2024 00:00:00 +0000', ) # Feed them in reverse order result = sort_msgs_by_received([msg2, msg1]) assert result[0]['Message-Id'] == '' assert result[1]['Message-Id'] == '' def test_skips_dateless(self, make_msg: MsgFactory) -> None: msg = make_msg() # No Date header set at all — del it if make_msg added one if 'Date' in msg: del msg['Date'] result = sort_msgs_by_received([msg]) assert result == [] python-liblore-0.10.1/tests/test_formatting.py000066400000000000000000000442461526032434100214770ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Tests for email formatting and thread minimization.""" from __future__ import annotations from email.message import EmailMessage import pytest from conftest import MsgFactory from liblore.utils import ( clean_header, format_addrs, get_msg_as_bytes, minimize_thread, wrap_header, ) # Adapted from b4 test_format_addrs — validates cpython#100900 workaround # and RFC 2047 handling in address formatting. class TestFormatAddrs: @pytest.mark.parametrize( 'pairs,verify,clean', [ ( [('', 'foo@example.com'), ('Foo Bar', 'bar@example.com')], 'foo@example.com, Foo Bar ', True, ), ( [('', 'foo@example.com'), ('Foo, Bar', 'bar@example.com')], 'foo@example.com, "Foo, Bar" ', True, ), ( [('', 'foo@example.com'), ('F\u00f4o, Bar', 'bar@example.com')], 'foo@example.com, "F\u00f4o, Bar" ', True, ), ( [ ('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx_Foo?=', 'quux@example.com'), ], 'foo@example.com, Qu\u00fbx Foo ', True, ), ( [ ('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com'), ], 'foo@example.com, "Qu\u00fbx, Foo" ', True, ), ( [ ('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com'), ], 'foo@example.com, =?utf-8?q?Qu=C3=BBx=2C_Foo?= ', False, ), ], ) def test_format_addrs( self, pairs: list[tuple[str, str]], verify: str, clean: bool ) -> None: assert format_addrs(pairs, clean) == verify # Adapted from b4 test_header_wrapping — validates RFC 2047 encoding, # line wrapping, and proper handling of address vs non-address headers. class TestWrapHeader: @pytest.mark.parametrize( 'hval,verify', [ # Short ASCII — no wrapping needed ('short-ascii', 'short-ascii'), # Short Unicode — RFC 2047 QP encoded ('short-unic\u00f4de', '=?utf-8?q?short-unic=C3=B4de?='), # Long ASCII — wrapped at word boundary ( ( 'Lorem ipsum dolor sit amet consectetur adipiscing elit ' 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua' ), ( 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do\n' ' eiusmod tempor incididunt ut labore et dolore magna aliqua' ), ), # Long Unicode — split across multiple encoded lines ( ( 'Lorem \u00eepsum dolor sit amet consectetur adipiscing el\u00eet ' 'sed do eiusmod temp\u00f4r incididunt ut labore et dol\u00f4re magna aliqua' ), ( '=?utf-8?q?Lorem_=C3=AEpsum_dolor_sit_amet_consectetur_adipiscin?=\n' ' =?utf-8?q?g_el=C3=AEt_sed_do_eiusmod_temp=C3=B4r_incididunt_ut_labore_et?=\n' ' =?utf-8?q?_dol=C3=B4re_magna_aliqua?=' ), ), # Exactly 75 chars — boundary condition ( 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', ), # Unicode on escape boundary ( 'Lorem ipsum dolor sit amet consectetur adipiscin el\u00eet', ( '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipiscin_el?=\n' ' =?utf-8?q?=C3=AEt?=' ), ), # Unicode 1 char too long ( 'Lorem ipsum dolor sit amet consectetur adipi el\u00eet', ( '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipi_el=C3=AE?=\n' ' =?utf-8?q?t?=' ), ), ], ) def test_non_address_header(self, hval: str, verify: str) -> None: wrapped = wrap_header(('X-Header', hval)) assert wrapped.decode() == f'X-Header: {verify}' @pytest.mark.parametrize( 'hval,verify', [ # Single address ('foo@example.com', 'foo@example.com'), # Two addresses ('foo@example.com, bar@example.com', 'foo@example.com, bar@example.com'), # Mixed plain + display name ( 'foo@example.com, Foo Bar ', 'foo@example.com, Foo Bar ', ), # Mixed Unicode — non-ASCII name gets QP encoded ( 'foo@example.com, Foo Bar , F\u00f4o Baz ', ( 'foo@example.com, Foo Bar , \n' ' =?utf-8?q?F=C3=B4o_Baz?= ' ), ), # Complex with quoted specials ( ( 'foo@example.com, Foo Bar , ' 'F\u00f4o Baz , "Quux, Foo" ' ), ( 'foo@example.com, Foo Bar , \n' ' =?utf-8?q?F=C3=B4o_Baz?= , ' '"Quux, Foo" ' ), ), # Long local part forces line wrap ( ( '01234567890123456789012345678901234567890123456789012345678901@example.org, ' '\u00e4 ' ), ( '01234567890123456789012345678901234567890123456789012345678901@example.org, \n' ' =?utf-8?q?=C3=A4?= ' ), ), # cpython#100900 — Unicode name with RFC 5322 specials ( ( 'foo@example.com, Foo Bar , ' 'F\u00f4o Baz , "Qu\u00fbx, Foo" ' ), ( 'foo@example.com, Foo Bar , \n' ' =?utf-8?q?F=C3=B4o_Baz?= , \n' ' =?utf-8?q?Qu=C3=BBx=2C_Foo?= ' ), ), ], ) def test_address_header(self, hval: str, verify: str) -> None: wrapped = wrap_header(('To', hval)) assert wrapped.decode() == f'To: {verify}' @pytest.mark.parametrize( 'hval,verify', [ # Short message-id ( '<20240319-short-message-id@example.com>', '<20240319-short-message-id@example.com>', ), # Long message-id — unbreakable, stays on one line ( ( '<20240319-very-long-message-id-that-spans-multiple-lines-for-sure' '-because-longer-than-75-characters-abcde123456@longdomain.example.com>' ), ( '<20240319-very-long-message-id-that-spans-multiple-lines-for-sure' '-because-longer-than-75-characters-abcde123456@longdomain.example.com>' ), ), ], ) def test_message_id(self, hval: str, verify: str) -> None: wrapped = wrap_header(('Message-ID', hval)) assert wrapped.decode() == f'Message-ID: {verify}' def test_round_trip(self) -> None: """Encoded non-address headers should decode back to the original.""" hval = 'L\u00f4rem \u00eepsum d\u00f4lor sit \u00e0met' wrapped = wrap_header(('X-Header', hval)) _, wval = wrapped.split(b':', maxsplit=1) assert clean_header(wval.decode()) == hval class TestGetMsgAsBytes: def test_headers_and_body(self) -> None: msg = EmailMessage() msg['Subject'] = 'Test subject' msg['To'] = 'dest@example.com' msg.set_payload('Hello world.\n') msg.set_charset('utf-8') bdata = get_msg_as_bytes(msg) text = bdata.decode() assert 'Subject: Test subject\n' in text assert 'To: dest@example.com\n' in text assert 'Hello world.\n' in text def test_smtp_line_endings(self) -> None: msg = EmailMessage() msg['Subject'] = 'Test' msg.set_payload('Body.\n') msg.set_charset('utf-8') bdata = get_msg_as_bytes(msg, nl='\r\n') # Every line should end with \r\n, no bare LF or CR stripped = bdata.replace(b'\r\n', b'') assert b'\n' not in stripped assert b'\r' not in stripped def test_non_ascii_header_encoded(self) -> None: msg = EmailMessage() msg['Subject'] = 'H\u00e9llo w\u00f6rld' msg.set_payload('Body.\n') msg.set_charset('utf-8') bdata = get_msg_as_bytes(msg) # Subject should be RFC 2047 encoded, not raw UTF-8 assert b'=?utf-8?q?' in bdata assert 'H\u00e9llo'.encode() not in bdata class TestMinimizeThread: def test_headers_filtered(self, make_msg: MsgFactory) -> None: """Only headers in MINIMIZE_KEEP_HEADERS are kept.""" msg = make_msg(subject='Test', body='Hello.\n') msg['X-Custom'] = 'should be dropped' msg['List-Id'] = '' result = minimize_thread([msg]) assert len(result) == 1 assert result[0]['Subject'] == 'Test' assert result[0]['X-Custom'] is None assert result[0]['List-Id'] is None def test_keep_headers_default(self, make_msg: MsgFactory) -> None: """All default MINIMIZE_KEEP_HEADERS are preserved when present.""" msg = make_msg( subject='Test', from_addr=('Alice', 'alice@example.com'), body='Hello.\n', date='Mon, 01 Jan 2024 00:00:00 +0000', in_reply_to='parent@example.com', ) msg['To'] = 'bob@example.com' msg['Cc'] = 'carol@example.com' msg['Reply-To'] = 'alice@example.com' result = minimize_thread([msg]) assert len(result) == 1 mmsg = result[0] assert mmsg['From'] is not None assert mmsg['To'] is not None assert mmsg['Cc'] is not None assert mmsg['Subject'] is not None assert mmsg['Date'] is not None assert mmsg['Message-ID'] is not None assert mmsg['Reply-To'] is not None assert mmsg['In-Reply-To'] is not None def test_custom_keep_headers(self, make_msg: MsgFactory) -> None: """Callers can override which headers to keep.""" msg = make_msg( subject='Test', body='Hello.\n', date='Mon, 01 Jan 2024 00:00:00 +0000' ) result = minimize_thread([msg], keep_headers=('Subject',)) assert len(result) == 1 assert result[0]['Subject'] == 'Test' assert result[0]['From'] is None assert result[0]['Date'] is None def test_multi_level_quotes_stripped(self, make_msg: MsgFactory) -> None: """Lines with >> (multi-level quoting) are removed.""" body = 'My reply.\n> Single quote.\n>> Double quote.\nMore text.\n' msg = make_msg(body=body) result = minimize_thread([msg]) assert len(result) == 1 text = result[0].get_payload() assert 'My reply.' in text assert 'Single quote.' in text assert 'Double quote.' not in text assert 'More text.' in text def test_empty_quote_lines_stripped(self, make_msg: MsgFactory) -> None: """Bare '>' lines are cleaned up.""" body = 'Reply.\n>\n> Real quote.\nMore text.\n' msg = make_msg(body=body) result = minimize_thread([msg]) text = result[0].get_payload() assert 'Reply.' in text assert 'Real quote.' in text assert 'More text.' in text def test_bottom_quotes_dropped(self, make_msg: MsgFactory) -> None: """Trailing quoted blocks at the end of a message are dropped.""" body = 'My reply.\n> Original message.\n' msg = make_msg(body=body) result = minimize_thread([msg]) text = result[0].get_payload() assert 'My reply.' in text assert 'Original message.' not in text def test_diff_preserved(self, make_msg: MsgFactory) -> None: """Messages with diff content are not minimized.""" body = ( '> Quoted context.\n' 'diff --git a/file.c b/file.c\n' '--- a/file.c\n' '+++ b/file.c\n' '@@ -1,3 +1,3 @@\n' '-old\n' '+new\n' ) msg = make_msg(body=body) result = minimize_thread([msg]) text = result[0].get_payload() assert 'diff --git' in text assert 'Quoted context.' in text def test_diffstat_preserved(self, make_msg: MsgFactory) -> None: """Messages with diffstat content are not minimized.""" body = '> Quoted.\n 3 files changed, 10 insertions(+), 5 deletions(-)\n' msg = make_msg(body=body) result = minimize_thread([msg]) text = result[0].get_payload() assert 'Quoted.' in text assert '3 files changed' in text def test_empty_after_minimize_dropped(self, make_msg: MsgFactory) -> None: """Messages that become empty after minimization are dropped.""" body = '> Only quoted text.\n>> And deeper quotes.\n' msg = make_msg(body=body) result = minimize_thread([msg]) assert len(result) == 0 def test_signature_preserved(self, make_msg: MsgFactory) -> None: """Compliant signatures are kept after quote processing.""" body = 'My reply.\n-- \nJane Doe\n' msg = make_msg(body=body) result = minimize_thread([msg]) text = result[0].get_payload() assert 'My reply.' in text assert '-- \n' in text assert 'Jane Doe' in text def test_trailing_quote_before_sig_dropped(self, make_msg: MsgFactory) -> None: """A trailing quoted block before a signature is dropped.""" body = 'My reply.\n> Huge quoted original.\n> More quoting.\n-- \nJane Doe\n' msg = make_msg(body=body) result = minimize_thread([msg]) text = result[0].get_payload() assert 'My reply.' in text assert 'Huge quoted original.' not in text assert 'More quoting.' not in text assert 'Jane Doe' in text def test_only_quotes_before_sig_dropped(self, make_msg: MsgFactory) -> None: """Message with only quotes before a signature is dropped entirely.""" body = '> Only quoted text.\n-- \nJane Doe\n' msg = make_msg(body=body) result = minimize_thread([msg]) assert len(result) == 0 def test_reduce_quote_context(self, make_msg: MsgFactory) -> None: """Long quoted blocks are reduced to the last paragraph.""" body = ( 'On Monday, Julius Caesar wrote:\n' '> First paragraph line one.\n' '> First paragraph line two.\n' '> First paragraph line three.\n' '> First paragraph line four.\n' '>\n' '> Second paragraph line one.\n' '> Second paragraph line two.\n' '>\n' '> Third paragraph line one.\n' '> Third paragraph line two.\n' 'My reply here.\n' ) msg = make_msg(body=body) result = minimize_thread([msg], reduce_quote_context=True) text = result[0].get_payload() assert '... skip 7 lines ...' in text assert 'Third paragraph line one.' in text assert 'Third paragraph line two.' in text assert 'First paragraph' not in text assert 'Second paragraph' not in text assert 'My reply here.' in text def test_reduce_quote_context_short_quote_untouched( self, make_msg: MsgFactory ) -> None: """Quotes with 5 or fewer skippable lines are left alone.""" body = '> Line one.\n> Line two.\n>\n> Last para.\nReply.\n' msg = make_msg(body=body) result = minimize_thread([msg], reduce_quote_context=True) text = result[0].get_payload() assert 'skip' not in text assert 'Line one.' in text assert 'Last para.' in text def test_reduce_quote_context_off_by_default(self, make_msg: MsgFactory) -> None: """Long quotes are untouched when reduce_quote_context is False.""" lines = ''.join(f'> Line {i}.\n' for i in range(20)) body = f'{lines}Reply.\n' msg = make_msg(body=body) result = minimize_thread([msg]) text = result[0].get_payload() assert 'skip' not in text assert 'Line 0.' in text assert 'Line 19.' in text def test_reduce_quote_context_preserves_sig(self, make_msg: MsgFactory) -> None: """Signature is preserved when reducing quote context.""" lines = ''.join(f'> Line {i}.\n' for i in range(10)) body = f'On Monday, someone wrote:\n{lines}>\n> Last para.\nReply.\n-- \nKR\n' msg = make_msg(body=body) result = minimize_thread([msg], reduce_quote_context=True) text = result[0].get_payload() assert 'skip' in text assert 'Last para.' in text assert 'Reply.' in text assert '-- \n' in text assert 'KR' in text def test_multiple_messages(self, make_msg: MsgFactory) -> None: """Multiple messages in a thread are all processed.""" msg1 = make_msg(body='First message.\n') msg2 = make_msg(body='Reply.\n> First message.\n') msg3 = make_msg(body='> Only quotes.\n') result = minimize_thread([msg1, msg2, msg3]) # msg3 should be dropped (all quotes) assert len(result) == 2 python-liblore-0.10.1/tests/test_mbox.py000066400000000000000000000337571526032434100202770ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Tests for liblore.mbox.""" from __future__ import annotations import textwrap from email.message import EmailMessage from liblore import emlpolicy from liblore.utils import ( get_clean_msgid, get_preferred_duplicate, split_and_dedupe, split_and_dedupe_as_bytes, split_mbox, split_mbox_as_bytes, ) class TestSplitMbox: def test_splits_two_messages(self, sample_mbox: bytes) -> None: msgs = split_mbox(sample_mbox) assert len(msgs) == 2 subjects = [m['Subject'] for m in msgs] assert 'First message' in subjects assert 'Second message' in subjects def test_empty_mbox(self) -> None: msgs = split_mbox(b'') assert msgs == [] def test_mboxrd_single_level_unescaping(self) -> None: """A line starting with >From in the body should be unescaped.""" raw = textwrap.dedent("""\ From sender@example.com Mon Jan 1 00:00:00 2024 From: sender@example.com Subject: Test mboxrd Message-Id: Here is a quoted line: >From someone And normal text. """).encode() msgs = split_mbox(raw) assert len(msgs) == 1 body = msgs[0].get_payload() assert 'From someone' in body assert '>From someone' not in body def test_mboxrd_double_level_unescaping(self) -> None: """>>From should become >From (only one level stripped).""" raw = textwrap.dedent("""\ From sender@example.com Mon Jan 1 00:00:00 2024 From: sender@example.com Subject: Double escaped Message-Id: Doubly escaped: >>From someone End. """).encode() msgs = split_mbox(raw) assert len(msgs) == 1 body = msgs[0].get_payload() assert '>From someone' in body assert '>>From someone' not in body def test_mboxrd_triple_level_unescaping(self) -> None: """>>>From should become >>From.""" raw = textwrap.dedent("""\ From sender@example.com Mon Jan 1 00:00:00 2024 From: sender@example.com Subject: Triple Message-Id: >>>From someone """).encode() msgs = split_mbox(raw) assert len(msgs) == 1 body = msgs[0].get_payload() assert '>>From someone' in body def test_from_in_headers_not_unescaped(self) -> None: """The >From stripping must not touch header lines.""" raw = textwrap.dedent("""\ From sender@example.com Mon Jan 1 00:00:00 2024 From: sender@example.com Subject: >From in subject Message-Id: Body text. """).encode() msgs = split_mbox(raw) assert len(msgs) == 1 assert '>From in subject' in msgs[0]['Subject'] def test_real_from_line_not_stripped(self) -> None: """A genuine 'From ' at the start of body should NOT be unescaped (it doesn't match >+From).""" raw = textwrap.dedent("""\ From sender@example.com Mon Jan 1 00:00:00 2024 From: sender@example.com Subject: Test Message-Id: From the beginning of a sentence. """).encode() msgs = split_mbox(raw) assert len(msgs) == 1 body = msgs[0].get_payload() assert 'From the beginning of a sentence.' in body def test_multiple_messages_with_escaping(self) -> None: raw = textwrap.dedent("""\ From a@example.com Mon Jan 1 00:00:00 2024 From: a@example.com Subject: First Message-Id: >From escaped in first. From b@example.com Mon Jan 1 00:01:00 2024 From: b@example.com Subject: Second Message-Id: >>From double-escaped in second. """).encode() msgs = split_mbox(raw) assert len(msgs) == 2 body1 = msgs[0].get_payload() body2 = msgs[1].get_payload() assert 'From escaped in first.' in body1 assert '>From escaped in first.' not in body1 assert '>From double-escaped in second.' in body2 assert '>>From double-escaped in second.' not in body2 class TestSplitMboxAsBytes: def test_splits_two_messages(self, sample_mbox: bytes) -> None: chunks = split_mbox_as_bytes(sample_mbox) assert len(chunks) == 2 assert all(isinstance(c, bytes) for c in chunks) def test_empty_mbox(self) -> None: assert split_mbox_as_bytes(b'') == [] def test_from_line_stripped(self, sample_mbox: bytes) -> None: """The 'From ' separator must not appear in the returned bytes.""" for chunk in split_mbox_as_bytes(sample_mbox): for line in chunk.split(b'\n'): if line: assert not line.startswith(b'From ') def test_headers_preserved(self, sample_mbox: bytes) -> None: chunks = split_mbox_as_bytes(sample_mbox) assert b'Subject: First message' in chunks[0] assert b'Subject: Second message' in chunks[1] def test_mboxrd_unescaping(self) -> None: raw = textwrap.dedent("""\ From sender@example.com Mon Jan 1 00:00:00 2024 From: sender@example.com Subject: Test Message-Id: >From someone >>From deeper """).encode() chunks = split_mbox_as_bytes(raw) assert len(chunks) == 1 assert b'From someone\n' in chunks[0] assert b'>From deeper\n' in chunks[0] # No double-escaped lines should remain assert b'>>From' not in chunks[0] def test_roundtrip_with_split_mbox(self, sample_mbox: bytes) -> None: """split_mbox_as_bytes + parse_message should match split_mbox.""" from liblore.utils import parse_message raw_chunks = split_mbox_as_bytes(sample_mbox) parsed = split_mbox(sample_mbox) assert len(raw_chunks) == len(parsed) for raw, msg in zip(raw_chunks, parsed): reparsed = parse_message(raw) assert reparsed['Message-Id'] == msg['Message-Id'] assert reparsed['Subject'] == msg['Subject'] class TestSplitAndDedupeAsBytes: def test_deduplicates(self, sample_mbox: bytes) -> None: doubled = sample_mbox + sample_mbox chunks = split_and_dedupe_as_bytes(doubled) assert len(chunks) == 2 assert all(isinstance(c, bytes) for c in chunks) def test_preserves_order(self, sample_mbox: bytes) -> None: chunks = split_and_dedupe_as_bytes(sample_mbox) assert b'Message-Id: ' in chunks[0] assert b'Message-Id: ' in chunks[1] def test_drops_no_msgid(self) -> None: raw = ( b'From test@example.com Mon Jan 1 00:00:00 2024\n' b'From: test@example.com\n' b'Subject: No msgid\n' b'\n' b'Body.\n' b'\n' ) assert split_and_dedupe_as_bytes(raw) == [] def test_prefers_better_source(self) -> None: mailman = textwrap.dedent("""\ From test@example.com Mon Jan 1 00:00:00 2024 From: test@example.com Subject: Test Message-Id: List-Id: Mailman copy with footer. """).encode() feeds = textwrap.dedent("""\ From test@example.com Mon Jan 1 00:00:00 2024 From: test@example.com Subject: Test Message-Id: List-Id: Clean feeds copy. """).encode() chunks = split_and_dedupe_as_bytes(mailman + feeds) assert len(chunks) == 1 assert b'Clean feeds copy' in chunks[0] chunks = split_and_dedupe_as_bytes(feeds + mailman) assert len(chunks) == 1 assert b'Clean feeds copy' in chunks[0] def test_empty_preference_keeps_first(self) -> None: mailman = textwrap.dedent("""\ From test@example.com Mon Jan 1 00:00:00 2024 From: test@example.com Subject: Test Message-Id: List-Id: First copy. """).encode() feeds = textwrap.dedent("""\ From test@example.com Mon Jan 1 00:00:00 2024 From: test@example.com Subject: Test Message-Id: List-Id: Second copy. """).encode() chunks = split_and_dedupe_as_bytes(mailman + feeds, listid_preference=[]) assert len(chunks) == 1 assert b'First copy' in chunks[0] def test_roundtrip_with_split_and_dedupe(self, sample_mbox: bytes) -> None: """Parsing the bytes output should match split_and_dedupe.""" from liblore.utils import parse_message doubled = sample_mbox + sample_mbox chunks = split_and_dedupe_as_bytes(doubled) msgs = split_and_dedupe(doubled) assert len(chunks) == len(msgs) for raw, msg in zip(chunks, msgs): reparsed = parse_message(raw) assert reparsed['Message-Id'] == msg['Message-Id'] class TestSplitAndDedupe: def test_deduplicates(self, sample_mbox: bytes) -> None: doubled = sample_mbox + sample_mbox msgs = split_and_dedupe(doubled) assert len(msgs) == 2 def test_preserves_order(self, sample_mbox: bytes) -> None: msgs = split_and_dedupe(sample_mbox) assert get_clean_msgid(msgs[0]) == 'first@example.com' assert get_clean_msgid(msgs[1]) == 'second@example.com' def test_drops_no_msgid(self) -> None: raw = ( b'From test@example.com Mon Jan 1 00:00:00 2024\n' b'From: test@example.com\n' b'Subject: No msgid\n' b'\n' b'Body.\n' b'\n' ) msgs = split_and_dedupe(raw) assert msgs == [] def test_prefers_better_source(self) -> None: """When duplicates exist, the copy from the preferred source wins.""" # Build two mbox messages with the same msgid but different List-Id mailman = textwrap.dedent("""\ From test@example.com Mon Jan 1 00:00:00 2024 From: test@example.com Subject: Test Message-Id: List-Id: Mailman copy with footer. """).encode() feeds = textwrap.dedent("""\ From test@example.com Mon Jan 1 00:00:00 2024 From: test@example.com Subject: Test Message-Id: List-Id: Clean feeds copy. """).encode() # feeds.kernel.org copy should win regardless of order msgs = split_and_dedupe(mailman + feeds) assert len(msgs) == 1 assert 'Clean feeds copy' in msgs[0].get_payload() msgs = split_and_dedupe(feeds + mailman) assert len(msgs) == 1 assert 'Clean feeds copy' in msgs[0].get_payload() def test_empty_preference_keeps_first(self) -> None: """An empty preference list disables source selection.""" mailman = textwrap.dedent("""\ From test@example.com Mon Jan 1 00:00:00 2024 From: test@example.com Subject: Test Message-Id: List-Id: First copy. """).encode() feeds = textwrap.dedent("""\ From test@example.com Mon Jan 1 00:00:00 2024 From: test@example.com Subject: Test Message-Id: List-Id: Second copy. """).encode() msgs = split_and_dedupe(mailman + feeds, listid_preference=[]) assert len(msgs) == 1 assert 'First copy' in msgs[0].get_payload() class TestGetPreferredDuplicate: def _make_msg(self, listid: str | None = None) -> EmailMessage: msg = EmailMessage(policy=emlpolicy) msg['Message-Id'] = '' msg['From'] = 'test@example.com' if listid: msg['List-Id'] = f'<{listid}>' return msg def test_prefers_feeds_over_vger(self) -> None: feeds = self._make_msg('list.feeds.kernel.org') vger = self._make_msg('list.vger.kernel.org') assert get_preferred_duplicate(feeds, vger) is feeds assert get_preferred_duplicate(vger, feeds) is feeds def test_prefers_linux_dev_over_kernel_org(self) -> None: ldev = self._make_msg('list.linux.dev') korg = self._make_msg('list.kernel.org') assert get_preferred_duplicate(ldev, korg) is ldev assert get_preferred_duplicate(korg, ldev) is ldev def test_prefers_kernel_org_over_unknown(self) -> None: korg = self._make_msg('list.kernel.org') unknown = self._make_msg('list.example.com') assert get_preferred_duplicate(korg, unknown) is korg assert get_preferred_duplicate(unknown, korg) is korg def test_no_listid_treated_as_wildcard(self) -> None: korg = self._make_msg('list.kernel.org') bare = self._make_msg() assert get_preferred_duplicate(korg, bare) is korg def test_both_no_listid_keeps_first(self) -> None: msg1 = self._make_msg() msg2 = self._make_msg() assert get_preferred_duplicate(msg1, msg2) is msg1 def test_custom_preference(self) -> None: custom = self._make_msg('list.custom.org') feeds = self._make_msg('list.feeds.kernel.org') prefs = ['*.custom.org', '*.feeds.kernel.org', '*'] assert get_preferred_duplicate(custom, feeds, prefs) is custom assert get_preferred_duplicate(feeds, custom, prefs) is custom python-liblore-0.10.1/tests/test_message.py000066400000000000000000000176701526032434100207520ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Tests for liblore.message.""" from __future__ import annotations from email.message import EmailMessage import pytest from conftest import MsgFactory from liblore.utils import _clean_msgid_raw, clean_header, get_clean_msgid, parse_message class TestCleanHeader: def test_plain_header(self) -> None: assert clean_header('Hello World') == 'Hello World' def test_none_returns_empty(self) -> None: assert clean_header(None) == '' def test_rfc2047_encoded(self) -> None: encoded = '=?utf-8?q?Caf=C3=A9?=' assert clean_header(encoded) == 'Caf\u00e9' def test_folded_whitespace(self) -> None: assert clean_header('Hello\n World') == 'Hello World' def test_encoded_address(self) -> None: # formataddr re-encodes Unicode names, so we just check # the address is preserved and the result is valid hdr = '=?utf-8?q?=C3=89ric?= ' result = clean_header(hdr) assert 'eric@example.com' in result def test_special_chars_in_name(self) -> None: hdr = '=?utf-8?q?O=27Brien=2C_Joe?= ' result = clean_header(hdr) assert 'joe@example.com' in result class TestGetCleanMsgid: def test_extracts_msgid(self, make_msg: MsgFactory) -> None: msg = make_msg(msgid='test123@example.com') assert get_clean_msgid(msg) == 'test123@example.com' def test_missing_header(self) -> None: msg = EmailMessage() assert get_clean_msgid(msg) is None def test_custom_header(self, make_msg: MsgFactory) -> None: msg = make_msg(in_reply_to='parent@example.com') assert get_clean_msgid(msg, 'In-Reply-To') == 'parent@example.com' def test_in_reply_to_with_encoded_comment(self) -> None: """Gnus appends an RFC 2047 encoded comment after the message-ID. The encoded-word comment must not send us down clean_header's address-parsing path, which would destroy the message-ID. """ msg = EmailMessage() msg['In-Reply-To'] = ( '' ' (=?utf-8?Q?=22Marc-Andr=C3=A9?= Lureau"\'s' ' message of "Mon, 20 Jul 2026 11:55:30 +0400")' ) assert get_clean_msgid(msg, 'In-Reply-To') == 'patch-v1-1-abc123@example.com' def test_in_reply_to_with_encoded_comment_parsed(self) -> None: """Same case, but via a message parsed under the modern policy.""" raw = ( b'From: x@example.com\r\n' b'Message-Id: \r\n' b'In-Reply-To: ' b' (=?utf-8?Q?=22Marc-Andr=C3=A9?= Lureau"\'s' b' message of "Mon, 20 Jul 2026 11:55:30 +0400")\r\n' b'\r\nbody\r\n' ) msg = parse_message(raw) assert get_clean_msgid(msg, 'In-Reply-To') == 'patch-v1-1-abc123@example.com' # --- Non-compliant / malformed headers --------------------------------- # These document how extraction copes with headers that violate # RFC 5322. The contract is deliberately simple: we return the # contents of the first ``<...>`` pair, or None when there isn't one. def test_missing_angle_brackets(self) -> None: """A bare address without angle brackets yields no message-ID.""" msg = EmailMessage() msg['Message-Id'] = 'bare@example.com' assert get_clean_msgid(msg) is None def test_empty_angle_brackets(self) -> None: """An empty ``<>`` has nothing to extract.""" msg = EmailMessage() msg['In-Reply-To'] = '<>' assert get_clean_msgid(msg, 'In-Reply-To') is None def test_no_at_sign_inside_brackets(self) -> None: """We trust the angle brackets and do not require an ``@``.""" msg = EmailMessage() msg['Message-Id'] = '' assert get_clean_msgid(msg) == 'garbage-no-at' def test_leading_comment_before_id(self) -> None: """A comment preceding the message-ID must not confuse extraction.""" msg = EmailMessage() msg['In-Reply-To'] = '(sent earlier) ' assert get_clean_msgid(msg, 'In-Reply-To') == 'parent@example.com' def test_multiple_ids_returns_first(self) -> None: """Some broken clients cram several IDs into In-Reply-To.""" msg = EmailMessage() msg['In-Reply-To'] = ' ' assert get_clean_msgid(msg, 'In-Reply-To') == 'first@example.com' def test_rfc2047_encoded_msgid(self) -> None: """A nonconformant fully RFC 2047-encoded ````. RFC 2047 forbids encoded words in msg-id headers, but if one shows up the modern EmailPolicy decodes it on read, so we still recover the ID -- no clean_header fallback required. """ msg = EmailMessage() msg['Message-Id'] = '=?utf-8?q?=3Cfoo=40bar=3E?=' assert get_clean_msgid(msg) == 'foo@bar' def test_rfc2047_encoded_msgid_parsed(self) -> None: """Same nonconformant case, parsed under the real policy.""" raw = ( b'From: x@example.com\r\n' b'Message-Id: =?utf-8?q?=3Cfoo=40bar=3E?=\r\n' b'\r\nbody\r\n' ) msg = parse_message(raw) assert get_clean_msgid(msg) == 'foo@bar' # --- Characters the header parser chokes on ------------------------- # The policy's Message-ID parser reads "(" as a comment and stops at a # stray quote, so these must come from the raw source value instead. @pytest.mark.parametrize( 'msgid', [ 'x$(id)@x', 'a(b)c@x', 'a"b@x', '"quoted local"@x', 'a/b?c@x', ], ) def test_parser_unfriendly_characters(self, msgid: str) -> None: raw = f'From: x@example.com\r\nMessage-ID: <{msgid}>\r\n\r\nbody\r\n' msg = parse_message(raw.encode()) assert get_clean_msgid(msg) == msgid def test_message_id_matches_in_reply_to(self) -> None: """A reply must point at the exact ID its parent reports.""" raw = ( b'From: x@example.com\r\n' b'Message-ID: \r\n' b'In-Reply-To: \r\n' b'\r\nbody\r\n' ) msg = parse_message(raw) assert get_clean_msgid(msg) == get_clean_msgid(msg, 'In-Reply-To') assert get_clean_msgid(msg) == _clean_msgid_raw(raw) def test_header_name_is_case_insensitive(self) -> None: raw = b'From: x@example.com\r\nmessage-id: \r\n\r\nbody\r\n' msg = parse_message(raw) assert get_clean_msgid(msg, 'Message-Id') == 'a(b)c@x' def test_first_header_wins(self) -> None: raw = ( b'From: x@example.com\r\n' b'Message-ID: \r\n' b'Message-ID: \r\n' b'\r\nbody\r\n' ) msg = parse_message(raw) assert get_clean_msgid(msg) == 'first(1)@x' def test_folded_header(self) -> None: raw = b'From: x@example.com\r\nMessage-ID:\r\n \r\n\r\nbody\r\n' msg = parse_message(raw) assert get_clean_msgid(msg) == 'a(b)c@x' def test_list_id(self) -> None: """List-Id is read the same way when picking among duplicates.""" raw = ( b'From: x@example.com\r\n' b'List-Id: Kernel list \r\n' b'\r\nbody\r\n' ) msg = parse_message(raw) assert get_clean_msgid(msg, 'List-Id') == 'linux-kernel.vger.kernel.org' class TestParseMessage: def test_roundtrip(self) -> None: raw = ( b'From: test@example.com\r\n' b'Subject: Test\r\n' b'Message-Id: \r\n' b'\r\n' b'Body text\r\n' ) msg = parse_message(raw) assert isinstance(msg, EmailMessage) assert msg['Subject'] == 'Test' assert msg['From'] == 'test@example.com' python-liblore-0.10.1/tests/test_node.py000066400000000000000000003067151526032434100202540ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Tests for liblore.node (LoreNode).""" from __future__ import annotations import gzip import os import pickle import subprocess from datetime import datetime, timezone from email.message import EmailMessage from unittest.mock import MagicMock, call, patch import pytest import requests import responses from typing_extensions import override from liblore import OperationCancelledError, RemoteError from liblore.node import LoreNode def request_url(rsps: responses.RequestsMock, index: int) -> str: url = rsps.calls[index].request.url assert url is not None return url # ===================================================================== # RemoteError # ===================================================================== class TestRemoteError: def test_status_code_defaults_to_none(self) -> None: # Code written before status_code existed keeps working. err = RemoteError('boom') assert str(err) == 'boom' assert err.status_code is None def test_status_code(self) -> None: err = RemoteError('not found', status_code=404) assert str(err) == 'not found' assert err.status_code == 404 def test_pickle_keeps_status_code(self) -> None: # Errors cross process boundaries (concurrent.futures). err = pickle.loads(pickle.dumps(RemoteError('gone', status_code=410))) assert str(err) == 'gone' assert err.status_code == 410 # ===================================================================== # Session management # ===================================================================== class TestSessionManagement: def test_creates_session(self) -> None: node = LoreNode() s = node._get_session() assert s is not None user_agent = s.headers['User-Agent'] assert isinstance(user_agent, str) assert 'liblore/' in user_agent node.close() def test_returns_same_session(self) -> None: node = LoreNode() s1 = node._get_session() s2 = node._get_session() assert s1 is s2 node.close() def test_close_and_reopen(self) -> None: node = LoreNode() s1 = node._get_session() node.close() s2 = node._get_session() assert s1 is not s2 node.close() def test_set_user_agent(self) -> None: node = LoreNode() node.set_user_agent('myapp', '1.0') s = node._get_session() assert s.headers['User-Agent'] == 'myapp/1.0' node.close() def test_set_user_agent_updates_existing(self) -> None: node = LoreNode() s = node._get_session() node.set_user_agent('otherapp', '2.0') assert s.headers['User-Agent'] == 'otherapp/2.0' node.close() def test_set_user_agent_plus(self) -> None: node = LoreNode() node.set_user_agent('korgalore', '0.6', plus='abcd1234') s = node._get_session() assert s.headers['User-Agent'] == 'korgalore/0.6+abcd1234' node.close() def test_no_plus(self) -> None: node = LoreNode() node.set_user_agent('korgalore', '0.6') s = node._get_session() assert s.headers['User-Agent'] == 'korgalore/0.6' node.close() def test_default_no_plus(self) -> None: node = LoreNode() s = node._get_session() user_agent = s.headers['User-Agent'] assert isinstance(user_agent, str) assert '+' not in user_agent node.close() def test_set_requests_session(self) -> None: external = requests.Session() external.headers.update({'User-Agent': 'custom/1.0'}) node = LoreNode() node.set_requests_session(external) s = node._get_session() assert s is external assert s.headers['User-Agent'] == 'custom/1.0' node.close() def test_set_requests_session_ua_not_overwritten(self) -> None: external = requests.Session() external.headers.update({'User-Agent': 'myapp/2.0'}) node = LoreNode() node.set_user_agent('liblore', '0.1') node.set_requests_session(external) assert node._get_session().headers['User-Agent'] == 'myapp/2.0' node.close() def test_close_only_owned_session(self) -> None: external = requests.Session() node = LoreNode() node.set_requests_session(external) node.close() # External session should NOT be closed — still usable external.headers.update({'X-Test': 'alive'}) external.close() def test_context_manager(self) -> None: with LoreNode() as node: s = node._get_session() assert s is not None # After exiting, session should be cleaned up assert node._session is None # ===================================================================== # get_mbox_by_msgid / get_mbox_by_query # ===================================================================== class TestGetMboxByMsgid: def test_returns_raw_bytes(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/first%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) result = node.get_mbox_by_msgid('first@example.com') assert result == sample_mbox def test_http_error(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40x.com/t.mbox.gz', status=500, ) with pytest.raises(RemoteError, match='Server returned an error') as ei: node.get_mbox_by_msgid('test@x.com') assert ei.value.status_code == 500 def test_404_falls_back_to_head_redirect(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: """On 404, try HEAD against the bare origin to discover the list path.""" node = LoreNode('https://lore.kernel.org/all') # First GET returns 404 rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', status=404, ) # HEAD follows redirect and succeeds rsps.add( responses.HEAD, 'https://lore.kernel.org/test%40example.com/', status=302, headers={ 'Location': 'https://lore.kernel.org/tools/test%40example.com/' }, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/tools/test%40example.com/', status=200, ) # Second GET (to resolved URL) succeeds rsps.add( responses.GET, 'https://lore.kernel.org/tools/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) result = node.get_mbox_by_msgid('test@example.com') assert result == sample_mbox # Verify the HEAD was sent to the bare origin assert request_url(rsps, 1) == 'https://lore.kernel.org/test%40example.com/' def test_404_no_redirect_raises(self) -> None: with responses.RequestsMock() as rsps: """When HEAD also 404s (no redirect), raise RemoteError.""" node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/nonexistent%40example.com/t.mbox.gz', status=404, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/nonexistent%40example.com/', status=404, ) with pytest.raises( RemoteError, match='Server returned an error: 404' ) as ei: node.get_mbox_by_msgid('nonexistent@example.com') assert ei.value.status_code == 404 def test_404_head_connection_error_is_remote_error(self) -> None: """A HEAD that can't connect raises RemoteError, not requests' error.""" with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', status=404, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/test%40example.com/', body=requests.ConnectionError('refused'), ) with pytest.raises(RemoteError, match='All hosts failed') as ei: node.get_mbox_by_msgid('test@example.com') assert ei.value.status_code is None def test_404_head_without_redirect_fetches_bare_mbox( self, sample_mbox: bytes ) -> None: """A HEAD 200 with no redirect fetches the mbox from the bare path. The HEAD response has no body, so it must not be taken for the mbox itself. """ with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', status=404, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/test%40example.com/', status=200, ) rsps.add( responses.GET, 'https://lore.kernel.org/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) assert node.get_mbox_by_msgid('test@example.com') == sample_mbox class TestGetMboxByQuery: def test_returns_raw_bytes(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&q=test+query', body=gzip.compress(sample_mbox), status=200, ) result = node.get_mbox_by_query('test query') assert result == sample_mbox def test_full_threads_adds_t_param(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&t=1&q=test+query', body=gzip.compress(sample_mbox), status=200, ) node.get_mbox_by_query('test query', full_threads=True) url = request_url(rsps, 0) assert '&t=1&' in url def test_no_full_threads_omits_t_param(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&q=test+query', body=gzip.compress(sample_mbox), status=200, ) node.get_mbox_by_query('test query') url = request_url(rsps, 0) assert 't=1' not in url def test_http_error(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&q=test', status=500, ) with pytest.raises(RemoteError, match='Server returned an error') as ei: node.get_mbox_by_query('test') assert ei.value.status_code == 500 def test_no_results_is_404(self) -> None: with responses.RequestsMock() as rsps: """lore.kernel.org answers a search with no hits with a 404.""" node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&q=nomatch', body=b'No results found\n', status=404, ) with pytest.raises(RemoteError, match='Server returned an error') as ei: node.get_mbox_by_query('nomatch') assert ei.value.status_code == 404 # ===================================================================== # get_thread_by_msgid # ===================================================================== class TestGetThreadByMsgid: def test_full_thread(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/first%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) msgs = node.get_thread_by_msgid('first@example.com') assert len(msgs) >= 1 # Without since, fetches full thread via GET /{msgid}/t.mbox.gz assert len(rsps.calls) == 1 def test_query_contains_msgid(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/first%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) node.get_thread_by_msgid('first@example.com') call_url = request_url(rsps, 0) assert 'first%40example.com' in call_url or 'first@example.com' in call_url assert call_url.endswith('/t.mbox.gz') def test_since_uses_dt_prefix(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/first%40example.com/?x=m&q=dt%3A20240101..', body=gzip.compress(sample_mbox), status=200, ) node.get_thread_by_msgid('first@example.com', since='20240101') call_url = request_url(rsps, 0) assert 'dt%3A20240101' in call_url or 'dt:20240101' in call_url assert 'first%40example.com' in call_url or 'first@example.com' in call_url def test_raises_on_empty(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/nonexistent%40x.com/t.mbox.gz', body=gzip.compress(b''), status=200, ) with pytest.raises(LookupError): node.get_thread_by_msgid('nonexistent@x.com') def test_sort_parameter(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/first%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) msgs = node.get_thread_by_msgid('first@example.com', sort=True) assert len(msgs) >= 1 # ===================================================================== # get_thread_updates_since # ===================================================================== class TestGetThreadUpdatesSince: def test_returns_messages(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/first%40example.com/?x=m&q=rt%3A1705320000..', body=gzip.compress(sample_mbox), status=200, ) since = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) msgs = node.get_thread_updates_since('first@example.com', since) assert len(msgs) >= 1 assert len(rsps.calls) == 1 def test_empty_returns_empty_list(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/first%40example.com/?x=m&q=rt%3A1705320000..', body=gzip.compress(b''), status=200, ) since = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) msgs = node.get_thread_updates_since('first@example.com', since) assert msgs == [] def test_converts_datetime_to_rt_epoch(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') since = datetime(2024, 3, 15, 8, 30, 45, tzinfo=timezone.utc) epoch = int(since.timestamp()) # 1710491445 rsps.add( responses.POST, f'https://lore.kernel.org/all/first%40example.com/?x=m&q=rt%3A{epoch}..', body=gzip.compress(sample_mbox), status=200, ) node.get_thread_updates_since('first@example.com', since) call_url = request_url(rsps, 0) assert f'rt%3A{epoch}' in call_url or f'rt:{epoch}' in call_url assert 'first%40example.com' in call_url or 'first@example.com' in call_url def test_with_sort(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/first%40example.com/?x=m&q=rt%3A1705320000..', body=gzip.compress(sample_mbox), status=200, ) since = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) msgs = node.get_thread_updates_since( 'first@example.com', since, sort=True, ) assert len(msgs) >= 1 def test_server_error_returns_empty_list(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/first%40example.com/?x=m&q=rt%3A1705320000..', status=404, ) since = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) msgs = node.get_thread_updates_since('first@example.com', since) assert msgs == [] # ===================================================================== # get_thread_by_query # ===================================================================== class TestGetThreadByQuery: def test_posts_query(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&q=test+query', body=gzip.compress(sample_mbox), status=200, ) msgs = node.get_thread_by_query('test query') assert len(msgs) == 2 assert len(rsps.calls) == 1 def test_query_with_date_filter(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&q=test+d%3A20240101..', body=gzip.compress(sample_mbox), status=200, ) node.get_thread_by_query('test d:20240101..') call_url = request_url(rsps, 0) assert 'd%3A20240101' in call_url or 'd:20240101' in call_url # ===================================================================== # get_message_by_msgid # ===================================================================== class TestGetMessageByMsgid: def test_fetches_raw(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40x.com/raw', body=b'raw email bytes', status=200, ) result = node.get_message_by_msgid('test@x.com') assert result == b'raw email bytes' def test_raises_remote_error(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40x.com/raw', body=requests.ConnectionError('connection refused'), ) with pytest.raises(RemoteError) as ei: node.get_message_by_msgid('test@x.com') assert ei.value.status_code is None def test_not_found_has_status(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40x.com/raw', status=404, ) with pytest.raises(RemoteError, match='Failed to fetch message') as ei: node.get_message_by_msgid('test@x.com') assert ei.value.status_code == 404 # ===================================================================== # batch_get_thread_by_msgid # ===================================================================== class TestBatchGetThreadByMsgid: class _Node(LoreNode): def __init__(self, results: list[list[EmailMessage]]) -> None: super().__init__() self.calls: list[tuple[str, bool, bool, str | None]] = [] self._results = results @override def get_thread_by_msgid( self, msgid: str, *, strict: bool = True, sort: bool = False, since: str | None = None, ) -> list[EmailMessage]: self.calls.append((msgid, strict, sort, since)) return self._results.pop(0) def test_returns_ordered_results(self) -> None: thread_a = [EmailMessage()] thread_b = [EmailMessage(), EmailMessage()] node = self._Node([thread_a, thread_b]) with patch('liblore.node.time.sleep') as mock_sleep: results = node.batch_get_thread_by_msgid(['a@x', 'b@x']) assert results == [thread_a, thread_b] assert len(node.calls) == 2 mock_sleep.assert_called_once_with(0.1) def test_no_sleep_for_single_msgid(self) -> None: thread = [EmailMessage()] node = self._Node([thread]) with patch('liblore.node.time.sleep') as mock_sleep: results = node.batch_get_thread_by_msgid(['only@x']) assert results == [thread] mock_sleep.assert_not_called() def test_passes_kwargs(self) -> None: node = self._Node([[EmailMessage()]]) with patch('liblore.node.time.sleep'): node.batch_get_thread_by_msgid( ['a@x'], strict=False, sort=True, since='20240101', ) assert node.calls == [('a@x', False, True, '20240101')] def test_sleep_count_matches_gaps(self) -> None: node = self._Node([[EmailMessage()], [EmailMessage()], [EmailMessage()]]) with patch('liblore.node.time.sleep') as mock_sleep: node.batch_get_thread_by_msgid(['a@x', 'b@x', 'c@x']) assert mock_sleep.call_args_list == [call(0.1), call(0.1)] def test_empty_list(self) -> None: node = self._Node([]) with patch('liblore.node.time.sleep') as mock_sleep: results = node.batch_get_thread_by_msgid([]) assert results == [] mock_sleep.assert_not_called() assert node.calls == [] # ===================================================================== # batch_get_thread_by_query # ===================================================================== class TestBatchGetThreadByQuery: class _Node(LoreNode): def __init__(self, results: list[list[EmailMessage]]) -> None: super().__init__() self.calls: list[tuple[str, bool, bool]] = [] self._results = results @override def get_thread_by_query( self, query: str, *, full_threads: bool = False, local_sufficient: bool = False, ) -> list[EmailMessage]: self.calls.append((query, full_threads, local_sufficient)) return self._results.pop(0) def test_returns_ordered_results(self) -> None: result_a = [EmailMessage()] result_b = [EmailMessage(), EmailMessage()] node = self._Node([result_a, result_b]) with patch('liblore.node.time.sleep') as mock_sleep: results = node.batch_get_thread_by_query(['q1', 'q2']) assert results == [result_a, result_b] assert len(node.calls) == 2 mock_sleep.assert_called_once_with(0.1) def test_no_sleep_for_single_query(self) -> None: result = [EmailMessage()] node = self._Node([result]) with patch('liblore.node.time.sleep') as mock_sleep: results = node.batch_get_thread_by_query(['only_query']) assert results == [result] mock_sleep.assert_not_called() def test_passes_arguments(self) -> None: node = self._Node([[EmailMessage()], [EmailMessage()]]) with patch('liblore.node.time.sleep'): node.batch_get_thread_by_query( ['q1', 'q2'], full_threads=True, local_sufficient=True ) assert node.calls == [('q1', True, True), ('q2', True, True)] def test_sleep_count_matches_gaps(self) -> None: node = self._Node( [[EmailMessage()], [EmailMessage()], [EmailMessage()], [EmailMessage()]] ) with patch('liblore.node.time.sleep') as mock_sleep: node.batch_get_thread_by_query(['q1', 'q2', 'q3', 'q4']) assert mock_sleep.call_args_list == [call(0.1), call(0.1), call(0.1)] def test_empty_list(self) -> None: node = self._Node([]) with patch('liblore.node.time.sleep') as mock_sleep: results = node.batch_get_thread_by_query([]) assert results == [] mock_sleep.assert_not_called() assert node.calls == [] # ===================================================================== # validate # ===================================================================== class TestValidate: def test_valid_url(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://lore.kernel.org/lkml') rsps.add( responses.HEAD, 'https://lore.kernel.org/lkml/_/text/help/', status=200, ) node.validate() assert request_url(rsps, 0) == 'https://lore.kernel.org/lkml/_/text/help/' def test_not_public_inbox(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://example.com/not-pi') rsps.add( responses.HEAD, 'https://example.com/not-pi/_/text/help/', status=404, ) with pytest.raises(RemoteError, match='does not appear') as ei: node.validate() assert ei.value.status_code == 404 def test_connection_error(self) -> None: with responses.RequestsMock() as rsps: node = LoreNode('https://unreachable.example.com') rsps.add( responses.HEAD, 'https://unreachable.example.com/_/text/help/', body=requests.ConnectionError('connection refused'), ) with pytest.raises(RemoteError, match='Failed to reach') as ei: node.validate() assert ei.value.status_code is None # ===================================================================== # URL fallback # ===================================================================== class TestFallback: """Tests for the fallback_urls feature.""" def test_no_fallbacks_unchanged(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: """Without fallback_urls, behavior is identical to before.""" node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) result = node.get_mbox_by_msgid('test@example.com') assert result == sample_mbox assert len(rsps.calls) == 1 url = request_url(rsps, 0) assert url.startswith('https://lore.kernel.org/') def test_fallback_on_connection_error(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: """Primary raises ConnectionError, fallback succeeds.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.GET, 'http://mirror.local/all/test%40example.com/t.mbox.gz', body=requests.ConnectionError('refused'), ) rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) result = node.get_mbox_by_msgid('test@example.com') assert result == sample_mbox assert len(rsps.calls) == 2 # First call goes to the fallback (tried first) first_url = request_url(rsps, 0) assert first_url.startswith('http://mirror.local/all/') # Second call goes to the canonical URL second_url = request_url(rsps, 1) assert second_url.startswith('https://lore.kernel.org/all/') def test_fallback_on_timeout(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: """Primary raises Timeout, fallback succeeds.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://ams.lore.kernel.org'], ) rsps.add( responses.GET, 'https://ams.lore.kernel.org/all/test%40example.com/t.mbox.gz', body=requests.Timeout('timed out'), ) rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) result = node.get_mbox_by_msgid('test@example.com') assert result == sample_mbox assert len(rsps.calls) == 2 def test_fallback_on_5xx(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: """Primary returns 500, fallback returns 200.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.GET, 'http://mirror.local/all/test%40example.com/t.mbox.gz', status=500, ) rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) result = node.get_mbox_by_msgid('test@example.com') assert result == sample_mbox assert len(rsps.calls) == 2 def test_no_fallback_on_4xx(self) -> None: with responses.RequestsMock() as rsps: """4xx is not retriable — fallback should NOT be tried.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.GET, 'http://mirror.local/all/test%40example.com/t.mbox.gz', status=404, ) rsps.add( responses.HEAD, 'http://mirror.local/test%40example.com/', status=404, ) with pytest.raises(RemoteError, match='Server returned an error'): node.get_mbox_by_msgid('test@example.com') # Fallback returns 404, then redirect discovery also returns # 404 from the same fallback. assert len(rsps.calls) == 2 # public-inbox answers 501 ("search is not available") when an # inbox has no search index, as a mirror made with git clone may # not. A 5xx moves on to the next origin, so the search reaches one # that can answer it. def test_search_without_index_fails_over(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.POST, 'http://mirror.local/all/?x=m&q=test', status=501, ) rsps.add( responses.POST, 'https://lore.kernel.org/all/?x=m&q=test', body=gzip.compress(sample_mbox), status=200, ) assert node.get_mbox_by_query('test') == sample_mbox assert request_url(rsps, 0).startswith('http://mirror.local/') assert request_url(rsps, 1).startswith('https://lore.kernel.org/') def test_thread_search_without_index_fails_over(self, sample_mbox: bytes) -> None: # Not an empty answer: "nothing new" would hide the replies. with responses.RequestsMock() as rsps: node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) path = '/all/first%40example.com/?x=m&q=rt%3A1705320000..' rsps.add(responses.POST, 'http://mirror.local' + path, status=501) rsps.add( responses.POST, 'https://lore.kernel.org' + path, body=gzip.compress(sample_mbox), status=200, ) since = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) msgs = node.get_thread_updates_since('first@example.com', since) assert len(msgs) >= 1 assert len(rsps.calls) == 2 def test_head_redirect_fails_over(self, sample_mbox: bytes) -> None: """The redirect lookup fails over like any other request.""" with responses.RequestsMock() as rsps: node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.GET, 'http://mirror.local/all/test%40example.com/t.mbox.gz', status=404, ) rsps.add( responses.HEAD, 'http://mirror.local/test%40example.com/', body=requests.ConnectionError('refused'), ) rsps.add( responses.HEAD, 'https://lore.kernel.org/test%40example.com/', status=302, headers={ 'Location': 'https://lore.kernel.org/tools/test%40example.com/' }, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/tools/test%40example.com/', status=200, ) rsps.add( responses.GET, 'http://mirror.local/tools/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) assert node.get_mbox_by_msgid('test@example.com') == sample_mbox def test_head_redirect_on_fallback_origin(self, sample_mbox: bytes) -> None: """A redirect found on a fallback origin maps back onto every origin. The resolved URL names the fallback host, so it can't be handed to the origin rotation as it is. """ with responses.RequestsMock() as rsps: node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.GET, 'http://mirror.local/all/test%40example.com/t.mbox.gz', status=404, ) rsps.add( responses.HEAD, 'http://mirror.local/test%40example.com/', status=302, headers={'Location': 'http://mirror.local/tools/test%40example.com/'}, ) rsps.add( responses.HEAD, 'http://mirror.local/tools/test%40example.com/', status=200, ) rsps.add( responses.GET, 'http://mirror.local/tools/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) assert node.get_mbox_by_msgid('test@example.com') == sample_mbox assert len(rsps.calls) == 4 def test_all_hosts_fail_connection(self) -> None: with responses.RequestsMock() as rsps: """All origins raise ConnectionError → RemoteError.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.GET, 'http://mirror.local/all/test%40example.com/t.mbox.gz', body=requests.ConnectionError('refused'), ) rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', body=requests.ConnectionError('refused'), ) with pytest.raises(RemoteError, match='All hosts failed') as ei: node.get_mbox_by_msgid('test@example.com') assert ei.value.status_code is None assert len(rsps.calls) == 2 def test_all_hosts_fail_5xx(self) -> None: with responses.RequestsMock() as rsps: """All origins return 5xx → caller gets the error response.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.GET, 'http://mirror.local/all/test%40example.com/t.mbox.gz', status=503, ) rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', status=503, ) # get_mbox_by_msgid checks status_code and raises RemoteError with pytest.raises(RemoteError, match='Server returned an error') as ei: node.get_mbox_by_msgid('test@example.com') assert ei.value.status_code == 503 assert len(rsps.calls) == 2 def test_all_hosts_fail_no_raise(self) -> None: with responses.RequestsMock() as rsps: """_fetch_thread_since path: all fail, returns empty list.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.POST, 'http://mirror.local/all/test%40example.com/?x=m&q=dt%3A20240101..', status=503, ) rsps.add( responses.POST, 'https://lore.kernel.org/all/test%40example.com/?x=m&q=dt%3A20240101..', status=503, ) result = node._fetch_thread_since('test@example.com', 'dt:20240101..') assert result == [] assert len(rsps.calls) == 2 def test_url_rewriting_preserves_path(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: """Verify full URL rewriting with scheme change.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mymirror.local'], ) rsps.add( responses.GET, 'http://mymirror.local/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) node.get_mbox_by_msgid('test@example.com') url = request_url(rsps, 0) assert url.startswith('http://mymirror.local/all/') assert url.endswith('/t.mbox.gz') def test_url_rewriting_post(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: """Verify URL rewriting works for POST requests too.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://ams.lore.kernel.org'], ) rsps.add( responses.POST, 'https://ams.lore.kernel.org/all/?x=m&q=test+query', body=gzip.compress(sample_mbox), status=200, ) node.get_mbox_by_query('test query') url = request_url(rsps, 0) assert url.startswith('https://ams.lore.kernel.org/all/') def test_validate_does_not_use_fallback(self) -> None: with responses.RequestsMock() as rsps: """validate() hits canonical URL only, ignoring fallbacks.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.HEAD, 'https://lore.kernel.org/all/_/text/help/', body=requests.ConnectionError('connection refused'), ) with pytest.raises(RemoteError, match='Failed to reach'): node.validate() # Only 1 call to the canonical URL — no fallback assert len(rsps.calls) == 1 url = request_url(rsps, 0) assert 'lore.kernel.org' in url def test_invalid_fallback_url_no_scheme(self) -> None: """Fallback URL without scheme raises LibloreError.""" from liblore import LibloreError with pytest.raises(LibloreError, match='Invalid fallback URL'): LoreNode( 'https://lore.kernel.org/all', fallback_urls=['mirror.local'], ) def test_invalid_fallback_url_with_path(self) -> None: """Fallback URL with a path component raises LibloreError.""" from liblore import LibloreError with pytest.raises(LibloreError, match='must be a scheme://host origin'): LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local/some/path'], ) def test_hostname_returns_canonical(self) -> None: """hostname property reflects the canonical URL, not fallbacks.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) assert node.hostname == 'lore.kernel.org' def test_multiple_fallbacks_tried_in_order(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: """With 3 fallbacks, they are tried in the configured order.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=[ 'http://mirror1.local', 'https://ams.lore.kernel.org', ], ) rsps.add( responses.GET, 'http://mirror1.local/all/test%40example.com/t.mbox.gz', body=requests.ConnectionError('refused'), ) rsps.add( responses.GET, 'https://ams.lore.kernel.org/all/test%40example.com/t.mbox.gz', body=requests.ConnectionError('refused'), ) rsps.add( responses.GET, 'https://lore.kernel.org/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) result = node.get_mbox_by_msgid('test@example.com') assert result == sample_mbox assert len(rsps.calls) == 3 urls = [request_url(rsps, i) for i in range(len(rsps.calls))] assert urls[0].startswith('http://mirror1.local/') assert urls[1].startswith('https://ams.lore.kernel.org/') assert urls[2].startswith('https://lore.kernel.org/') # ===================================================================== # Origin probing # ===================================================================== class TestProbeOrigins: """Tests for the probe_origins() fastest-mirror feature.""" def test_probe_reorders_by_latency(self) -> None: """Origins are reordered fastest-first after probing.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=[ 'https://slow.example.com', 'https://fast.example.com', ], ) # Map each origin to a deterministic fake latency latencies = { 'slow.example.com': 2.0, 'fast.example.com': 0.1, 'lore.kernel.org': 0.5, } def fake_probe_one(origin: str) -> tuple[str, float]: for host, lat in latencies.items(): if host in origin: return origin, lat return origin, float('inf') with patch.object(node, '_probe_one', side_effect=fake_probe_one): results = node.probe_origins() assert len(results) == 3 origins = [o for o, _ in results] assert origins[0] == 'https://fast.example.com' assert origins[1] == 'https://lore.kernel.org' assert origins[2] == 'https://slow.example.com' # _all_origins should be reordered too assert node._all_origins == origins def test_probe_unreachable_sorted_last(self) -> None: with responses.RequestsMock() as rsps: """Unreachable origins get inf elapsed and sort to the end.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://dead.example.com'], ) rsps.add( responses.HEAD, 'https://dead.example.com/manifest.js.gz', body=requests.ConnectionError('refused'), ) rsps.add( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', status=200, ) results = node.probe_origins() assert len(results) == 2 # canonical should be first (reachable), dead last assert results[0][0] == 'https://lore.kernel.org' assert results[0][1] < float('inf') assert results[1][0] == 'https://dead.example.com' assert results[1][1] == float('inf') def test_probe_4xx_treated_as_unreachable(self) -> None: with responses.RequestsMock() as rsps: """Origins returning 4xx are treated as unreachable.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://nomanifest.example.com'], ) rsps.add( responses.HEAD, 'https://nomanifest.example.com/manifest.js.gz', status=404, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', status=200, ) results = node.probe_origins() assert results[0][0] == 'https://lore.kernel.org' assert results[1][1] == float('inf') def test_probe_single_origin_noop(self) -> None: """With only one origin, probe is a no-op.""" node = LoreNode('https://lore.kernel.org/all') results = node.probe_origins() assert len(results) == 1 assert results[0] == ('https://lore.kernel.org', 0.0) assert node._probe_done is True def test_probe_uses_manifest_url(self) -> None: with responses.RequestsMock() as rsps: """Probe hits /manifest.js.gz on each origin.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) probed_urls: list[str] = [] def callback( request: requests.PreparedRequest, ) -> tuple[int, dict[str, str], str]: assert request.url is not None probed_urls.append(request.url) return 200, {}, '' rsps.add_callback( responses.HEAD, 'http://mirror.local/manifest.js.gz', callback=callback, ) rsps.add_callback( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', callback=callback, ) node.probe_origins() assert len(probed_urls) == 2 assert 'http://mirror.local/manifest.js.gz' in probed_urls assert 'https://lore.kernel.org/manifest.js.gz' in probed_urls def test_probe_sends_user_agent(self) -> None: with responses.RequestsMock() as rsps: """Probe requests include the configured User-Agent.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) node.set_user_agent('myapp', '1.0') captured_headers: list[dict[str, str]] = [] def callback( request: requests.PreparedRequest, ) -> tuple[int, dict[str, str], str]: captured_headers.append(dict(request.headers)) return 200, {}, '' rsps.add_callback( responses.HEAD, 'http://mirror.local/manifest.js.gz', callback=callback, ) rsps.add_callback( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', callback=callback, ) node.probe_origins() for h in captured_headers: assert h['User-Agent'] == 'myapp/1.0' def test_auto_probe_triggers_on_first_request( self, sample_mbox: bytes, ) -> None: with responses.RequestsMock() as rsps: """With auto_probe=True, first _request() triggers probe.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://fast.example.com'], auto_probe=True, ) rsps.add( responses.HEAD, 'https://fast.example.com/manifest.js.gz', status=200, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', status=200, ) rsps.add( responses.GET, 'https://fast.example.com/all/test%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) node.get_mbox_by_msgid('test@example.com') assert node._probe_done is True def test_auto_probe_only_once(self, sample_mbox: bytes) -> None: with responses.RequestsMock() as rsps: """auto_probe fires only on the first request, not subsequent ones.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://fast.example.com'], auto_probe=True, ) probe_count = 0 def callback( _request: requests.PreparedRequest, ) -> tuple[int, dict[str, str], str]: nonlocal probe_count probe_count += 1 return 200, {}, '' rsps.add_callback( responses.HEAD, 'https://fast.example.com/manifest.js.gz', callback=callback, ) rsps.add_callback( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', callback=callback, ) rsps.add( responses.GET, 'https://fast.example.com/all/first%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) rsps.add( responses.GET, 'https://fast.example.com/all/second%40example.com/t.mbox.gz', body=gzip.compress(sample_mbox), status=200, ) node.get_mbox_by_msgid('first@example.com') first_probe_count = probe_count node.get_mbox_by_msgid('second@example.com') # Second request should NOT trigger another probe assert probe_count == first_probe_count def test_probe_cache_write_and_read(self, tmp_path: object) -> None: with responses.RequestsMock() as rsps: """Probe results are cached and restored on next probe call.""" cache_dir = str(tmp_path) node1 = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://fast.example.com'], cache_dir=cache_dir, ) rsps.add( responses.HEAD, 'https://fast.example.com/manifest.js.gz', status=200, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', status=200, ) with patch('liblore.node.time.monotonic') as mock_mono: # fast: 0.1s, canonical: 0.5s mock_mono.side_effect = [0.0, 0.1, 0.0, 0.5] node1.probe_origins() expected_order = node1._all_origins[:] # New node with same origins should get cached order node2 = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://fast.example.com'], cache_dir=cache_dir, ) # Without patching requests.head — cache should be used node2.probe_origins() assert node2._all_origins == expected_order def test_probe_cache_expired(self, tmp_path: object) -> None: with responses.RequestsMock() as rsps: """Expired probe cache triggers a fresh probe.""" cache_dir = str(tmp_path) node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://fast.example.com'], cache_dir=cache_dir, probe_ttl=10, ) rsps.add( responses.HEAD, 'https://fast.example.com/manifest.js.gz', status=200, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', status=200, ) node.probe_origins() # Backdate cache file to force expiry import glob as glob_mod for f in glob_mod.glob(os.path.join(cache_dir, '*.lore.cache')): os.utime(f, (0, 0)) node._probe_done = False rsps.add( responses.HEAD, 'https://fast.example.com/manifest.js.gz', status=200, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', status=200, ) node.probe_origins() assert len(rsps.calls) == 4 def test_probe_cache_ignored_when_origins_change( self, tmp_path: object, ) -> None: with responses.RequestsMock() as rsps: """Cache is ignored when the set of origins differs.""" cache_dir = str(tmp_path) node1 = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://fast.example.com'], cache_dir=cache_dir, ) rsps.add( responses.HEAD, 'https://fast.example.com/manifest.js.gz', status=200, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', status=200, ) node1.probe_origins() # New node with DIFFERENT fallbacks node2 = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://other.example.com'], cache_dir=cache_dir, ) rsps.add( responses.HEAD, 'https://other.example.com/manifest.js.gz', status=200, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', status=200, ) node2.probe_origins() # Different origins → cache miss → fresh probe assert len(rsps.calls) == 4 @pytest.mark.parametrize( 'contents', [ '5', 'null', '"https://lore.kernel.org"', '[["https://lore.kernel.org"]]', '["https://lore.kernel.org", "https://fast.example.com", 5]', '{"https://lore.kernel.org": 1, "https://fast.example.com": 2}', ], ) def test_probe_cache_corrupt_is_ignored( self, tmp_path: object, contents: str ) -> None: """A cache file that is valid JSON but not a list of origins is a miss.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://fast.example.com'], cache_dir=str(tmp_path), ) assert set(node._all_origins) == { 'https://lore.kernel.org', 'https://fast.example.com', } path = os.path.join(str(tmp_path), f'{node._probe_cache_key()}.lore.cache') with open(path, 'w') as f: f.write(contents) assert node._probe_cache_read() is None def test_probe_nocache_skips_cache(self, tmp_path: object) -> None: with responses.RequestsMock() as rsps: """nocache=True forces a live probe even when cache is fresh.""" cache_dir = str(tmp_path) node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://fast.example.com'], cache_dir=cache_dir, ) rsps.add( responses.HEAD, 'https://fast.example.com/manifest.js.gz', status=200, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', status=200, ) # First probe — populates cache with patch('liblore.node.time.monotonic') as mock_mono: mock_mono.side_effect = [0.0, 0.1, 0.0, 0.5] node.probe_origins() # Second probe with nocache — should do a live probe, not read cache node._probe_done = False rsps.add( responses.HEAD, 'https://fast.example.com/manifest.js.gz', status=200, ) rsps.add( responses.HEAD, 'https://lore.kernel.org/manifest.js.gz', status=200, ) with patch('liblore.node.time.monotonic') as mock_mono: mock_mono.side_effect = [0.0, 1.0, 2.0, 3.0] results = node.probe_origins(nocache=True) assert len(rsps.calls) == 4 # Results should have real timing, not 0.0 assert all(elapsed > 0.0 for _, elapsed in results) # ===================================================================== # Git config integration # ===================================================================== class TestGitBool: """Tests for the _git_bool() helper.""" @pytest.mark.parametrize( 'value,expected', [ ('true', True), ('TRUE', True), ('yes', True), ('On', True), ('1', True), ('42', True), ('-1', True), ('false', False), ('No', False), ('off', False), ('0', False), ('', False), (' true ', True), ], ) def test_git_values(self, value: str, expected: bool) -> None: from liblore.node import _git_bool assert _git_bool(value) is expected @pytest.mark.parametrize('value', ['maybe', 'tru', '1.5']) def test_unknown_value(self, value: str) -> None: from liblore.node import _git_bool assert _git_bool(value) is None class TestFromGitConfig: """Tests for LoreNode.from_git_config() with legacy [lore] section. These tests simulate the [lore] fallback path by returning empty from _get_subsection_config (no [liblore] subsection found). """ def test_reads_all_config_keys(self) -> None: """Reads all lore.* keys via [lore] fallback.""" gitcfg: dict[str, str | list[str]] = { 'fallback': [ 'https://tor.lore.kernel.org', 'https://sea.lore.kernel.org', ], 'autoprobe': 'true', 'probetimeout': '10.0', 'probettl': '7200', } with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config() assert node._all_origins == [ 'https://tor.lore.kernel.org', 'https://sea.lore.kernel.org', 'https://lore.kernel.org', ] assert node._auto_probe is True assert node._probe_timeout == 10.0 assert node._probe_ttl == 7200 def test_explicit_kwargs_override_git_config(self) -> None: """Explicit kwargs take precedence over git config values.""" gitcfg: dict[str, str | list[str]] = { 'fallback': ['https://from-git-config.example.com'], 'autoprobe': 'true', } with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config( fallback_urls=['https://explicit.example.com'], auto_probe=False, ) assert node._all_origins == [ 'https://explicit.example.com', 'https://lore.kernel.org', ] assert node._auto_probe is False def test_git_not_installed(self) -> None: """Works fine when both config helpers return empty.""" with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value={}), ): node = LoreNode.from_git_config() assert node._all_origins == ['https://lore.kernel.org'] def test_no_config_keys(self) -> None: """Works fine when no lore.* keys exist in git config.""" with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value={}), ): node = LoreNode.from_git_config() assert node._all_origins == ['https://lore.kernel.org'] assert node._auto_probe is False def test_invalid_probe_timeout_ignored(self) -> None: """Non-numeric lore.probetimeout is silently ignored.""" gitcfg: dict[str, str | list[str]] = {'probetimeout': 'notanumber'} with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config() assert node._probe_timeout == 5.0 # default def test_autoprobe_accepts_git_bools(self) -> None: """autoprobe = yes turns probing on, as it would in git.""" gitcfg: dict[str, str | list[str]] = {'autoprobe': 'yes'} with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config() assert node._auto_probe is True def test_invalid_autoprobe_keeps_default(self) -> None: """An autoprobe value git would reject keeps the default.""" gitcfg: dict[str, str | list[str]] = {'autoprobe': 'maybe'} with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config() assert node._auto_probe is False # default def test_custom_url_passed_through(self) -> None: """The url argument is forwarded to __init__.""" with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value={}), ): node = LoreNode.from_git_config( url='https://my-inbox.example.com/lists', ) assert node._url == 'https://my-inbox.example.com/lists' def test_reads_useragentplus(self) -> None: """Reads lore.useragentplus and applies it via set_user_agent.""" gitcfg: dict[str, str | list[str]] = { 'useragentplus': '550e8400-e29b-41d4', } with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config() assert node._user_agent_plus == '550e8400-e29b-41d4' # Now when the caller sets their app name, plus is auto-applied node.set_user_agent('korgalore', '0.7') assert node._user_agent == 'korgalore/0.7+550e8400-e29b-41d4' def test_useragentplus_overridden_by_explicit_plus(self) -> None: """Explicit plus= in set_user_agent overrides git config value.""" gitcfg: dict[str, str | list[str]] = { 'useragentplus': 'from-git-config', } with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config() node.set_user_agent('myapp', '1.0', plus='explicit') assert node._user_agent == 'myapp/1.0+explicit' def test_useragentplus_not_set_without_git_config(self) -> None: """Without git config, set_user_agent works as before.""" node = LoreNode() node.set_user_agent('myapp', '1.0') assert node._user_agent == 'myapp/1.0' def test_useragentplus_applied_to_session(self) -> None: """The plus identifier propagates to the session headers.""" gitcfg: dict[str, str | list[str]] = { 'useragentplus': 'myuuid', } with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config() node.set_user_agent('bugspray', '0.3') session = node._get_session() assert session.headers['User-Agent'] == 'bugspray/0.3+myuuid' class TestGetConfigFromGit: """Tests for the _get_config_from_git() helper.""" def test_parses_nul_separated_output(self) -> None: """Parses git config -z output correctly.""" from liblore.node import _get_config_from_git result = subprocess.CompletedProcess[str]( args=['git'], returncode=0, stdout=( 'lore.fallback\nhttps://tor.lore.kernel.org\x00' 'lore.fallback\nhttps://sea.lore.kernel.org\x00' 'lore.autoprobe\ntrue\x00' 'lore.probettl\n7200\x00' ), ) with patch('liblore.node.subprocess.run', return_value=result): cfg = _get_config_from_git(r'^lore\.', multivals=['fallback']) assert cfg == { 'fallback': [ 'https://tor.lore.kernel.org', 'https://sea.lore.kernel.org', ], 'autoprobe': 'true', 'probettl': '7200', } def test_git_not_installed(self) -> None: """Returns empty dict when git is not installed.""" from liblore.node import _get_config_from_git with patch( 'liblore.node.subprocess.run', side_effect=FileNotFoundError('git not found'), ): cfg = _get_config_from_git(r'^lore\.') assert cfg == {} def test_no_matching_keys(self) -> None: """Returns empty dict when no keys match the regexp.""" from liblore.node import _get_config_from_git result = subprocess.CompletedProcess[str]( args=['git'], returncode=1, stdout='', ) with patch('liblore.node.subprocess.run', return_value=result): cfg = _get_config_from_git(r'^lore\.') assert cfg == {} def test_bare_key_defaults_to_true(self) -> None: """A key without a value (no newline) defaults to 'true'.""" from liblore.node import _get_config_from_git result = subprocess.CompletedProcess[str]( args=['git'], returncode=0, stdout='lore.autoprobe\x00', ) with patch('liblore.node.subprocess.run', return_value=result): cfg = _get_config_from_git(r'^lore\.') assert cfg == {'autoprobe': 'true'} class TestGetSubsectionConfig: """Tests for the _get_subsection_config() helper.""" def test_parses_subsection_keys(self) -> None: """Parses keys from [liblore "https://lore.kernel.org"].""" from liblore.node import _get_subsection_config result = subprocess.CompletedProcess[str]( args=['git'], returncode=0, stdout=( 'liblore.https://lore.kernel.org.fallback\nhttps://tor.lore.kernel.org\x00' 'liblore.https://lore.kernel.org.fallback\nhttps://sea.lore.kernel.org\x00' 'liblore.https://lore.kernel.org.autoprobe\ntrue\x00' 'liblore.https://lore.kernel.org.useragentplus\nmyuuid\x00' ), ) with patch('liblore.node.subprocess.run', return_value=result): cfg = _get_subsection_config( 'liblore', 'https://lore.kernel.org', multivals=['fallback'], ) assert cfg == { 'fallback': [ 'https://tor.lore.kernel.org', 'https://sea.lore.kernel.org', ], 'autoprobe': 'true', 'useragentplus': 'myuuid', } def test_dots_in_subsection_handled(self) -> None: """Subsection names with dots (URLs) are parsed correctly.""" from liblore.node import _get_subsection_config result = subprocess.CompletedProcess[str]( args=['git'], returncode=0, stdout='liblore.https://subspace.kernel.org.fallback\nhttps://mirror.example.com\x00', ) with patch('liblore.node.subprocess.run', return_value=result): cfg = _get_subsection_config( 'liblore', 'https://subspace.kernel.org', multivals=['fallback'], ) assert cfg == { 'fallback': ['https://mirror.example.com'], } def test_no_matching_subsection(self) -> None: """Returns empty dict when no keys match the subsection.""" from liblore.node import _get_subsection_config result = subprocess.CompletedProcess[str]( args=['git'], returncode=1, stdout='', ) with patch('liblore.node.subprocess.run', return_value=result): cfg = _get_subsection_config( 'liblore', 'https://nonexistent.example.com', ) assert cfg == {} def test_git_not_installed(self) -> None: """Returns empty dict when git is not installed.""" from liblore.node import _get_subsection_config with patch( 'liblore.node.subprocess.run', side_effect=FileNotFoundError('git not found'), ): cfg = _get_subsection_config( 'liblore', 'https://lore.kernel.org', ) assert cfg == {} class TestFromGitConfigSubsections: """Tests for from_git_config() with [liblore ""] subsections.""" def test_subsection_used_for_lore(self) -> None: """[liblore "https://lore.kernel.org"] is used when present.""" subsection_cfg: dict[str, str | list[str]] = { 'fallback': ['https://tor.lore.kernel.org'], 'autoprobe': 'true', 'useragentplus': 'subsection-uuid', } with ( patch('liblore.node._get_subsection_config', return_value=subsection_cfg), patch('liblore.node._get_config_from_git', return_value={}) as mock_legacy, ): node = LoreNode.from_git_config() assert node._all_origins == [ 'https://tor.lore.kernel.org', 'https://lore.kernel.org', ] assert node._auto_probe is True assert node._user_agent_plus == 'subsection-uuid' # [lore] should NOT be consulted when subsection has config mock_legacy.assert_not_called() def test_falls_back_to_lore_section(self) -> None: """Falls back to [lore] when no [liblore] subsection for lore.kernel.org.""" legacy_cfg: dict[str, str | list[str]] = { 'fallback': ['https://sea.lore.kernel.org'], 'autoprobe': 'true', } with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=legacy_cfg), ): node = LoreNode.from_git_config() assert node._all_origins == [ 'https://sea.lore.kernel.org', 'https://lore.kernel.org', ] assert node._auto_probe is True def test_no_fallback_to_lore_for_non_lore_url(self) -> None: """Non-lore URLs do NOT fall back to [lore] section.""" legacy_cfg: dict[str, str | list[str]] = { 'fallback': ['https://tor.lore.kernel.org'], 'autoprobe': 'true', } with ( patch('liblore.node._get_subsection_config', return_value={}), patch( 'liblore.node._get_config_from_git', return_value=legacy_cfg ) as mock_legacy, ): node = LoreNode.from_git_config( url='https://subspace.kernel.org/_lists/helpdesk', ) # Should have NO fallbacks — [lore] mirrors don't serve subspace assert node._all_origins == ['https://subspace.kernel.org'] assert node._auto_probe is False # [lore] must not be consulted for non-lore URLs mock_legacy.assert_not_called() def test_non_lore_url_with_own_subsection(self) -> None: """Non-lore URLs use their own [liblore ""] section.""" subsection_cfg: dict[str, str | list[str]] = { 'fallback': ['https://subspace-mirror.kernel.org'], 'useragentplus': 'subspace-token', } with ( patch('liblore.node._get_subsection_config', return_value=subsection_cfg), patch('liblore.node._get_config_from_git') as mock_legacy, ): node = LoreNode.from_git_config( url='https://subspace.kernel.org/_lists/helpdesk', ) assert node._all_origins == [ 'https://subspace-mirror.kernel.org', 'https://subspace.kernel.org', ] assert node._user_agent_plus == 'subspace-token' mock_legacy.assert_not_called() def test_subsection_overrides_lore_section(self) -> None: """[liblore "https://lore.kernel.org"] takes precedence over [lore].""" subsection_cfg: dict[str, str | list[str]] = { 'fallback': ['https://preferred-mirror.example.com'], 'useragentplus': 'new-uuid', } legacy_cfg: dict[str, str | list[str]] = { 'fallback': ['https://old-mirror.example.com'], 'useragentplus': 'old-uuid', } with ( patch('liblore.node._get_subsection_config', return_value=subsection_cfg), patch( 'liblore.node._get_config_from_git', return_value=legacy_cfg ) as mock_legacy, ): node = LoreNode.from_git_config() # Subsection wins assert node._all_origins == [ 'https://preferred-mirror.example.com', 'https://lore.kernel.org', ] assert node._user_agent_plus == 'new-uuid' # Legacy not even consulted mock_legacy.assert_not_called() def test_explicit_kwargs_override_subsection(self) -> None: """Explicit kwargs still take precedence over subsection config.""" subsection_cfg: dict[str, str | list[str]] = { 'fallback': ['https://from-config.example.com'], 'autoprobe': 'true', } with patch('liblore.node._get_subsection_config', return_value=subsection_cfg): node = LoreNode.from_git_config( fallback_urls=['https://explicit.example.com'], auto_probe=False, ) assert node._all_origins == [ 'https://explicit.example.com', 'https://lore.kernel.org', ] assert node._auto_probe is False # ===================================================================== # Public API: request() # ===================================================================== class TestRequest: """Tests for the public request() method.""" def test_delegates_to_private_request(self) -> None: with responses.RequestsMock() as rsps: """request() delegates to _request() with raise_on_error=True.""" node = LoreNode('https://lore.kernel.org/all') rsps.add( responses.GET, 'https://lore.kernel.org/manifest.js.gz', status=200, ) resp = node.request('GET', 'https://lore.kernel.org/manifest.js.gz') assert resp.status_code == 200 def test_failover_works(self) -> None: with responses.RequestsMock() as rsps: """First origin fails, second succeeds.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.GET, 'http://mirror.local/manifest.js.gz', body=requests.ConnectionError('refused'), ) rsps.add( responses.GET, 'https://lore.kernel.org/manifest.js.gz', status=200, ) resp = node.request('GET', 'https://lore.kernel.org/manifest.js.gz') assert resp.status_code == 200 assert len(rsps.calls) == 2 def test_raises_remote_error_when_all_fail(self) -> None: with responses.RequestsMock() as rsps: """RemoteError raised when every origin fails.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) rsps.add( responses.GET, 'http://mirror.local/manifest.js.gz', body=requests.ConnectionError('refused'), ) rsps.add( responses.GET, 'https://lore.kernel.org/manifest.js.gz', body=requests.ConnectionError('refused'), ) with pytest.raises(RemoteError, match='All hosts failed') as ei: node.request('GET', 'https://lore.kernel.org/manifest.js.gz') assert ei.value.status_code is None def test_kwargs_forwarded(self) -> None: """Extra kwargs (e.g. timeout) are passed through.""" node = LoreNode('https://lore.kernel.org/all') with patch.object( node, '_request', return_value=MagicMock(status_code=200) ) as mock_request: node.request( 'GET', 'https://lore.kernel.org/manifest.js.gz', timeout=30, ) mock_request.assert_called_once_with( 'GET', 'https://lore.kernel.org/manifest.js.gz', raise_on_error=True, timeout=30, ) # ===================================================================== # Public API: user_agent_plus property # ===================================================================== class TestUserAgentPlusProperty: """Tests for the user_agent_plus read-only property.""" def test_none_when_created_directly(self) -> None: """None when created via LoreNode() without git config.""" node = LoreNode() assert node.user_agent_plus is None def test_set_via_from_git_config(self) -> None: """Populated when created via from_git_config() with the key set.""" gitcfg: dict[str, str | list[str]] = { 'useragentplus': 'my-tracking-uuid', } with patch('liblore.node._get_config_from_git', return_value=gitcfg): node = LoreNode.from_git_config() assert node.user_agent_plus == 'my-tracking-uuid' # ===================================================================== # Public API: origins property # ===================================================================== class TestOriginsProperty: """Tests for the origins read-only property.""" def test_returns_fallbacks_plus_canonical(self) -> None: """Returns fallbacks followed by the canonical origin.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local', 'https://ams.lore.kernel.org'], ) assert node.origins == [ 'http://mirror.local', 'https://ams.lore.kernel.org', 'https://lore.kernel.org', ] def test_returns_copy(self) -> None: """Mutating the returned list does not affect the node.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['http://mirror.local'], ) origins = node.origins origins.append('https://injected.example.com') assert 'https://injected.example.com' not in node.origins def test_reflects_reordering_after_probe(self) -> None: """After probe_origins(), list reflects the new order.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://slow.example.com', 'https://fast.example.com'], ) latencies = { 'slow.example.com': 2.0, 'fast.example.com': 0.1, 'lore.kernel.org': 0.5, } def fake_probe_one(origin: str) -> tuple[str, float]: for host, lat in latencies.items(): if host in origin: return origin, lat return origin, float('inf') with patch.object(node, '_probe_one', side_effect=fake_probe_one): node.probe_origins() assert node.origins[0] == 'https://fast.example.com' # ===================================================================== # Public API: canonical_origin property # ===================================================================== class TestCanonicalOriginProperty: """Tests for the canonical_origin read-only property.""" def test_matches_scheme_host(self) -> None: """Returns scheme://host from the URL passed to __init__.""" node = LoreNode('https://lore.kernel.org/all') assert node.canonical_origin == 'https://lore.kernel.org' def test_no_path_component(self) -> None: """The origin has no path, even when the URL does.""" node = LoreNode('https://lore.kernel.org/some/deep/path') assert node.canonical_origin == 'https://lore.kernel.org' assert '/some' not in node.canonical_origin def test_preserves_port(self) -> None: """Port number is included in the origin when present.""" node = LoreNode('http://localhost:8080/inbox') assert node.canonical_origin == 'http://localhost:8080' # ===================================================================== # Cancellation and request timeouts # ===================================================================== class TestCancellation: """Tests for operation-scoped cancellation: cancel_active()/shutdown().""" def test_cancel_active_closes_owned_session(self) -> None: """cancel_active() closes an owned session and nulls it out.""" node = LoreNode() session = node._get_session() # owned with patch.object(session, 'close') as mock_close: node.cancel_active() mock_close.assert_called_once() assert node._session is None assert node._owns_session is False def test_shutdown_closes_owned_session(self) -> None: """shutdown() closes an owned session and nulls it out.""" node = LoreNode() session = node._get_session() # owned with patch.object(session, 'close') as mock_close: node.shutdown() mock_close.assert_called_once() assert node._session is None assert node._owns_session is False def test_cancel_active_leaves_injected_session(self) -> None: """cancel_active() must not close or drop an injected session.""" node = LoreNode() mock_session = MagicMock() node.set_requests_session(mock_session) node.cancel_active() mock_session.close.assert_not_called() assert node._session is mock_session def test_new_operation_after_cancel_active_proceeds(self) -> None: """cancel_active() does not poison operations started later. This is the structural fix for the stale-cancel-flag bug class: there is no sticky flag, so a fresh operation needs no reset. """ node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() mock_session.get.return_value = MagicMock(status_code=200) node.set_requests_session(mock_session) node.cancel_active() resp = node.request('GET', 'https://lore.kernel.org/manifest.js.gz') assert resp.status_code == 200 def test_cancel_active_mid_flight_does_not_failover(self) -> None: """A socket closed by cancel_active() cancels, without failover. The session being closed mid-flight surfaces as a ConnectionError, which would normally trigger failover to the next origin. When the operation was cancelled, it must convert to OperationCancelledError instead and stop — no failover, no RemoteError. """ node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://mirror.example.com'], ) mock_session = MagicMock() def boom(*_args: object, **_kwargs: object) -> object: # Simulate cancel_active() closing the socket during the read. node.cancel_active() raise requests.ConnectionError('socket closed') mock_session.get.side_effect = boom node.set_requests_session(mock_session) with pytest.raises(OperationCancelledError): node.request('GET', 'https://lore.kernel.org/manifest.js.gz') # Only the first origin was tried — no failover to the mirror. assert mock_session.get.call_count == 1 def test_cancelled_operation_stays_cancelled(self) -> None: """Within one operation a cancel is sticky; outside it is not.""" node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() mock_session.get.return_value = MagicMock(status_code=200) node.set_requests_session(mock_session) with node._operation(): node.cancel_active() # Every request for the rest of this operation raises... for _ in range(2): with pytest.raises(OperationCancelledError): node.request('GET', 'https://lore.kernel.org/manifest.js.gz') # ...and none of them was dispatched over the wire. mock_session.get.assert_not_called() # A fresh operation proceeds normally. resp = node.request('GET', 'https://lore.kernel.org/manifest.js.gz') assert resp.status_code == 200 def test_shutdown_refuses_new_operations(self) -> None: """After shutdown(), starting an operation raises immediately.""" node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() node.set_requests_session(mock_session) assert node.is_shutdown is False node.shutdown() assert node.is_shutdown is True with pytest.raises(OperationCancelledError): node.request('GET', 'https://lore.kernel.org/manifest.js.gz') # Refused at operation entry — no HTTP call was attempted. mock_session.get.assert_not_called() def test_shutdown_mid_flight_does_not_failover(self) -> None: """A socket closed by shutdown() cancels, without failover.""" node = LoreNode( 'https://lore.kernel.org/all', fallback_urls=['https://mirror.example.com'], ) mock_session = MagicMock() def boom(*_args: object, **_kwargs: object) -> object: node.shutdown() raise requests.ConnectionError('socket closed') mock_session.get.side_effect = boom node.set_requests_session(mock_session) with pytest.raises(OperationCancelledError): node.request('GET', 'https://lore.kernel.org/manifest.js.gz') assert mock_session.get.call_count == 1 def test_batch_cancel_between_requests_stops_batch( self, sample_mbox: bytes ) -> None: """A cancel landing between batch items stops the whole batch. The batch methods are one operation, so a cancel_active() that arrives while no request is in flight (e.g. during the inter-item cooldown) must still stop the remaining items. """ node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() def first_ok_then_cancelled(*_args: object, **_kwargs: object) -> object: # The cancel lands while item one is still being processed, # i.e. between the batch's HTTP requests. node.cancel_active() return MagicMock(status_code=200, content=gzip.compress(sample_mbox)) mock_session.get.side_effect = first_ok_then_cancelled node.set_requests_session(mock_session) with pytest.raises(OperationCancelledError): node.batch_get_thread_by_msgid(['first@example.com', 'first@example.com']) # Item one completed; item two was never fetched. assert mock_session.get.call_count == 1 def test_message_cancel_mid_flight_is_not_remote_error(self) -> None: """A cancel during get_message_by_msgid() raises OperationCancelledError. get_message_by_msgid() turns unexpected errors into RemoteError; a cancel must not be one of them, or callers that catch RemoteError would treat the cancel as a server failure. """ node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() def boom(*_args: object, **_kwargs: object) -> object: node.cancel_active() raise requests.ConnectionError('socket closed') mock_session.get.side_effect = boom node.set_requests_session(mock_session) with pytest.raises(OperationCancelledError): node.get_message_by_msgid('first@example.com') def test_head_redirect_cancel_mid_flight(self) -> None: """A cancel during the redirect lookup raises OperationCancelledError.""" node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() mock_session.get.return_value = MagicMock(status_code=404) def boom(*_args: object, **_kwargs: object) -> object: node.cancel_active() raise requests.ConnectionError('socket closed') mock_session.head.side_effect = boom node.set_requests_session(mock_session) with pytest.raises(OperationCancelledError): node.get_mbox_by_msgid('first@example.com') def test_validate_cancel_mid_flight_is_not_remote_error(self) -> None: """A cancel during validate() raises OperationCancelledError.""" node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() def boom(*_args: object, **_kwargs: object) -> object: node.cancel_active() raise requests.ConnectionError('socket closed') mock_session.head.side_effect = boom node.set_requests_session(mock_session) with pytest.raises(OperationCancelledError): node.validate() def test_nested_operations_join_the_outermost(self) -> None: """Nested _operation() scopes share the outer generation.""" node = LoreNode() with node._operation(): outer_generation = node._op_local.generation with node._operation(): assert node._op_local.depth == 2 assert node._op_local.generation == outer_generation assert node._op_local.depth == 1 assert node._op_local.depth == 0 assert node._op_local.generation is None def test_cancel_is_deprecated_alias_for_cancel_active(self) -> None: """cancel() warns and behaves exactly like cancel_active().""" node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() mock_session.get.return_value = MagicMock(status_code=200) node.set_requests_session(mock_session) with pytest.warns(DeprecationWarning, match='cancel_active'): node.cancel() assert node._cancel_generation == 1 # Not sticky: a fresh operation proceeds without any reset. resp = node.request('GET', 'https://lore.kernel.org/manifest.js.gz') assert resp.status_code == 200 def test_reset_cancel_is_a_deprecated_noop(self) -> None: """reset_cancel() warns and changes no cancellation state.""" node = LoreNode() with pytest.warns(DeprecationWarning, match='operation-scoped'): node.reset_cancel() assert node._cancel_generation == 0 assert node.is_shutdown is False def test_default_timeout_injected(self) -> None: """A request without an explicit timeout gets the default.""" node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() mock_session.get.return_value = MagicMock(status_code=200) node.set_requests_session(mock_session) node.request('GET', 'https://lore.kernel.org/manifest.js.gz') _, kwargs = mock_session.get.call_args assert kwargs['timeout'] == (5.0, 30.0) def test_explicit_timeout_preserved(self) -> None: """An explicit per-call timeout overrides the default.""" node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() mock_session.get.return_value = MagicMock(status_code=200) node.set_requests_session(mock_session) node.request('GET', 'https://lore.kernel.org/manifest.js.gz', timeout=99) _, kwargs = mock_session.get.call_args assert kwargs['timeout'] == 99 def test_custom_request_timeout_from_init(self) -> None: """request_timeout passed to __init__ is the injected default.""" node = LoreNode('https://lore.kernel.org/all', request_timeout=12.0) mock_session = MagicMock() mock_session.get.return_value = MagicMock(status_code=200) node.set_requests_session(mock_session) node.request('GET', 'https://lore.kernel.org/manifest.js.gz') _, kwargs = mock_session.get.call_args assert kwargs['timeout'] == 12.0 def test_resolve_via_head_passes_timeout(self) -> None: """_resolve_msgid_via_head() applies the request timeout.""" node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() head_resp = MagicMock(status_code=404) head_resp.url = 'https://lore.kernel.org/q/' # no redirect mock_session.head.return_value = head_resp node.set_requests_session(mock_session) node._resolve_msgid_via_head('q') _, kwargs = mock_session.head.call_args assert kwargs['timeout'] == (5.0, 30.0) def test_validate_passes_timeout(self) -> None: """validate() applies the request timeout to its HEAD call.""" node = LoreNode('https://lore.kernel.org/all') mock_session = MagicMock() mock_session.head.return_value = MagicMock(status_code=200) node.set_requests_session(mock_session) node.validate() _, kwargs = mock_session.head.call_args assert kwargs['timeout'] == (5.0, 30.0) def test_from_git_config_single_float(self) -> None: """A single-float requesttimeout is parsed into a float.""" gitcfg: dict[str, str | list[str]] = {'requesttimeout': '15'} with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config() assert node._request_timeout == 15.0 def test_from_git_config_connect_read_tuple(self) -> None: """A "connect,read" requesttimeout is parsed into a tuple.""" gitcfg: dict[str, str | list[str]] = {'requesttimeout': '3,20'} with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config() assert node._request_timeout == (3.0, 20.0) def test_from_git_config_invalid_ignored(self) -> None: """A non-numeric requesttimeout is silently ignored (default kept).""" gitcfg: dict[str, str | list[str]] = {'requesttimeout': 'notanumber'} with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=gitcfg), ): node = LoreNode.from_git_config() assert node._request_timeout == (5.0, 30.0) python-liblore-0.10.1/tests/test_partial.py000066400000000000000000003571021526032434100207570ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Tests for partial mirror support (X-Archive-Coverage / X-Archive-Upstream).""" from __future__ import annotations import gzip import json import logging import os import threading import time from collections.abc import Callable, Iterator from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import patch import pytest import requests import responses from liblore import ( LibloreError, NotOnMirrorError, OperationCancelledError, RemoteError, Source, ) from liblore.node import ( LoreNode, _date_string_epoch, _LoreNodeInitKwargs, _normalize_url, _parse_coverage, ) if TYPE_CHECKING: from typing_extensions import Self, Unpack LOCAL = 'http://127.0.0.1:11043/lore/all' RAW = f'{LOCAL}/x%40example.com/raw' UPSTREAM = 'https://lore.kernel.org/all' PARTIAL = { 'X-Archive-Coverage': 'partial', 'X-Archive-Upstream': 'https://lore.kernel.org/all/', } # A partial mirror that says when it last caught up: its known threads # can be searched locally. UPDATED = 1790000000 FRESH = {**PARTIAL, 'X-Archive-Coverage': f'partial; updated={UPDATED}'} # A partial mirror naming an upstream that is not trusted by default. OTHER = {**PARTIAL, 'X-Archive-Upstream': 'https://mirror.example.com/all'} @pytest.fixture(autouse=True) def _reset_warnings() -> Iterator[None]: # pyright: ignore[reportUnusedFunction] """Each test starts with no "untrusted upstream" or outage warning given yet.""" with ( patch('liblore.node._warned_untrusted', set[str]()), patch('liblore.node._warned_outage', set[tuple[str, str]]()), ): yield def fetch( node: LoreNode, status: int = 200, headers: dict[str, str] | None = None, url: str = RAW, ) -> None: """Make *node* receive one response with *status* and *headers*.""" with responses.RequestsMock() as rsps: rsps.add(responses.GET, url, status=status, headers=headers or {}) node.request('GET', url) def upstream_files(cache_dir: Path) -> list[Path]: return sorted(cache_dir.glob('*.lore.upstream')) # ===================================================================== # Helpers # ===================================================================== class TestParseCoverage: def test_token_only(self) -> None: assert _parse_coverage('partial') == ('partial', {}) def test_parameters(self) -> None: assert _parse_coverage(' Partial ; Updated = 1790000000 ; future ') == ( 'partial', {'updated': '1790000000'}, ) def test_full(self) -> None: assert _parse_coverage('full') == ('full', {}) class TestNormalizeUrl: @pytest.mark.parametrize( 'url,expected', [ ('HTTPS://Lore.Kernel.org/all/', 'https://lore.kernel.org/all'), ('https://lore.kernel.org:443/all', 'https://lore.kernel.org/all'), ('http://127.0.0.1:80/lore/all', 'http://127.0.0.1/lore/all'), ('http://127.0.0.1:11043/lore/all/', 'http://127.0.0.1:11043/lore/all'), ('https://example.com:80/All', 'https://example.com:80/All'), ('http://[::1]:11043/lore', 'http://[::1]:11043/lore'), ], ) def test_normalize(self, url: str, expected: str) -> None: assert _normalize_url(url) == expected @pytest.mark.parametrize( 'url', ['/lore/all', 'lore.kernel.org', 'http://x:99999', 'https://u:p@example.com'], ) def test_invalid(self, url: str) -> None: with pytest.raises(ValueError): _normalize_url(url) # ===================================================================== # L1: learning that a node is partial # ===================================================================== class TestLearnUpstream: def test_partial_learns_upstream(self) -> None: node = LoreNode(LOCAL) fetch(node, headers=PARTIAL) # The trailing / from the header is dropped. assert node._get_upstream_url() == UPSTREAM def test_no_headers_is_full(self) -> None: node = LoreNode(LOCAL) fetch(node) assert node._get_upstream_url() is None @pytest.mark.parametrize('coverage', ['full', 'complete', '']) def test_other_tokens_are_full(self, coverage: str) -> None: node = LoreNode(LOCAL) fetch(node, headers={**PARTIAL, 'X-Archive-Coverage': coverage}) assert node._get_upstream_url() is None def test_upstream_without_partial_ignored(self) -> None: node = LoreNode(LOCAL) fetch(node, headers={'X-Archive-Upstream': UPSTREAM}) assert node._get_upstream_url() is None def test_partial_without_upstream(self) -> None: node = LoreNode(LOCAL) fetch(node, headers={'X-Archive-Coverage': 'partial'}) assert node._get_upstream_url() is None def test_token_case_and_parameters(self) -> None: node = LoreNode(LOCAL) headers = {**PARTIAL, 'X-Archive-Coverage': 'PARTIAL; updated=1790000000'} fetch(node, headers=headers) assert node._get_upstream_url() == UPSTREAM def test_learned_on_404(self) -> None: """A miss is the most likely first answer from a partial mirror.""" node = LoreNode(LOCAL) fetch(node, status=404, headers=PARTIAL) assert node._get_upstream_url() == UPSTREAM def test_learned_on_5xx_with_headers(self) -> None: node = LoreNode(LOCAL) fetch(node, status=503, headers=PARTIAL) assert node._get_upstream_url() == UPSTREAM def test_learned_from_fallback_origin(self) -> None: node = LoreNode( 'https://lore.example.org/all', fallback_urls=['http://127.0.0.1:11043'], allowed_upstreams=['https://lore.example.org'], ) url = 'https://lore.example.org/all/x%40example.com/raw' with responses.RequestsMock() as rsps: rsps.add( responses.GET, 'http://127.0.0.1:11043/all/x%40example.com/raw', headers={ **PARTIAL, 'X-Archive-Upstream': 'https://lore.kernel.org/all', }, ) node.request('GET', url) assert node._get_upstream_url() == UPSTREAM @pytest.mark.parametrize( 'value', [ '/all/', 'lore.kernel.org/all', 'ftp://lore.kernel.org/all', 'https://lore.kernel.org/all?x=1', 'https://user@lore.kernel.org/all', 'https://lore.kernel.org:99999/all', ], ) def test_invalid_upstream_ignored(self, value: str) -> None: node = LoreNode(LOCAL) fetch(node, headers={**PARTIAL, 'X-Archive-Upstream': value}) assert node._get_upstream_url() is None def test_self_reference_ignored(self) -> None: node = LoreNode(LOCAL, allowed_upstreams=['http://127.0.0.1:11043']) headers = {**PARTIAL, 'X-Archive-Upstream': 'HTTP://127.0.0.1:11043/lore/all/'} fetch(node, headers=headers) assert node._get_upstream_url() is None def test_normalized_upstream(self) -> None: node = LoreNode(LOCAL) headers = {**PARTIAL, 'X-Archive-Upstream': 'HTTPS://Lore.Kernel.org:443/all/'} fetch(node, headers=headers) assert node._get_upstream_url() == UPSTREAM def test_follow_upstream_off(self, tmp_path: Path) -> None: node = LoreNode(LOCAL, follow_upstream=False, cache_dir=str(tmp_path)) fetch(node, headers=PARTIAL) assert node._get_upstream_url() is None assert upstream_files(tmp_path) == [] class TestTrust: def test_untrusted_ignored(self) -> None: node = LoreNode(LOCAL) fetch(node, headers=OTHER) assert node._get_upstream_url() is None def test_untrusted_warns_once_per_process( self, caplog: pytest.LogCaptureFixture ) -> None: with caplog.at_level(logging.WARNING, logger='liblore.node'): for _ in range(2): node = LoreNode(LOCAL) fetch(node, headers=OTHER) fetch(node, headers=OTHER) warnings = [r for r in caplog.records if 'untrusted' in r.getMessage()] assert len(warnings) == 1 assert 'https://mirror.example.com' in warnings[0].getMessage() def test_allowed_upstream_trusted(self) -> None: node = LoreNode(LOCAL, allowed_upstreams=['HTTPS://Mirror.Example.com:443/']) fetch(node, headers=OTHER) assert node._get_upstream_url() == 'https://mirror.example.com/all' def test_allowed_is_per_origin(self) -> None: """Trusting a host on one port does not trust it on another.""" node = LoreNode(LOCAL, allowed_upstreams=['https://mirror.example.com:8443']) fetch(node, headers=OTHER) assert node._get_upstream_url() is None def test_plain_http_lore_not_trusted(self) -> None: """Only https://lore.kernel.org is built in, not http://.""" node = LoreNode(LOCAL) headers = {**PARTIAL, 'X-Archive-Upstream': 'http://lore.kernel.org/all'} fetch(node, headers=headers) assert node._get_upstream_url() is None @pytest.mark.parametrize( 'value', [ 'mirror.example.com', 'ftp://mirror.example.com', 'https://mirror.example.com/all', 'https://user@mirror.example.com', ], ) def test_invalid_allowed_upstream(self, value: str) -> None: with pytest.raises(LibloreError, match='Invalid allowed upstream'): LoreNode(LOCAL, allowed_upstreams=[value]) class TestForget: def learned(self) -> LoreNode: node = LoreNode(LOCAL) fetch(node, headers=PARTIAL) assert node._get_upstream_url() == UPSTREAM return node @pytest.mark.parametrize('status', [200, 302, 404]) def test_forgotten_without_headers(self, status: int) -> None: """The server changed its mind (e.g. ARCHIVE_UPSTREAM='').""" node = self.learned() fetch(node, status=status) assert node._get_upstream_url() is None @pytest.mark.parametrize('status', [500, 502, 503, 401, 403]) def test_kept_without_headers(self, status: int) -> None: """A proxy in front of the mirror may answer without the headers.""" node = self.learned() fetch(node, status=status) assert node._get_upstream_url() == UPSTREAM def test_forgotten_when_upstream_becomes_untrusted(self) -> None: node = self.learned() fetch(node, headers=OTHER) assert node._get_upstream_url() is None # ===================================================================== # L1: remembering the upstream across runs # ===================================================================== class TestSavedUpstream: def test_saved(self, tmp_path: Path) -> None: node = LoreNode(LOCAL, cache_dir=str(tmp_path)) fetch(node, headers=PARTIAL) (path,) = upstream_files(tmp_path) assert json.loads(path.read_text()) == {'url': LOCAL, 'upstream': UPSTREAM} assert list(tmp_path.glob('*.tmp')) == [] def test_loaded_without_any_request(self, tmp_path: Path) -> None: """This is what lets a stopped mirror fall back to the upstream.""" fetch(LoreNode(LOCAL, cache_dir=str(tmp_path)), headers=PARTIAL) # conftest blocks the network: this node sends no request at all. node = LoreNode(LOCAL, cache_dir=str(tmp_path)) assert node._get_upstream_url() == UPSTREAM def test_keyed_by_url(self, tmp_path: Path) -> None: fetch(LoreNode(LOCAL, cache_dir=str(tmp_path)), headers=PARTIAL) other = LoreNode('http://127.0.0.1:11043/lore/tools', cache_dir=str(tmp_path)) assert other._get_upstream_url() is None def test_survives_clear_cache(self, tmp_path: Path) -> None: node = LoreNode(LOCAL, cache_dir=str(tmp_path)) fetch(node, headers=PARTIAL) node.clear_cache() assert len(upstream_files(tmp_path)) == 1 def test_deleted_when_forgotten(self, tmp_path: Path) -> None: node = LoreNode(LOCAL, cache_dir=str(tmp_path)) fetch(node, headers=PARTIAL) fetch(node, status=404) assert upstream_files(tmp_path) == [] assert LoreNode(LOCAL, cache_dir=str(tmp_path))._get_upstream_url() is None def test_kept_on_5xx_without_headers(self, tmp_path: Path) -> None: node = LoreNode(LOCAL, cache_dir=str(tmp_path)) fetch(node, headers=PARTIAL) fetch(node, status=503) assert len(upstream_files(tmp_path)) == 1 def test_written_only_on_change(self, tmp_path: Path) -> None: node = LoreNode(LOCAL, cache_dir=str(tmp_path)) with patch('liblore.node.os.replace', wraps=os.replace) as replace: for _ in range(3): fetch(node, headers=PARTIAL) assert replace.call_count == 1 def test_file_from_earlier_run_not_rewritten(self, tmp_path: Path) -> None: """The saved file is loaded before the first answer is compared.""" fetch(LoreNode(LOCAL, cache_dir=str(tmp_path)), headers=PARTIAL) node = LoreNode(LOCAL, cache_dir=str(tmp_path)) with patch('liblore.node.os.replace', wraps=os.replace) as replace: fetch(node, headers=PARTIAL) assert replace.call_count == 0 def test_saved_upstream_checked_for_trust(self, tmp_path: Path) -> None: """Removing a host from allowupstream stops a saved file using it.""" allowed = ['https://mirror.example.com'] node = LoreNode(LOCAL, cache_dir=str(tmp_path), allowed_upstreams=allowed) fetch(node, headers=OTHER) assert len(upstream_files(tmp_path)) == 1 assert LoreNode(LOCAL, cache_dir=str(tmp_path))._get_upstream_url() is None @pytest.mark.parametrize( 'content', [ b'not json', b'\xff\xfe', b'["a list"]', json.dumps({'url': LOCAL}).encode(), json.dumps({'url': LOCAL, 'upstream': 42}).encode(), json.dumps({'url': 'http://elsewhere/all', 'upstream': UPSTREAM}).encode(), ], ) def test_bad_file_ignored(self, tmp_path: Path, content: bytes) -> None: fetch(LoreNode(LOCAL, cache_dir=str(tmp_path)), headers=PARTIAL) (path,) = upstream_files(tmp_path) path.write_bytes(content) assert LoreNode(LOCAL, cache_dir=str(tmp_path))._get_upstream_url() is None def test_no_cache_dir(self) -> None: """Without cache_dir the upstream is learned again in each process.""" node = LoreNode(LOCAL) fetch(node, headers=PARTIAL) assert node._get_upstream_url() == UPSTREAM assert LoreNode(LOCAL)._get_upstream_url() is None # ===================================================================== # L8: git config # ===================================================================== class TestGitConfig: def from_config(self, cfg: dict[str, str | list[str]]) -> LoreNode: with ( patch('liblore.node._get_subsection_config', return_value=cfg), patch('liblore.node._get_config_from_git', return_value={}), ): return LoreNode.from_git_config(LOCAL) def test_defaults(self) -> None: node = self.from_config({}) assert node._follow_upstream is True assert node._trusted_upstreams == {'https://lore.kernel.org'} @pytest.mark.parametrize('value', ['false', 'no', 'off', '0']) def test_followupstream_off(self, value: str) -> None: assert self.from_config({'followupstream': value})._follow_upstream is False def test_invalid_followupstream_keeps_default(self) -> None: node = self.from_config({'followupstream': 'maybe'}) assert node._follow_upstream is True def test_allowupstream(self) -> None: node = self.from_config( {'allowupstream': ['https://a.example.com', 'http://b.example.com:8080']} ) assert node._trusted_upstreams == { 'https://lore.kernel.org', 'https://a.example.com', 'http://b.example.com:8080', } def test_multivalued_key_requested(self) -> None: with ( patch('liblore.node._get_subsection_config', return_value={}) as sub, patch('liblore.node._get_config_from_git', return_value={}), ): LoreNode.from_git_config(LOCAL) assert 'allowupstream' in sub.call_args.kwargs['multivals'] @pytest.mark.parametrize( ('value', 'mode'), [('local', 'local'), ('upstream', 'upstream'), (' UPSTREAM ', 'upstream')], ) def test_partialsearch(self, value: str, mode: str) -> None: assert self.from_config({'partialsearch': value})._partial_search == mode def test_partialsearch_default(self) -> None: assert self.from_config({})._partial_search == 'local' def test_invalid_partialsearch_keeps_default(self) -> None: node = self.from_config({'partialsearch': 'sideways'}) assert node._partial_search == 'local' @pytest.mark.parametrize(('value', 'holdoff'), [('60', 60.0), (' 0 ', 0.0)]) def test_upstreamholdoff(self, value: str, holdoff: float) -> None: node = self.from_config({'upstreamholdoff': value}) assert node._upstream_holdoff == holdoff def test_upstreamholdoff_default(self) -> None: assert self.from_config({})._upstream_holdoff == 300.0 @pytest.mark.parametrize('value', ['soon', '-5', 'nan']) def test_invalid_upstreamholdoff_keeps_default(self, value: str) -> None: node = self.from_config({'upstreamholdoff': value}) assert node._upstream_holdoff == 300.0 @pytest.mark.parametrize('value', [-1.0, float('nan')]) def test_invalid_upstream_holdoff_argument(self, value: float) -> None: with pytest.raises(LibloreError, match='upstream_holdoff'): LoreNode(LOCAL, upstream_holdoff=value) def test_invalid_partial_search_argument(self) -> None: with pytest.raises(LibloreError, match='partial_search'): LoreNode(LOCAL, partial_search='sideways') def test_explicit_kwargs_win(self) -> None: cfg: dict[str, str | list[str]] = { 'followupstream': 'false', 'allowupstream': ['https://a.example.com'], } with ( patch('liblore.node._get_subsection_config', return_value=cfg), patch('liblore.node._get_config_from_git', return_value={}), ): node = LoreNode.from_git_config( LOCAL, follow_upstream=True, allowed_upstreams=[] ) assert node._follow_upstream is True assert node._trusted_upstreams == {'https://lore.kernel.org'} # ===================================================================== # L2 + L7: the upstream node and its lifecycle # ===================================================================== # What the user's [lore] section says about lore.kernel.org. LORE_CFG: dict[str, str | list[str]] = { 'fallback': ['https://tor.example.com'], 'useragentplus': 'secret', } class TestUpstreamNode: @pytest.fixture(autouse=True) def _git_config(self) -> Iterator[None]: # pyright: ignore[reportUnusedFunction] with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value=LORE_CFG), ): yield def partial( self, tmp_path: Path, *, follow_upstream: bool = True, allowed_upstreams: list[str] | None = None, ) -> LoreNode: """Return a node that has learned it is a partial mirror.""" node = LoreNode( LOCAL, cache_dir=str(tmp_path), cache_ttl=60, follow_upstream=follow_upstream, allowed_upstreams=allowed_upstreams, ) fetch(node, headers=PARTIAL) return node def upstream(self, node: LoreNode) -> LoreNode: up = node._get_upstream_node() assert up is not None return up def test_none_for_full_node(self, tmp_path: Path) -> None: node = LoreNode(LOCAL, cache_dir=str(tmp_path)) fetch(node) assert node._get_upstream_node() is None def test_none_when_not_following(self, tmp_path: Path) -> None: assert ( self.partial(tmp_path, follow_upstream=False)._get_upstream_node() is None ) def test_built_lazily_and_reused(self, tmp_path: Path) -> None: node = self.partial(tmp_path) assert node._upstream_node is None up = self.upstream(node) assert up.url == UPSTREAM assert node._get_upstream_node() is up def test_one_hop_only(self, tmp_path: Path) -> None: up = self.upstream(self.partial(tmp_path)) assert up._follow_upstream is False def test_shares_cache_settings(self, tmp_path: Path) -> None: up = self.upstream(self.partial(tmp_path)) assert up._cache_dir == str(tmp_path) assert up._cache_ttl == 60 def test_no_auth_headers(self, tmp_path: Path) -> None: # This node authenticates what it returns; doing it twice would # add a second Authentication-Results header. node = self.partial(tmp_path) with patch.object( LoreNode, 'from_git_config', wraps=LoreNode.from_git_config ) as build: up = self.upstream(node) assert build.call_args.kwargs['add_auth_headers'] is False assert up._authenticate_message is None def test_uses_git_config_for_upstream(self, tmp_path: Path) -> None: up = self.upstream(self.partial(tmp_path)) assert 'https://tor.example.com' in up._all_origins def test_shares_injected_session(self, tmp_path: Path) -> None: node = self.partial(tmp_path) session = requests.Session() node.set_requests_session(session) up = self.upstream(node) assert up._session is session assert up._owns_session is False def test_session_injected_later_is_shared(self, tmp_path: Path) -> None: node = self.partial(tmp_path) up = self.upstream(node) session = requests.Session() node.set_requests_session(session) assert up._session is session def test_own_session_not_shared(self, tmp_path: Path) -> None: node = self.partial(tmp_path) node._get_session() up = self.upstream(node) assert up._session is None def test_user_agent_without_plus(self, tmp_path: Path) -> None: node = self.partial(tmp_path) node.set_user_agent('b4', '1.0', 'mine') up = self.upstream(node) # The upstream's own useragentplus from git config, never ours. assert up._user_agent == 'b4/1.0+secret' assert node._user_agent == 'b4/1.0+mine' def test_user_agent_set_later(self, tmp_path: Path) -> None: node = self.partial(tmp_path) up = self.upstream(node) node.set_user_agent('b4', '2.0', 'mine') assert up._user_agent == 'b4/2.0+secret' def test_new_url_keeps_old_node(self, tmp_path: Path) -> None: node = self.partial(tmp_path, allowed_upstreams=['https://mirror.example.com']) old = self.upstream(node) fetch(node, headers=OTHER) new = self.upstream(node) assert new is not old assert new.url == 'https://mirror.example.com/all' assert node._upstream_nodes() == [old, new] def test_cancel_active_propagates(self, tmp_path: Path) -> None: node = self.partial(tmp_path, allowed_upstreams=['https://mirror.example.com']) old = self.upstream(node) fetch(node, headers=OTHER) new = self.upstream(node) gens = (old._cancel_generation, new._cancel_generation) node.cancel_active() assert old._cancel_generation == gens[0] + 1 assert new._cancel_generation == gens[1] + 1 assert not new.is_shutdown def test_shutdown_propagates(self, tmp_path: Path) -> None: node = self.partial(tmp_path, allowed_upstreams=['https://mirror.example.com']) old = self.upstream(node) fetch(node, headers=OTHER) new = self.upstream(node) node.shutdown() assert old.is_shutdown assert new.is_shutdown def test_no_upstream_after_shutdown(self, tmp_path: Path) -> None: node = self.partial(tmp_path) node.shutdown() assert node._get_upstream_node() is None assert node._upstream_node is None def test_shutdown_during_build(self, tmp_path: Path) -> None: node = self.partial(tmp_path) real = LoreNode.from_git_config def racing(url: str, **kwargs: Unpack[_LoreNodeInitKwargs]) -> LoreNode: built = real(url, **kwargs) node.shutdown() return built with patch.object(LoreNode, 'from_git_config', side_effect=racing): up = node._get_upstream_node() assert up is not None assert up.is_shutdown def test_close_closes_all(self, tmp_path: Path) -> None: node = self.partial(tmp_path, allowed_upstreams=['https://mirror.example.com']) old = self.upstream(node) fetch(node, headers=OTHER) new = self.upstream(node) with ( patch.object(old, 'close') as old_close, patch.object(new, 'close') as new_close, ): node.close() old_close.assert_called_once_with() new_close.assert_called_once_with() assert node._upstream_nodes() == [] def test_context_manager_closes_upstream(self, tmp_path: Path) -> None: with self.partial(tmp_path) as node: up = self.upstream(node) up._get_session() assert up._session is None def test_rebuilt_after_close(self, tmp_path: Path) -> None: node = self.partial(tmp_path) old = self.upstream(node) node.close() assert self.upstream(node) is not old def test_built_once_across_threads(self, tmp_path: Path) -> None: node = self.partial(tmp_path) barrier = threading.Barrier(8) found: list[LoreNode | None] = [] def worker() -> None: barrier.wait() found.append(node._get_upstream_node()) threads = [threading.Thread(target=worker) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() assert len(found) == 8 assert len({id(up) for up in found}) == 1 assert node._upstream_nodes() == [found[0]] def test_build_does_not_block_cancel(self, tmp_path: Path) -> None: # The build runs git; a cancel (from a signal handler, say) # must not wait for it. node = self.partial(tmp_path) building = threading.Event() release = threading.Event() real_from_git_config = LoreNode.from_git_config def slow_from_git_config( url: str, **kwargs: Unpack[_LoreNodeInitKwargs] ) -> LoreNode: building.set() assert release.wait(5) return real_from_git_config(url, **kwargs) builder = threading.Thread(target=node._get_upstream_node) with patch.object(LoreNode, 'from_git_config', slow_from_git_config): builder.start() try: assert building.wait(5) cancelled = threading.Thread(target=node.cancel_active) cancelled.start() cancelled.join(2) assert not cancelled.is_alive(), 'cancel_active() waited for the build' finally: release.set() builder.join(5) assert node._upstream_node is not None def test_build_failure_remembered(self, tmp_path: Path) -> None: # Bad git config for the upstream: say so every time, but read # the config once. node = self.partial(tmp_path) boom = LibloreError('bad config') with patch.object( LoreNode, 'from_git_config', side_effect=boom ) as from_git_config: for _ in range(2): with pytest.raises(LibloreError, match='bad config'): node._get_upstream_node() assert from_git_config.call_count == 1 # A miss then fails the same way, and the user sees why. with responses.RequestsMock() as rsps: rsps.add( responses.GET, f'{LOCAL}/x%40example.com/t.mbox.gz', status=404, headers=PARTIAL, ) with pytest.raises(LibloreError, match='bad config'): node.get_mbox_by_msgid('x@example.com') # ===================================================================== # L3 + L9: a local miss goes upstream # ===================================================================== MSGID = 'first@example.com' Q = 'first%40example.com' LOCAL_MBOX = f'{LOCAL}/{Q}/t.mbox.gz' UP_MBOX = f'{UPSTREAM}/{Q}/t.mbox.gz' LOCAL_RAW = f'{LOCAL}/{Q}/raw' UP_RAW = f'{UPSTREAM}/{Q}/raw' LOCAL_QUERY = f'{LOCAL}/?x=m&q=test' UP_QUERY = f'{UPSTREAM}/?x=m&q=test' SINCE = datetime(2024, 1, 15, 12, 0, tzinfo=timezone.utc) LOCAL_SINCE = f'{LOCAL}/{Q}/?x=m&q=rt%3A1705320000..' UP_SINCE = f'{UPSTREAM}/{Q}/?x=m&q=rt%3A1705320000..' # The HEAD redirect lookup of a full node: bare origin, no base path. REDIRECT_HEAD = f'http://127.0.0.1:11043/{Q}/' LOCAL_MSG = ( b'From x Mon Jan 1 00:00:00 2024\n' b'From: Carol \n' b'Subject: Re: First message\n' b'Message-Id: \n' b'In-Reply-To: \n' b'\n' b'Hello.\n' ) def gz(data: bytes) -> bytes: return gzip.compress(data) def urls(rsps: responses.RequestsMock) -> list[tuple[str, str | None]]: """Every request sent, as ``(method, url)``, in order.""" return [(c.request.method or '', c.request.url) for c in rsps.calls] class PartialNode: """Shared setup: no git config, and a node on the local mirror.""" @pytest.fixture(autouse=True) def _git_config(self) -> Iterator[None]: # pyright: ignore[reportUnusedFunction] with ( patch('liblore.node._get_subsection_config', return_value={}), patch('liblore.node._get_config_from_git', return_value={}), ): yield def node( self, tmp_path: Path, *, learned: bool = True, follow_upstream: bool = True, partial_search: str = 'local', upstream_holdoff: float = 300.0, ) -> LoreNode: """Return a node, by default one that knows it is partial.""" node = LoreNode( LOCAL, cache_dir=str(tmp_path), follow_upstream=follow_upstream, partial_search=partial_search, upstream_holdoff=upstream_holdoff, ) if learned: fetch(node, headers=PARTIAL) return node class TestMissRules(PartialNode): # --- get_mbox_by_msgid / get_thread_by_msgid --------------------- def test_mbox_hit_is_local(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=PARTIAL) assert node.get_mbox_by_msgid(MSGID) == sample_mbox assert urls(rsps) == [('GET', LOCAL_MBOX)] def test_mbox_miss_goes_upstream(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) assert node.get_mbox_by_msgid(MSGID) == sample_mbox # No HEAD redirect lookup anywhere (L9). assert urls(rsps) == [('GET', LOCAL_MBOX), ('GET', UP_MBOX)] def test_mbox_miss_learned_from_same_answer( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) assert node.get_mbox_by_msgid(MSGID) == sample_mbox assert urls(rsps) == [('GET', LOCAL_MBOX), ('GET', UP_MBOX)] def test_mbox_upstream_answer_cached_upstream_only( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) # Local is asked again (the mirror may have it by now), and # the upstream node answers from its own cache. assert node.get_mbox_by_msgid(MSGID) == sample_mbox assert urls(rsps) == [ ('GET', LOCAL_MBOX), ('GET', UP_MBOX), ('GET', LOCAL_MBOX), ] def test_mbox_nocache_reaches_upstream( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) node.get_mbox_by_msgid(MSGID, nocache=True) assert urls(rsps).count(('GET', UP_MBOX)) == 2 def test_mbox_upstream_404_is_final(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, status=404) # The upstream is a full node: it does its own redirect # lookup, as it would for any other caller. rsps.add(responses.HEAD, f'https://lore.kernel.org/{Q}/', status=404) with pytest.raises(RemoteError) as exc: node.get_mbox_by_msgid(MSGID) assert exc.value.status_code == 404 assert not isinstance(exc.value, NotOnMirrorError) assert len(rsps.calls) == 3 def test_full_node_keeps_redirect_lookup(self, tmp_path: Path) -> None: node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404) rsps.add(responses.HEAD, REDIRECT_HEAD, status=404) with pytest.raises(RemoteError): node.get_mbox_by_msgid(MSGID) assert urls(rsps) == [('GET', LOCAL_MBOX), ('HEAD', REDIRECT_HEAD)] def test_not_following_keeps_redirect_lookup(self, tmp_path: Path) -> None: node = self.node(tmp_path, learned=False, follow_upstream=False) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.HEAD, REDIRECT_HEAD, status=404) with pytest.raises(RemoteError): node.get_mbox_by_msgid(MSGID) assert urls(rsps) == [('GET', LOCAL_MBOX), ('HEAD', REDIRECT_HEAD)] def test_thread_miss_goes_upstream( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) msgs = node.get_thread_by_msgid(MSGID) assert [m['Message-Id'] for m in msgs] == [ '', '', ] def test_batch_per_item(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) other = f'{LOCAL}/second%40example.com/t.mbox.gz' with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) rsps.add(responses.GET, other, body=gz(sample_mbox), headers=PARTIAL) with patch('liblore.node.time.sleep'): results = node.batch_get_thread_by_msgid( [MSGID, 'second@example.com'], strict=False ) assert len(results) == 2 assert urls(rsps) == [('GET', LOCAL_MBOX), ('GET', UP_MBOX), ('GET', other)] def test_no_fallback_after_cancel(self, tmp_path: Path) -> None: node = self.node(tmp_path) def cancel_then_404( _req: object, ) -> tuple[int, dict[str, str], bytes]: node.cancel_active() return 404, PARTIAL, b'' with responses.RequestsMock() as rsps: rsps.add_callback(responses.GET, LOCAL_MBOX, callback=cancel_then_404) with pytest.raises(OperationCancelledError): node.get_mbox_by_msgid(MSGID) assert urls(rsps) == [('GET', LOCAL_MBOX)] def test_upstream_uses_its_fallbacks( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) tor = 'https://tor.lore.kernel.org/all/first%40example.com/t.mbox.gz' cfg: dict[str, str | list[str]] = {'fallback': ['https://tor.lore.kernel.org']} with ( patch('liblore.node._get_config_from_git', return_value=cfg), responses.RequestsMock() as rsps, ): rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, tor, status=503) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) assert node.get_mbox_by_msgid(MSGID) == sample_mbox assert urls(rsps) == [('GET', LOCAL_MBOX), ('GET', tor), ('GET', UP_MBOX)] def test_one_hop_only(self, tmp_path: Path) -> None: # The upstream says it is partial too, and names a trusted # upstream of its own: that one is not asked. node = self.node(tmp_path) chained = {**PARTIAL, 'X-Archive-Upstream': 'https://lore.kernel.org/lkml'} with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, status=404, headers=chained) rsps.add(responses.HEAD, f'https://lore.kernel.org/{Q}/', status=404) with pytest.raises(RemoteError) as exc: node.get_mbox_by_msgid(MSGID) assert exc.value.status_code == 404 assert len(rsps.calls) == 3 # --- get_message_by_msgid ---------------------------------------- def test_raw_hit_is_local(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_RAW, body=b'local', headers=PARTIAL) assert node.get_message_by_msgid(MSGID) == b'local' assert len(rsps.calls) == 1 def test_raw_miss_goes_upstream(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_RAW, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_RAW, body=b'upstream') assert node.get_message_by_msgid(MSGID) == b'upstream' assert urls(rsps) == [('GET', LOCAL_RAW), ('GET', UP_RAW)] def test_raw_upstream_404_is_final(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_RAW, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_RAW, status=404) with pytest.raises(RemoteError) as exc: node.get_message_by_msgid(MSGID) assert exc.value.status_code == 404 assert not isinstance(exc.value, NotOnMirrorError) def test_raw_other_4xx_not_a_miss(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_RAW, status=403, headers=PARTIAL) with pytest.raises(RemoteError) as exc: node.get_message_by_msgid(MSGID) assert exc.value.status_code == 403 assert len(rsps.calls) == 1 # --- get_mbox_by_query ------------------------------------------- def test_search_goes_upstream_first( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path, partial_search='upstream') with responses.RequestsMock() as rsps: rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps) == [('POST', UP_QUERY)] def test_search_upstream_404_is_final(self, tmp_path: Path) -> None: # lore.kernel.org answers "no results" with a 404, not an empty 200. node = self.node(tmp_path, partial_search='upstream') with responses.RequestsMock() as rsps: rsps.add(responses.POST, UP_QUERY, status=404) with pytest.raises(RemoteError) as exc: node.get_mbox_by_query('test') assert exc.value.status_code == 404 assert not isinstance(exc.value, NotOnMirrorError) assert urls(rsps) == [('POST', UP_QUERY)] def test_search_learned_from_same_answer( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path, learned=False, partial_search='upstream') with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(b'partial'), headers=PARTIAL) rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps) == [('POST', LOCAL_QUERY), ('POST', UP_QUERY)] # The mirror's partial answer was not cached. key = node._cache_key('mbox_by_query', 'test', 'False') assert node._cache_read(key) is None def test_search_default_is_local_first( self, tmp_path: Path, sample_mbox: bytes ) -> None: # The mirror answers without the upstream archive, which is the # point of having a mirror: fast, and working while lore is down. node = LoreNode(LOCAL, cache_dir=str(tmp_path)) fetch(node, headers=PARTIAL) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps) == [('POST', LOCAL_QUERY)] def test_search_local_mode_hit(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path, partial_search='local') with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps) == [('POST', LOCAL_QUERY)] @pytest.mark.parametrize(('status', 'body'), [(404, b''), (200, gz(b''))]) def test_search_local_mode_miss( self, tmp_path: Path, sample_mbox: bytes, status: int, body: bytes ) -> None: node = self.node(tmp_path, partial_search='local') with responses.RequestsMock() as rsps: rsps.add( responses.POST, LOCAL_QUERY, status=status, body=body, headers=PARTIAL ) rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps) == [('POST', LOCAL_QUERY), ('POST', UP_QUERY)] def test_thread_by_query_upstream(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) msgs = node.get_thread_by_query('test') assert len(msgs) == 2 # --- _fetch_thread_since: the known-thread check ----------------- def test_known_thread_searched_locally( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, headers=FRESH) rsps.add(responses.POST, LOCAL_SINCE, body=gz(sample_mbox), headers=PARTIAL) msgs = node.get_thread_updates_since(MSGID, SINCE) assert len(msgs) == 2 # The check comes before the search. assert urls(rsps) == [('HEAD', LOCAL_RAW), ('POST', LOCAL_SINCE)] def test_known_thread_via_redirect( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add( responses.HEAD, LOCAL_RAW, status=302, headers={**FRESH, 'Location': f'{LOCAL}/elsewhere/raw'}, ) rsps.add(responses.POST, LOCAL_SINCE, body=gz(sample_mbox), headers=PARTIAL) assert len(node.get_thread_updates_since(MSGID, SINCE)) == 2 # The redirect was not followed. assert urls(rsps) == [('HEAD', LOCAL_RAW), ('POST', LOCAL_SINCE)] @pytest.mark.parametrize(('status', 'body'), [(404, b''), (200, gz(b''))]) def test_known_thread_nothing_new_is_final( self, tmp_path: Path, status: int, body: bytes ) -> None: # public-inbox answers 404 "No results found" for a known thread # with no hits. node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, headers=FRESH) rsps.add( responses.POST, LOCAL_SINCE, status=status, body=body, headers=PARTIAL ) assert node.get_thread_updates_since(MSGID, SINCE) == [] assert urls(rsps) == [('HEAD', LOCAL_RAW), ('POST', LOCAL_SINCE)] def test_unknown_thread_goes_upstream( self, tmp_path: Path, sample_mbox: bytes ) -> None: # For an unknown message-id public-inbox drops the thread limit # and searches the whole archive, so the mirror is never searched. node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, status=404, headers=PARTIAL) rsps.add(responses.POST, UP_SINCE, body=gz(sample_mbox)) msgs = node.get_thread_updates_since(MSGID, SINCE) assert len(msgs) == 2 assert urls(rsps) == [('HEAD', LOCAL_RAW), ('POST', UP_SINCE)] def test_unknown_thread_nothing_new_upstream(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, status=404, headers=PARTIAL) rsps.add(responses.POST, UP_SINCE, status=404) assert node.get_thread_updates_since(MSGID, SINCE) == [] def test_thread_by_msgid_since(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) up = f'{UPSTREAM}/{Q}/?x=m&q=dt%3A20240101..' with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, status=404, headers=PARTIAL) rsps.add(responses.POST, up, body=gz(sample_mbox)) msgs = node.get_thread_by_msgid(MSGID, since='20240101') assert len(msgs) == 2 @pytest.mark.parametrize('head_status', [200, 404]) def test_check_after_learning_from_search( self, tmp_path: Path, sample_mbox: bytes, head_status: int ) -> None: # A node that learns it is partial from the search answer itself # can't trust that answer without the check. node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_SINCE, body=gz(LOCAL_MSG), headers=PARTIAL) rsps.add(responses.HEAD, LOCAL_RAW, status=head_status, headers=FRESH) if head_status == 404: rsps.add(responses.POST, UP_SINCE, body=gz(sample_mbox)) msgs = node._fetch_thread_since(MSGID, 'rt:1705320000..') sent = urls(rsps) if head_status == 404: # Unknown here: the mirror's answer was a whole-archive search. assert len(msgs) == 2 assert sent[-1] == ('POST', UP_SINCE) else: assert [m['Message-Id'] for m in msgs] == [''] assert sent == [('POST', LOCAL_SINCE), ('HEAD', LOCAL_RAW)] def test_full_node_sends_no_check(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_SINCE, body=gz(sample_mbox)) assert len(node.get_thread_updates_since(MSGID, SINCE)) == 2 assert urls(rsps) == [('POST', LOCAL_SINCE)] # --- local only -------------------------------------------------- def test_validate_is_local_only(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, f'{LOCAL}/_/text/help/', status=404) with pytest.raises(RemoteError): node.validate() assert len(rsps.calls) == 1 # ===================================================================== # L4: unreachable nodes # ===================================================================== LOCAL_HOST = '127.0.0.1:11043' def add_down( rsps: responses.RequestsMock, method: str, url: str, status: int | None ) -> None: """Make *url* unreachable: a connection error, or a *status* 5xx.""" if status is None: rsps.add(method, url, body=requests.ConnectionError('Connection refused')) else: rsps.add(method, url, status=status) def warnings_about(caplog: pytest.LogCaptureFixture, text: str) -> list[str]: return [ r.getMessage() for r in caplog.records if r.levelno == logging.WARNING and text in r.getMessage() ] # A connection error (no status at all) and a 5xx both mean "unreachable". DOWN = pytest.mark.parametrize('status', [None, 503]) class TestUnreachable(PartialNode): # --- a local miss, and the upstream is unreachable --------------- @DOWN def test_mbox_miss_upstream_down(self, tmp_path: Path, status: int | None) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) add_down(rsps, responses.GET, UP_MBOX, status) with pytest.raises(NotOnMirrorError) as exc: node.get_mbox_by_msgid(MSGID) # Callers that catch RemoteError keep working. assert isinstance(exc.value, RemoteError) assert exc.value.status_code == status msg = str(exc.value) assert MSGID in msg assert LOCAL_HOST in msg assert 'lore.kernel.org' in msg @DOWN def test_raw_miss_upstream_down(self, tmp_path: Path, status: int | None) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_RAW, status=404, headers=PARTIAL) add_down(rsps, responses.GET, UP_RAW, status) with pytest.raises(NotOnMirrorError) as exc: node.get_message_by_msgid(MSGID) assert exc.value.status_code == status def test_thread_miss_upstream_down(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) add_down(rsps, responses.GET, UP_MBOX, None) with pytest.raises(NotOnMirrorError): node.get_thread_by_msgid(MSGID) # --- the local mirror is unreachable ----------------------------- @DOWN def test_mbox_local_down_uses_upstream( self, tmp_path: Path, sample_mbox: bytes, status: int | None ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: add_down(rsps, responses.GET, LOCAL_MBOX, status) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) assert node.get_mbox_by_msgid(MSGID) == sample_mbox assert urls(rsps) == [('GET', LOCAL_MBOX), ('GET', UP_MBOX)] @DOWN def test_raw_local_down_uses_upstream( self, tmp_path: Path, status: int | None ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: add_down(rsps, responses.GET, LOCAL_RAW, status) rsps.add(responses.GET, UP_RAW, body=LOCAL_MSG) assert node.get_message_by_msgid(MSGID) == LOCAL_MSG assert urls(rsps) == [('GET', LOCAL_RAW), ('GET', UP_RAW)] def test_local_down_warns_once( self, tmp_path: Path, sample_mbox: bytes, caplog: pytest.LogCaptureFixture ) -> None: node = self.node(tmp_path) with ( caplog.at_level(logging.WARNING, logger='liblore.node'), responses.RequestsMock() as rsps, ): add_down(rsps, responses.GET, LOCAL_MBOX, None) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) node.get_mbox_by_msgid(MSGID) # Local is tried every time: it may be back. assert urls(rsps).count(('GET', LOCAL_MBOX)) == 2 warned = warnings_about(caplog, 'unreachable') assert len(warned) == 1 assert LOCAL_HOST in warned[0] assert 'lore.kernel.org' in warned[0] def test_local_down_saved_upstream( self, tmp_path: Path, sample_mbox: bytes ) -> None: # An earlier run learned the upstream; a stopped mirror sends no # headers to learn from. self.node(tmp_path) node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: add_down(rsps, responses.GET, LOCAL_MBOX, None) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) assert node.get_mbox_by_msgid(MSGID) == sample_mbox @DOWN def test_local_down_no_upstream(self, tmp_path: Path, status: int | None) -> None: node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: add_down(rsps, responses.GET, LOCAL_MBOX, status) with pytest.raises(RemoteError) as exc: node.get_mbox_by_msgid(MSGID) assert urls(rsps) == [('GET', LOCAL_MBOX)] assert not isinstance(exc.value, NotOnMirrorError) assert exc.value.status_code == status def test_both_down(self, tmp_path: Path) -> None: # Nothing says the thread is missing locally, so this is no # NotOnMirrorError: the upstream's error is raised as it is. node = self.node(tmp_path) with responses.RequestsMock() as rsps: add_down(rsps, responses.GET, LOCAL_MBOX, None) add_down(rsps, responses.GET, UP_MBOX, 503) with pytest.raises(RemoteError) as exc: node.get_mbox_by_msgid(MSGID) assert not isinstance(exc.value, NotOnMirrorError) assert exc.value.status_code == 503 # --- whole-archive search ---------------------------------------- @DOWN def test_search_upstream_down_is_degraded( self, tmp_path: Path, sample_mbox: bytes, caplog: pytest.LogCaptureFixture, status: int | None, ) -> None: node = self.node(tmp_path, partial_search='upstream') with ( caplog.at_level(logging.WARNING, logger='liblore.node'), responses.RequestsMock() as rsps, ): add_down(rsps, responses.POST, UP_QUERY, status) rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps) == [('POST', UP_QUERY), ('POST', LOCAL_QUERY)] # The answer may be incomplete: never cached. key = node._cache_key('mbox_by_query', 'test', 'False') assert node._cache_read(key) is None assert len(warnings_about(caplog, 'incomplete')) == 1 def test_search_learned_then_upstream_down( self, tmp_path: Path, sample_mbox: bytes ) -> None: # The mirror's own answer says it is partial, and then the # upstream can't be reached: the answer is degraded, not cached. node = self.node(tmp_path, learned=False, partial_search='upstream') with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) add_down(rsps, responses.POST, UP_QUERY, None) assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps) == [('POST', LOCAL_QUERY), ('POST', UP_QUERY)] key = node._cache_key('mbox_by_query', 'test', 'False') assert node._cache_read(key) is None @pytest.mark.parametrize(('status', 'body'), [(404, b''), (200, gz(b''))]) def test_search_upstream_down_nothing_local( self, tmp_path: Path, caplog: pytest.LogCaptureFixture, status: int, body: bytes, ) -> None: # A partial mirror finding nothing says nothing about the archive. node = self.node(tmp_path) with ( caplog.at_level(logging.WARNING, logger='liblore.node'), responses.RequestsMock() as rsps, ): add_down(rsps, responses.POST, UP_QUERY, None) rsps.add( responses.POST, LOCAL_QUERY, status=status, body=body, headers=PARTIAL ) with pytest.raises(NotOnMirrorError) as exc: node.get_mbox_by_query('test') assert "No results for 'test'" in str(exc.value) # Nothing was returned, so nothing is "incomplete". assert not warnings_about(caplog, 'incomplete') def test_search_both_down(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: add_down(rsps, responses.POST, UP_QUERY, None) add_down(rsps, responses.POST, LOCAL_QUERY, None) with pytest.raises(RemoteError) as exc: node.get_mbox_by_query('test') assert not isinstance(exc.value, NotOnMirrorError) def test_search_cancel_is_not_degraded(self, tmp_path: Path) -> None: node = self.node(tmp_path, partial_search='upstream') def cancel_then_fail(_req: object) -> tuple[int, dict[str, str], bytes]: node.cancel_active() raise requests.ConnectionError('Connection aborted') with responses.RequestsMock() as rsps: rsps.add_callback(responses.POST, UP_QUERY, callback=cancel_then_fail) with pytest.raises(OperationCancelledError): node.get_mbox_by_query('test') # No local search after the cancel. assert urls(rsps) == [('POST', UP_QUERY)] @pytest.mark.parametrize(('status', 'body'), [(404, b''), (200, gz(b''))]) def test_search_local_mode_miss_upstream_down( self, tmp_path: Path, status: int, body: bytes ) -> None: node = self.node(tmp_path, partial_search='local') with responses.RequestsMock() as rsps: rsps.add( responses.POST, LOCAL_QUERY, status=status, body=body, headers=PARTIAL ) add_down(rsps, responses.POST, UP_QUERY, None) with pytest.raises(NotOnMirrorError): node.get_mbox_by_query('test') assert urls(rsps) == [('POST', LOCAL_QUERY), ('POST', UP_QUERY)] @DOWN def test_search_local_mode_local_down_uses_upstream( self, tmp_path: Path, sample_mbox: bytes, status: int | None ) -> None: node = self.node(tmp_path, partial_search='local') with responses.RequestsMock() as rsps: add_down(rsps, responses.POST, LOCAL_QUERY, status) rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps) == [('POST', LOCAL_QUERY), ('POST', UP_QUERY)] def test_search_local_mode_other_4xx_is_raised(self, tmp_path: Path) -> None: # Only "no results" is a miss; a bad query is a bad query anywhere. node = self.node(tmp_path, partial_search='local') with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, status=400, headers=PARTIAL) with pytest.raises(RemoteError) as exc: node.get_mbox_by_query('test') assert urls(rsps) == [('POST', LOCAL_QUERY)] assert exc.value.status_code == 400 # --- thread updates ---------------------------------------------- @DOWN def test_since_check_down_uses_upstream( self, tmp_path: Path, sample_mbox: bytes, status: int | None ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: add_down(rsps, responses.HEAD, LOCAL_RAW, status) rsps.add(responses.POST, UP_SINCE, body=gz(sample_mbox)) assert len(node.get_thread_updates_since(MSGID, SINCE)) == 2 assert urls(rsps) == [('HEAD', LOCAL_RAW), ('POST', UP_SINCE)] @DOWN def test_since_search_down_uses_upstream( self, tmp_path: Path, sample_mbox: bytes, status: int | None ) -> None: # The mirror knows the thread, then fails on the search itself. node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, headers=FRESH) add_down(rsps, responses.POST, LOCAL_SINCE, status) rsps.add(responses.POST, UP_SINCE, body=gz(sample_mbox)) assert len(node.get_thread_updates_since(MSGID, SINCE)) == 2 assert urls(rsps) == [ ('HEAD', LOCAL_RAW), ('POST', LOCAL_SINCE), ('POST', UP_SINCE), ] @DOWN def test_since_unknown_thread_upstream_down( self, tmp_path: Path, status: int | None ) -> None: # "Nothing new" would be a guess. node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, status=404, headers=PARTIAL) add_down(rsps, responses.POST, UP_SINCE, status) with pytest.raises(NotOnMirrorError) as exc: node.get_thread_updates_since(MSGID, SINCE) assert MSGID in str(exc.value) def test_since_both_down(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: add_down(rsps, responses.HEAD, LOCAL_RAW, None) add_down(rsps, responses.POST, UP_SINCE, None) with pytest.raises(RemoteError) as exc: node.get_thread_updates_since(MSGID, SINCE) assert not isinstance(exc.value, NotOnMirrorError) def test_full_node_since_5xx_is_nothing_new(self, tmp_path: Path) -> None: # Unchanged: a full node has always read a 5xx as "nothing new". node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_SINCE, status=503) assert node.get_thread_updates_since(MSGID, SINCE) == [] def test_full_node_since_connection_error_raises(self, tmp_path: Path) -> None: node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: add_down(rsps, responses.POST, LOCAL_SINCE, None) with pytest.raises(RemoteError) as exc: node.get_thread_updates_since(MSGID, SINCE) assert not isinstance(exc.value, NotOnMirrorError) # ===================================================================== # L13: upstream hold-off # ===================================================================== MIRROR_MBOX = f'https://mirror.example.com/all/{Q}/t.mbox.gz' class Clock: """time.monotonic(), moved forward by the test.""" def __init__(self) -> None: self.offset = 0.0 self._real = time.monotonic def __call__(self) -> float: return self._real() + self.offset def advance(self, seconds: float) -> None: self.offset += seconds def holdoff_warnings(caplog: pytest.LogCaptureFixture) -> list[str]: return warnings_about(caplog, 'not trying it again') class TestHoldOff(PartialNode): @pytest.fixture def clock(self) -> Iterator[Clock]: clock = Clock() with patch('liblore.node.time.monotonic', clock): yield clock def miss(self, rsps: responses.RequestsMock, status: int | None = None) -> None: """Register a local mbox miss, and an unreachable upstream.""" rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) add_down(rsps, responses.GET, UP_MBOX, status) @DOWN def test_two_misses_one_upstream_request( self, tmp_path: Path, clock: Clock, caplog: pytest.LogCaptureFixture, status: int | None, ) -> None: node = self.node(tmp_path) with ( caplog.at_level(logging.WARNING, logger='liblore.node'), responses.RequestsMock() as rsps, ): self.miss(rsps, status) with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) clock.advance(42) with pytest.raises(NotOnMirrorError) as exc: node.get_mbox_by_msgid(MSGID) assert urls(rsps).count(('GET', UP_MBOX)) == 1 assert 'lore.kernel.org was unreachable 42 s ago' in str(exc.value) assert 'could not be reached' not in str(exc.value) # Nothing answered the second time. assert exc.value.status_code is None warned = holdoff_warnings(caplog) assert len(warned) == 1 assert 'lore.kernel.org' in warned[0] assert '300 s' in warned[0] def test_mirror_still_asked_while_held_off(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: self.miss(rsps) for _ in range(2): with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) assert urls(rsps) == [ ('GET', LOCAL_MBOX), ('GET', UP_MBOX), ('GET', LOCAL_MBOX), ] def test_covers_every_method(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: self.miss(rsps) rsps.add(responses.GET, LOCAL_RAW, status=404, headers=PARTIAL) rsps.add(responses.POST, LOCAL_QUERY, status=404, headers=PARTIAL) rsps.add(responses.HEAD, LOCAL_RAW, status=404, headers=PARTIAL) with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) with pytest.raises(NotOnMirrorError, match='ago'): node.get_message_by_msgid(MSGID) with pytest.raises(NotOnMirrorError, match='ago'): node.get_mbox_by_query('test') with pytest.raises(NotOnMirrorError, match='ago'): node.get_thread_updates_since(MSGID, SINCE) upstream = [u for _, u in urls(rsps) if u and u.startswith(UPSTREAM)] assert upstream == [UP_MBOX] def test_upstream_first_search_held_off_is_degraded( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path, partial_search='upstream') with responses.RequestsMock() as rsps: self.miss(rsps) rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) assert node.get_mbox_by_query('test') == sample_mbox assert ('POST', UP_QUERY) not in urls(rsps) @pytest.mark.parametrize( ('what', 'method', 'local_url'), [ ('mbox', responses.GET, LOCAL_MBOX), ('raw', responses.GET, LOCAL_RAW), ('search', responses.POST, LOCAL_QUERY), ('since', responses.HEAD, LOCAL_RAW), ], ) def test_local_down_while_held_off( self, tmp_path: Path, what: str, method: str, local_url: str ) -> None: # Both down: the upstream's error, which now needs no request. node = self.node(tmp_path) with responses.RequestsMock() as rsps: self.miss(rsps) with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) rsps.remove(responses.GET, LOCAL_MBOX) add_down(rsps, method, local_url, None) calls: dict[str, Callable[[], object]] = { 'mbox': lambda: node.get_mbox_by_msgid(MSGID), 'raw': lambda: node.get_message_by_msgid(MSGID), 'search': lambda: node.get_mbox_by_query('test'), 'since': lambda: node.get_thread_updates_since(MSGID, SINCE), } with pytest.raises(RemoteError, match='ago') as exc: calls[what]() upstream = [u for _, u in urls(rsps) if u and u.startswith(UPSTREAM)] assert upstream == [UP_MBOX] assert not isinstance(exc.value, NotOnMirrorError) assert exc.value.status_code is None # Both failures are named: the hold-off alone would read as if # the mirror had answered. assert LOCAL_HOST in str(exc.value) assert 'unreachable' in str(exc.value) def test_asked_again_when_time_is_up( self, tmp_path: Path, clock: Clock, caplog: pytest.LogCaptureFixture ) -> None: node = self.node(tmp_path) with ( caplog.at_level(logging.WARNING, logger='liblore.node'), responses.RequestsMock() as rsps, ): self.miss(rsps) with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) clock.advance(300) with pytest.raises(NotOnMirrorError) as exc: node.get_mbox_by_msgid(MSGID) assert urls(rsps).count(('GET', UP_MBOX)) == 2 assert 'could not be reached' in str(exc.value) # Still unreachable: a new hold-off, with its own warning. assert len(holdoff_warnings(caplog)) == 2 def test_success_after_time_is_up( self, tmp_path: Path, clock: Clock, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) add_down(rsps, responses.GET, UP_MBOX, None) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) clock.advance(300) assert node.get_mbox_by_msgid(MSGID) == sample_mbox def in_flight( self, node: LoreNode, answer: Callable[[], tuple[int, dict[str, str], bytes]] ) -> Callable[[object], tuple[int, dict[str, str], bytes]]: """A callback for a request that another thread's miss overtakes. The other thread's upstream request fails, and starts a hold-off, before this one gets *answer*. """ def callback(_req: object) -> tuple[int, dict[str, str], bytes]: other = threading.Thread(target=self.raw_miss, args=(node,)) other.start() other.join() return answer() return callback @staticmethod def raw_miss(node: LoreNode) -> None: with pytest.raises(NotOnMirrorError): node.get_message_by_msgid(MSGID) @pytest.mark.parametrize('status', [200, 404]) def test_answer_in_flight_ends_holdoff( self, tmp_path: Path, sample_mbox: bytes, status: int ) -> None: # The upstream answers a request sent before another thread # started a hold-off: it is back, so stop holding off. A "not # found" is an answer too. node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) body = gz(sample_mbox) if status == 200 else b'' rsps.add_callback( responses.GET, UP_MBOX, callback=self.in_flight(node, lambda: (status, {}, body)), ) if status == 404: # The upstream's own redirect lookup. rsps.add(responses.HEAD, f'https://lore.kernel.org/{Q}/', status=404) rsps.add(responses.GET, LOCAL_RAW, status=404, headers=PARTIAL) add_down(rsps, responses.GET, UP_RAW, None) if status == 200: assert node.get_mbox_by_msgid(MSGID) == sample_mbox else: with pytest.raises(RemoteError): node.get_mbox_by_msgid(MSGID) self.raw_miss(node) assert urls(rsps).count(('GET', UP_RAW)) == 2 def test_failure_in_flight_warns_once( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: # Two requests fail at the same time: one hold-off, one warning. node = self.node(tmp_path) def fail() -> tuple[int, dict[str, str], bytes]: raise requests.ConnectionError('Connection refused') with ( caplog.at_level(logging.WARNING, logger='liblore.node'), responses.RequestsMock() as rsps, ): rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add_callback( responses.GET, UP_MBOX, callback=self.in_flight(node, fail) ) rsps.add(responses.GET, LOCAL_RAW, status=404, headers=PARTIAL) add_down(rsps, responses.GET, UP_RAW, None) with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) assert len(holdoff_warnings(caplog)) == 1 def test_real_answer_starts_no_holdoff(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, status=404) rsps.add(responses.HEAD, f'https://lore.kernel.org/{Q}/', status=404) for _ in range(2): with pytest.raises(RemoteError) as exc: node.get_mbox_by_msgid(MSGID) assert exc.value.status_code == 404 assert urls(rsps).count(('GET', UP_MBOX)) == 2 def test_cancel_starts_no_holdoff(self, tmp_path: Path) -> None: node = self.node(tmp_path) def cancel_then_fail(_req: object) -> tuple[int, dict[str, str], bytes]: node.cancel_active() raise requests.ConnectionError('Connection aborted') with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add_callback(responses.GET, UP_MBOX, callback=cancel_then_fail) add_down(rsps, responses.GET, UP_MBOX, None) with pytest.raises(OperationCancelledError): node.get_mbox_by_msgid(MSGID) with pytest.raises(NotOnMirrorError) as exc: node.get_mbox_by_msgid(MSGID) assert urls(rsps).count(('GET', UP_MBOX)) == 2 assert 'could not be reached' in str(exc.value) def test_zero_turns_it_off( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: node = self.node(tmp_path, upstream_holdoff=0) with ( caplog.at_level(logging.WARNING, logger='liblore.node'), responses.RequestsMock() as rsps, ): self.miss(rsps) for _ in range(2): with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) assert urls(rsps).count(('GET', UP_MBOX)) == 2 assert holdoff_warnings(caplog) == [] def test_new_upstream_is_not_held_off( self, tmp_path: Path, sample_mbox: bytes ) -> None: # The hold-off is for the upstream that failed, not its successor. node = LoreNode( LOCAL, cache_dir=str(tmp_path), allowed_upstreams=['https://mirror.example.com'], ) fetch(node, headers=PARTIAL) with responses.RequestsMock() as rsps: self.miss(rsps) with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) rsps.replace(responses.GET, LOCAL_MBOX, status=404, headers=OTHER) rsps.add(responses.GET, MIRROR_MBOX, body=gz(sample_mbox)) assert node.get_mbox_by_msgid(MSGID) == sample_mbox def test_new_node_starts_without_holdoff(self, tmp_path: Path) -> None: with responses.RequestsMock() as rsps: self.miss(rsps) for _ in range(2): node = self.node(tmp_path) with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID) assert urls(rsps).count(('GET', UP_MBOX)) == 2 # ===================================================================== # L5: provenance # ===================================================================== def source_of(node: LoreNode) -> Source | None: """Read ``last_source`` fresh, so mypy doesn't narrow it between calls.""" return node.last_source class TestProvenance(PartialNode): def test_exported(self) -> None: import liblore assert 'Source' in liblore.__all__ def test_none_before_first_call(self) -> None: assert source_of(LoreNode(LOCAL)) is None def test_full_node_is_local(self, tmp_path: Path, sample_mbox: bytes) -> None: # "Local" means "this node", even on lore.kernel.org itself. node = LoreNode(UPSTREAM, cache_dir=str(tmp_path)) with responses.RequestsMock() as rsps: rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) assert source_of(node) is Source.LOCAL def test_mbox_hit(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=PARTIAL) node.get_mbox_by_msgid(MSGID) assert source_of(node) is Source.LOCAL def test_mbox_miss(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) assert source_of(node) is Source.UPSTREAM def test_raw_miss(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_RAW, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_RAW, body=LOCAL_MSG) node.get_message_by_msgid(MSGID) assert source_of(node) is Source.UPSTREAM def test_local_down(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: add_down(rsps, responses.GET, LOCAL_MBOX, None) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) assert source_of(node) is Source.UPSTREAM def test_since_known_thread(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, headers=FRESH) rsps.add(responses.POST, LOCAL_SINCE, body=gz(sample_mbox), headers=PARTIAL) node.get_thread_updates_since(MSGID, SINCE) assert source_of(node) is Source.LOCAL def test_since_unknown_thread(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, status=404, headers=PARTIAL) rsps.add(responses.POST, UP_SINCE, body=gz(sample_mbox)) node.get_thread_updates_since(MSGID, SINCE) assert source_of(node) is Source.UPSTREAM def test_search_hit(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) node.get_mbox_by_query('test') assert source_of(node) is Source.LOCAL def test_search_miss(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, status=404, headers=PARTIAL) rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) node.get_mbox_by_query('test') assert source_of(node) is Source.UPSTREAM def test_upstream_first_search(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path, partial_search='upstream') with responses.RequestsMock() as rsps: rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) node.get_mbox_by_query('test') assert source_of(node) is Source.UPSTREAM def test_degraded_search(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path, partial_search='upstream') with responses.RequestsMock() as rsps: add_down(rsps, responses.POST, UP_QUERY, None) rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) node.get_mbox_by_query('test') assert source_of(node) is Source.DEGRADED def test_degraded_search_learned_from_same_answer( self, tmp_path: Path, sample_mbox: bytes ) -> None: # The search's own answer is the first to say the node is partial. node = self.node(tmp_path, learned=False, partial_search='upstream') with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) add_down(rsps, responses.POST, UP_QUERY, None) node.get_mbox_by_query('test') assert source_of(node) is Source.DEGRADED def test_none_after_error(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) add_down(rsps, responses.GET, UP_MBOX, None) node.get_mbox_by_msgid(MSGID) assert source_of(node) is Source.UPSTREAM with pytest.raises(NotOnMirrorError): node.get_mbox_by_msgid(MSGID, nocache=True) assert source_of(node) is None def test_none_after_refused_call(self, tmp_path: Path) -> None: node = self.node(tmp_path) assert source_of(node) is Source.LOCAL node.shutdown() with pytest.raises(OperationCancelledError): node.get_mbox_by_msgid(MSGID) assert source_of(node) is None def test_each_call_starts_over(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=PARTIAL) rsps.add(responses.GET, LOCAL_RAW, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_RAW, body=LOCAL_MSG) node.get_mbox_by_msgid(MSGID) node.get_message_by_msgid(MSGID) assert source_of(node) is Source.UPSTREAM # From this node's cache, with no request at all. node.get_mbox_by_msgid(MSGID) assert urls(rsps).count(('GET', LOCAL_MBOX)) == 1 assert source_of(node) is Source.LOCAL def test_upstream_cache_is_upstream( self, tmp_path: Path, sample_mbox: bytes ) -> None: # The upstream node caches its answer; this node asks locally # again, and then gets the upstream node's cached copy. node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) node.get_mbox_by_msgid(MSGID) assert urls(rsps).count(('GET', LOCAL_MBOX)) == 2 assert urls(rsps).count(('GET', UP_MBOX)) == 1 assert source_of(node) is Source.UPSTREAM @pytest.mark.parametrize('upstream_first', [True, False]) def test_batch_reports_the_worst( self, tmp_path: Path, sample_mbox: bytes, upstream_first: bool ) -> None: other = 'second@example.com' other_mbox = f'{LOCAL}/second%40example.com/t.mbox.gz' node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) rsps.add(responses.GET, other_mbox, body=gz(sample_mbox), headers=PARTIAL) msgids = [MSGID, other] if upstream_first else [other, MSGID] with patch('liblore.node.time.sleep'): node.batch_get_thread_by_msgid(msgids) assert source_of(node) is Source.UPSTREAM def test_batch_keeps_degraded(self, tmp_path: Path, sample_mbox: bytes) -> None: # An upstream answer after a degraded one doesn't hide it. node = self.node(tmp_path, partial_search='upstream', upstream_holdoff=0) with responses.RequestsMock() as rsps: add_down(rsps, responses.POST, UP_QUERY, None) rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) rsps.add(responses.POST, f'{UPSTREAM}/?x=m&q=other', body=gz(sample_mbox)) with patch('liblore.node.time.sleep'): node.batch_get_thread_by_query(['test', 'other']) assert source_of(node) is Source.DEGRADED def test_per_thread(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) seen: dict[str, Source | None] = {} local_done = threading.Event() upstream_done = threading.Event() def local() -> None: node.get_mbox_by_msgid('second@example.com') local_done.set() # Read after the other thread's upstream call has finished. upstream_done.wait(5) seen['local'] = node.last_source def upstream() -> None: local_done.wait(5) node.get_mbox_by_msgid(MSGID) upstream_done.set() seen['upstream'] = node.last_source with responses.RequestsMock() as rsps: rsps.add( responses.GET, f'{LOCAL}/second%40example.com/t.mbox.gz', body=gz(sample_mbox), headers=PARTIAL, ) rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) threads = [threading.Thread(target=f) for f in (local, upstream)] for t in threads: t.start() for t in threads: t.join() assert seen == {'local': Source.LOCAL, 'upstream': Source.UPSTREAM} # The node was learned partial on this thread, before either call. assert source_of(node) is Source.LOCAL # --- upstream_url ------------------------------------------------ def test_upstream_url_full_node(self, tmp_path: Path) -> None: assert self.node(tmp_path, learned=False).upstream_url is None def test_upstream_url_learned(self, tmp_path: Path) -> None: assert self.node(tmp_path).upstream_url == UPSTREAM def test_upstream_url_saved(self, tmp_path: Path) -> None: self.node(tmp_path) assert self.node(tmp_path, learned=False).upstream_url == UPSTREAM def test_upstream_url_not_followed(self, tmp_path: Path) -> None: node = self.node(tmp_path, follow_upstream=False) assert node.upstream_url is None # ===================================================================== # L6: cache rules # ===================================================================== def cached(node: LoreNode, namespace: str, *parts: str) -> bool: """Whether *node* has a fresh cache entry for these key parts.""" return node._cache_read(node._cache_key(namespace, *parts)) is not None class TestCacheRules(PartialNode): def upstream(self, node: LoreNode) -> LoreNode: up = node._get_upstream_node() assert up is not None return up def test_local_answer_cached_here(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=PARTIAL) node.get_mbox_by_msgid(MSGID) assert cached(node, 'mbox_by_msgid', MSGID) assert not cached(self.upstream(node), 'mbox_by_msgid', MSGID) def test_mbox_upstream_answer_cached_upstream( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) assert not cached(node, 'mbox_by_msgid', MSGID) assert cached(self.upstream(node), 'mbox_by_msgid', MSGID) def test_raw_upstream_answer_cached_upstream(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_RAW, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_RAW, body=LOCAL_MSG) node.get_message_by_msgid(MSGID) # Local is asked again; the upstream node answers from its # own cache. assert node.get_message_by_msgid(MSGID) == LOCAL_MSG assert urls(rsps) == [ ('GET', LOCAL_RAW), ('GET', UP_RAW), ('GET', LOCAL_RAW), ] assert not cached(node, 'message_by_msgid', MSGID) assert cached(self.upstream(node), 'message_by_msgid', MSGID) def test_search_upstream_answer_cached_upstream( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, status=404, headers=PARTIAL) rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) node.get_mbox_by_query('test') assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps) == [ ('POST', LOCAL_QUERY), ('POST', UP_QUERY), ('POST', LOCAL_QUERY), ] assert not cached(node, 'mbox_by_query', 'test', 'False') assert cached(self.upstream(node), 'mbox_by_query', 'test', 'False') def test_mirror_catches_up(self, tmp_path: Path, sample_mbox: bytes) -> None: # The importer pulled the thread after the first call: the next # answer is local, and it is the one cached here. node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(b'from upstream')) node.get_mbox_by_msgid(MSGID) rsps.replace( responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=PARTIAL ) assert node.get_mbox_by_msgid(MSGID) == sample_mbox assert source_of(node) is Source.LOCAL # And from now on it comes from this node's cache. assert node.get_mbox_by_msgid(MSGID) == sample_mbox assert urls(rsps).count(('GET', LOCAL_MBOX)) == 2 assert source_of(node) is Source.LOCAL def test_degraded_cached_nowhere(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path, partial_search='upstream', upstream_holdoff=0) with responses.RequestsMock() as rsps: add_down(rsps, responses.POST, UP_QUERY, None) rsps.add(responses.POST, LOCAL_QUERY, body=gz(b'partial'), headers=PARTIAL) rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) assert node.get_mbox_by_query('test') == b'partial' assert not cached(node, 'mbox_by_query', 'test', 'False') assert not cached(self.upstream(node), 'mbox_by_query', 'test', 'False') # Once the upstream is back, it answers. assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps).count(('POST', UP_QUERY)) == 2 def test_clear_cache_clears_upstream( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) node.clear_cache() assert not cached(self.upstream(node), 'mbox_by_msgid', MSGID) # The saved upstream is not a cache entry. assert len(upstream_files(tmp_path)) == 1 assert node.upstream_url == UPSTREAM def test_clear_cache_clears_upstream_elsewhere( self, tmp_path: Path, sample_mbox: bytes ) -> None: # Guards a future where the upstream node has its own cache_dir, # or clear_cache removes only this node's entries. node = self.node(tmp_path / 'here') up = self.upstream(node) (tmp_path / 'there').mkdir() up._cache_dir = str(tmp_path / 'there') with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=PARTIAL) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) assert cached(up, 'mbox_by_msgid', MSGID) node.clear_cache() assert not cached(up, 'mbox_by_msgid', MSGID) def test_clear_cache_old_upstream_nodes(self, tmp_path: Path) -> None: # An upstream node replaced by a newer one may still hold entries. node = self.node(tmp_path / 'here') old = self.upstream(node) old._cache_dir = str(tmp_path / 'old') (tmp_path / 'old').mkdir() (tmp_path / 'old' / 'x.lore.cache').write_bytes(b'x') node._old_upstream_nodes.append(old) node._upstream_node = None node.clear_cache() assert not (tmp_path / 'old' / 'x.lore.cache').exists() # ===================================================================== # L10: freshness # ===================================================================== def as_of_of(node: LoreNode) -> float | None: """Read ``last_as_of`` fresh, as a Unix time.""" as_of = node.last_as_of return None if as_of is None else as_of.timestamp() def coverage(value: str) -> dict[str, str]: return {**PARTIAL, 'X-Archive-Coverage': value} class Timer: """The wall clock around a call, to check an answer's time.""" def __enter__(self) -> Self: self.start = time.time() return self def __exit__(self, *exc: object) -> None: self.end = time.time() def spans(self, when: float | None) -> bool: return when is not None and self.start <= when <= self.end class TestFreshness(PartialNode): def test_none_before_first_call(self) -> None: assert LoreNode(LOCAL).last_as_of is None def test_utc_datetime(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=FRESH) node.get_mbox_by_msgid(MSGID) assert node.last_as_of == datetime.fromtimestamp(UPDATED, timezone.utc) def test_full_node_is_request_time( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = LoreNode(UPSTREAM, cache_dir=str(tmp_path)) with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) assert timer.spans(as_of_of(node)) def test_partial_without_updated_is_request_time( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=PARTIAL) node.get_mbox_by_msgid(MSGID) assert timer.spans(as_of_of(node)) def test_raw_local(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_RAW, body=LOCAL_MSG, headers=FRESH) node.get_message_by_msgid(MSGID) assert as_of_of(node) == UPDATED def test_search_local(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=FRESH) node.get_mbox_by_query('test') assert as_of_of(node) == UPDATED # --- an upstream answer: the mirror's miss doesn't count --------- def test_mbox_upstream(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=FRESH) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) assert timer.spans(as_of_of(node)) def test_raw_upstream(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.GET, LOCAL_RAW, status=404, headers=FRESH) rsps.add(responses.GET, UP_RAW, body=LOCAL_MSG) node.get_message_by_msgid(MSGID) assert timer.spans(as_of_of(node)) def test_search_upstream(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.POST, LOCAL_QUERY, body=gz(b''), headers=FRESH) rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) node.get_mbox_by_query('test') assert timer.spans(as_of_of(node)) def test_search_upstream_first(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path, partial_search='upstream') with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) node.get_mbox_by_query('test') assert timer.spans(as_of_of(node)) def test_learned_then_upstream(self, tmp_path: Path, sample_mbox: bytes) -> None: # The mirror's own answer says it is partial, and is replaced # by the upstream's. node = self.node(tmp_path, learned=False, partial_search='upstream') with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.POST, LOCAL_QUERY, body=gz(b'partial'), headers=FRESH) rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) node.get_mbox_by_query('test') assert timer.spans(as_of_of(node)) # --- degraded answers ---------------------------------------------- def test_degraded(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path, partial_search='upstream') with responses.RequestsMock() as rsps: add_down(rsps, responses.POST, UP_QUERY, None) rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=FRESH) node.get_mbox_by_query('test') assert source_of(node) is Source.DEGRADED assert as_of_of(node) == UPDATED def test_degraded_without_updated(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path, partial_search='upstream') with responses.RequestsMock() as rsps, Timer() as timer: add_down(rsps, responses.POST, UP_QUERY, None) rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) node.get_mbox_by_query('test') assert timer.spans(as_of_of(node)) def test_degraded_learned_from_same_answer( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path, learned=False, partial_search='upstream') with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=FRESH) add_down(rsps, responses.POST, UP_QUERY, None) node.get_mbox_by_query('test') assert source_of(node) is Source.DEGRADED assert as_of_of(node) == UPDATED # --- thread updates -------------------------------------------------- def test_since_known_thread(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, headers=FRESH) rsps.add(responses.POST, LOCAL_SINCE, body=gz(sample_mbox), headers=FRESH) node.get_thread_updates_since(MSGID, SINCE) assert as_of_of(node) == UPDATED def test_since_nothing_new(self, tmp_path: Path) -> None: # The answer that matters most: "nothing new, as of updated". node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, headers=FRESH) rsps.add(responses.POST, LOCAL_SINCE, status=404, headers=FRESH) assert node.get_thread_updates_since(MSGID, SINCE) == [] assert as_of_of(node) == UPDATED def test_since_unknown_thread(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.HEAD, LOCAL_RAW, status=404, headers=FRESH) rsps.add(responses.POST, UP_SINCE, body=gz(sample_mbox)) node.get_thread_updates_since(MSGID, SINCE) assert timer.spans(as_of_of(node)) def test_since_full_node(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.POST, LOCAL_SINCE, body=gz(sample_mbox)) node.get_thread_updates_since(MSGID, SINCE) assert timer.spans(as_of_of(node)) def test_since_full_node_5xx(self, tmp_path: Path) -> None: # "Nothing new" because every origin failed: it says nothing # past since, so the next poll starts there again. node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_SINCE, status=503) assert node.get_thread_updates_since(MSGID, SINCE) == [] assert node.last_as_of == SINCE def test_thread_by_msgid_since_full_node_5xx(self, tmp_path: Path) -> None: # The dt: path has a since too; parse it so the answer says how # far back it reaches. node = self.node(tmp_path, learned=False) since = f'{LOCAL}/{Q}/?x=m&q=dt%3A20240115120000..' with responses.RequestsMock() as rsps: rsps.add(responses.POST, since, status=503) with pytest.raises(LookupError): node.get_thread_by_msgid(MSGID, since='20240115120000') # The operation raised, so nothing is published; a batch # that goes on reads it from the operation's state. with node._operation(): node._fetch_thread_since( MSGID, 'dt:20240115120000..', since=SINCE.timestamp() ) assert node._op_local.as_of == SINCE.timestamp() @pytest.mark.parametrize('bad', ['2024', '20240115', 'yesterday']) def test_loose_since_reaches_nowhere(self, bad: str) -> None: assert _date_string_epoch(bad) is None def test_since_string_is_utc(self) -> None: assert _date_string_epoch('20240115120000') == SINCE.timestamp() def test_thread_by_msgid_since(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) since = f'{LOCAL}/{Q}/?x=m&q=dt%3A20240115120000..' with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, headers=FRESH) rsps.add(responses.POST, since, body=gz(sample_mbox), headers=FRESH) node.get_thread_by_msgid(MSGID, since='20240115120000') assert as_of_of(node) == UPDATED # --- no updated time: the mirror's "nothing new" can't be trusted --- def test_no_updated_goes_upstream(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, headers=PARTIAL) rsps.add(responses.POST, UP_SINCE, body=gz(sample_mbox)) assert len(node.get_thread_updates_since(MSGID, SINCE)) == 2 # The mirror is not searched. assert urls(rsps) == [('HEAD', LOCAL_RAW), ('POST', UP_SINCE)] assert source_of(node) is Source.UPSTREAM def test_junk_updated_goes_upstream( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add( responses.HEAD, LOCAL_RAW, headers=coverage('partial; updated=soon') ) rsps.add(responses.POST, UP_SINCE, body=gz(sample_mbox)) node.get_thread_updates_since(MSGID, SINCE) assert urls(rsps) == [('HEAD', LOCAL_RAW), ('POST', UP_SINCE)] def test_no_updated_upstream_down(self, tmp_path: Path) -> None: # "Nothing new" from the mirror would be a guess. node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW, headers=PARTIAL) add_down(rsps, responses.POST, UP_SINCE, None) with pytest.raises(NotOnMirrorError): node.get_thread_updates_since(MSGID, SINCE) assert ('POST', LOCAL_SINCE) not in urls(rsps) def test_check_answered_by_full_node( self, tmp_path: Path, sample_mbox: bytes ) -> None: # The mirror is no longer partial: its answer is final, as on # any full node. node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.HEAD, LOCAL_RAW) rsps.add(responses.POST, LOCAL_SINCE, body=gz(sample_mbox)) assert len(node.get_thread_updates_since(MSGID, SINCE)) == 2 assert urls(rsps) == [('HEAD', LOCAL_RAW), ('POST', LOCAL_SINCE)] assert node.upstream_url is None # --- which updated values count -------------------------------------- @pytest.mark.parametrize( 'value', [ 'soon', '-5', '+5', '1.5', '', # Not ASCII digits, though int() reads them. '١٧٩', ], ) def test_junk_updated_ignored( self, tmp_path: Path, sample_mbox: bytes, value: str ) -> None: node = self.node(tmp_path) headers = coverage(f'partial; updated={value}') with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=headers) node.get_mbox_by_msgid(MSGID) assert timer.spans(as_of_of(node)) def test_far_future_ignored(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) future = int(time.time()) + 3600 headers = coverage(f'partial; updated={future}') with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.HEAD, LOCAL_RAW, headers=headers) rsps.add(responses.POST, UP_SINCE, body=gz(sample_mbox)) node.get_thread_updates_since(MSGID, SINCE) # Junk: the mirror can't be trusted with "nothing new". assert urls(rsps) == [('HEAD', LOCAL_RAW), ('POST', UP_SINCE)] assert timer.spans(as_of_of(node)) def test_near_future_is_skew(self, tmp_path: Path, sample_mbox: bytes) -> None: # A server clock a little ahead is accepted, but the answer # never reaches past the time we asked. node = self.node(tmp_path) headers = coverage(f'partial; updated={int(time.time()) + 200}') with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.HEAD, LOCAL_RAW, headers=headers) rsps.add(responses.POST, LOCAL_SINCE, body=gz(sample_mbox), headers=headers) node.get_thread_updates_since(MSGID, SINCE) assert ('POST', UP_SINCE) not in urls(rsps) assert timer.spans(as_of_of(node)) def test_names_ignore_case(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) headers = coverage(f'Partial; Updated={UPDATED}; coverage=/x') with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=headers) node.get_mbox_by_msgid(MSGID) assert as_of_of(node) == UPDATED def test_updated_needs_partial(self, tmp_path: Path, sample_mbox: bytes) -> None: node = LoreNode(LOCAL, cache_dir=str(tmp_path)) headers = coverage(f'full; updated={UPDATED}') with responses.RequestsMock() as rsps, Timer() as timer: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=headers) node.get_mbox_by_msgid(MSGID) assert timer.spans(as_of_of(node)) # --- operations ------------------------------------------------------ @pytest.mark.parametrize('older_first', [True, False]) def test_batch_reports_the_oldest( self, tmp_path: Path, sample_mbox: bytes, older_first: bool ) -> None: other = 'second@example.com' other_mbox = f'{LOCAL}/second%40example.com/t.mbox.gz' newer = coverage(f'partial; updated={UPDATED + 100}') node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=FRESH) rsps.add(responses.GET, other_mbox, body=gz(sample_mbox), headers=newer) msgids = [MSGID, other] if older_first else [other, MSGID] with patch('liblore.node.time.sleep'): node.batch_get_thread_by_msgid(msgids) assert as_of_of(node) == UPDATED def test_each_call_starts_over(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) newer = coverage(f'partial; updated={UPDATED + 100}') with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=FRESH) rsps.add(responses.GET, LOCAL_RAW, body=LOCAL_MSG, headers=newer) node.get_mbox_by_msgid(MSGID, nocache=True) assert as_of_of(node) == UPDATED node.get_message_by_msgid(MSGID, nocache=True) assert as_of_of(node) == UPDATED + 100 def test_none_after_error(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=FRESH) rsps.add(responses.GET, LOCAL_RAW, status=404, headers=FRESH) add_down(rsps, responses.GET, UP_RAW, None) node.get_mbox_by_msgid(MSGID) assert as_of_of(node) == UPDATED with pytest.raises(NotOnMirrorError): node.get_message_by_msgid(MSGID) assert as_of_of(node) is None def test_none_after_request(self, tmp_path: Path) -> None: node = self.node(tmp_path) fetch(node, headers=FRESH) assert node.last_as_of is None def test_cache_hit_is_mtime(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=FRESH) node.get_mbox_by_msgid(MSGID) path = tmp_path / f'{node._cache_key("mbox_by_msgid", MSGID)}.lore.cache' os.utime(path, (UPDATED + 50, UPDATED + 50)) with patch('liblore.node.time.time', return_value=UPDATED + 60): node.get_mbox_by_msgid(MSGID) assert as_of_of(node) == UPDATED + 50 def test_upstream_cache_hit_is_mtime( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, status=404, headers=FRESH) rsps.add(responses.GET, UP_MBOX, body=gz(sample_mbox)) node.get_mbox_by_msgid(MSGID) up = node._get_upstream_node() assert up is not None key = up._cache_key('mbox_by_msgid', MSGID) os.utime(tmp_path / f'{key}.lore.cache', (UPDATED + 50, UPDATED + 50)) with patch('liblore.node.time.time', return_value=UPDATED + 60): node.get_mbox_by_msgid(MSGID) assert urls(rsps).count(('GET', UP_MBOX)) == 1 assert as_of_of(node) == UPDATED + 50 def test_per_thread(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) seen: list[float | None] = [] def other() -> None: node.get_message_by_msgid(MSGID) seen.append(as_of_of(node)) newer = coverage(f'partial; updated={UPDATED + 100}') with responses.RequestsMock() as rsps: rsps.add(responses.GET, LOCAL_MBOX, body=gz(sample_mbox), headers=FRESH) rsps.add(responses.GET, LOCAL_RAW, body=LOCAL_MSG, headers=newer) node.get_mbox_by_msgid(MSGID) thread = threading.Thread(target=other) thread.start() thread.join() assert seen == [UPDATED + 100] assert as_of_of(node) == UPDATED class TestLocalSufficient(PartialNode): """get_mbox_by_query(local_sufficient=True): the local result is final.""" def test_hit_stays_local(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) assert node.get_mbox_by_query('test', local_sufficient=True) == sample_mbox assert urls(rsps) == [('POST', LOCAL_QUERY)] assert source_of(node) is Source.LOCAL assert cached(node, 'mbox_by_query', 'test', 'False') def test_404_is_final(self, tmp_path: Path) -> None: # The same answer a full node gives for "no results". node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, status=404, headers=PARTIAL) with pytest.raises(RemoteError) as exc: node.get_mbox_by_query('test', local_sufficient=True) assert urls(rsps) == [('POST', LOCAL_QUERY)] assert exc.value.status_code == 404 assert not isinstance(exc.value, NotOnMirrorError) def test_empty_is_final(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(b''), headers=PARTIAL) assert node.get_mbox_by_query('test', local_sufficient=True) == b'' assert urls(rsps) == [('POST', LOCAL_QUERY)] assert source_of(node) is Source.LOCAL def test_empty_is_not_cached(self, tmp_path: Path, sample_mbox: bytes) -> None: # A later call without local_sufficient must still ask the upstream # archive, and not find the sufficient empty answer in the cache. node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(b''), headers=PARTIAL) rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) node.get_mbox_by_query('test', local_sufficient=True) assert not cached(node, 'mbox_by_query', 'test', 'False') assert node.get_mbox_by_query('test') == sample_mbox assert urls(rsps) == [ ('POST', LOCAL_QUERY), ('POST', LOCAL_QUERY), ('POST', UP_QUERY), ] def test_upstream_first_is_ignored( self, tmp_path: Path, sample_mbox: bytes ) -> None: node = self.node(tmp_path, partial_search='upstream') with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox), headers=PARTIAL) assert node.get_mbox_by_query('test', local_sufficient=True) == sample_mbox assert urls(rsps) == [('POST', LOCAL_QUERY)] assert source_of(node) is Source.LOCAL # Upstream-first never caches the mirror's answer: a later # call without local_sufficient must not get it from the cache. assert not cached(node, 'mbox_by_query', 'test', 'False') def test_learned_from_same_answer(self, tmp_path: Path) -> None: node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, status=404, headers=PARTIAL) with pytest.raises(RemoteError): node.get_mbox_by_query('test', local_sufficient=True) assert urls(rsps) == [('POST', LOCAL_QUERY)] assert node.upstream_url == UPSTREAM @DOWN def test_mirror_down_uses_upstream( self, tmp_path: Path, sample_mbox: bytes, status: int | None ) -> None: # A sufficient local result needs the mirror to answer at all. node = self.node(tmp_path) with responses.RequestsMock() as rsps: add_down(rsps, responses.POST, LOCAL_QUERY, status) rsps.add(responses.POST, UP_QUERY, body=gz(sample_mbox)) assert node.get_mbox_by_query('test', local_sufficient=True) == sample_mbox assert urls(rsps) == [('POST', LOCAL_QUERY), ('POST', UP_QUERY)] assert source_of(node) is Source.UPSTREAM def test_bad_upstream_config_is_not_read(self, tmp_path: Path) -> None: # No fallback means no upstream node, so its git config can't fail. node = self.node(tmp_path) with ( patch.object(LoreNode, 'from_git_config', side_effect=LibloreError('bad')), responses.RequestsMock() as rsps, ): rsps.add(responses.POST, LOCAL_QUERY, body=gz(b''), headers=PARTIAL) assert node.get_mbox_by_query('test', local_sufficient=True) == b'' def test_thread_by_query(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, status=404, headers=PARTIAL) with pytest.raises(RemoteError): node.get_thread_by_query('test', local_sufficient=True) assert urls(rsps) == [('POST', LOCAL_QUERY)] def test_batch_thread_by_query(self, tmp_path: Path) -> None: node = self.node(tmp_path) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(b''), headers=PARTIAL) with pytest.raises(LookupError): node.batch_get_thread_by_query(['test'], local_sufficient=True) assert urls(rsps) == [('POST', LOCAL_QUERY)] def test_full_node_unchanged(self, tmp_path: Path, sample_mbox: bytes) -> None: node = self.node(tmp_path, learned=False) with responses.RequestsMock() as rsps: rsps.add(responses.POST, LOCAL_QUERY, body=gz(sample_mbox)) assert node.get_mbox_by_query('test', local_sufficient=True) == sample_mbox assert cached(node, 'mbox_by_query', 'test', 'False') python-liblore-0.10.1/tests/test_series.py000066400000000000000000001565561526032434100206270ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Tests for liblore.series.""" from __future__ import annotations import json from email.message import EmailMessage import pytest from conftest import MsgFactory from liblore.series import ( SCHEMA_VERSION, Identity, ThreadSummary, Trailer, email_matches, extract_trailers, group_by_thread, name_matches, parse_patch_subject, summarize_message, summarize_thread, summarize_threads, ) from liblore.utils import msg_get_author, msg_get_inbody_author DATE1 = 'Mon, 15 Sep 2026 10:00:00 +0000' DATE2 = 'Mon, 15 Sep 2026 11:00:00 +0000' DATE3 = 'Mon, 15 Sep 2026 12:00:00 +0000' # What turns a body into a real patch: the basement line plus a diff. PATCH_TAIL = ( '---\n' ' net/core.c | 2 +-\n' ' 1 file changed, 1 insertion(+), 1 deletion(-)\n' '--- a/net/core.c\n' '+++ b/net/core.c\n' ) class TestParsePatchSubject: def test_plain_subject(self) -> None: parsed = parse_patch_subject('Just a question about USB') assert parsed.is_patch is False assert parsed.title == 'Just a question about USB' assert parsed.version == 1 assert parsed.counter == 1 assert parsed.counters_inferred is True def test_lone_patch(self) -> None: parsed = parse_patch_subject('[PATCH] usb: fix the thing') assert parsed.is_patch is True assert parsed.title == 'usb: fix the thing' assert parsed.counter == 1 assert parsed.counters_inferred is True assert parsed.expected == 1 assert parsed.version == 1 def test_numbered_and_versioned(self) -> None: parsed = parse_patch_subject('[PATCH v3 2/7] usb: fix the thing') assert (parsed.counter, parsed.expected, parsed.version) == (2, 7, 3) assert parsed.title == 'usb: fix the thing' assert parsed.is_cover is False def test_cover_letter(self) -> None: parsed = parse_patch_subject('[PATCH v2 0/4] usb: a series') assert parsed.is_cover is True assert parsed.counter == 0 def test_extra_prefixes_preserved_in_order(self) -> None: parsed = parse_patch_subject('[RFC PATCH net-next v3 2/7] net: thing') assert parsed.prefixes == ('RFC', 'net-next') assert parsed.version == 3 assert parsed.is_patch is True def test_split_bracket_groups(self) -> None: parsed = parse_patch_subject('[RFC] [PATCH 1/2] subject here') assert parsed.is_patch is True assert parsed.prefixes == ('RFC',) assert (parsed.counter, parsed.expected) == (1, 2) assert parsed.title == 'subject here' def test_reply_is_flagged(self) -> None: parsed = parse_patch_subject('Re: [PATCH v3 2/7] usb: fix the thing') assert parsed.is_reply is True assert parsed.is_patch is True def test_nested_reply_marker(self) -> None: assert parse_patch_subject('Re: Re: [PATCH] x').is_reply is True assert parse_patch_subject('Re[2]: [PATCH] x').is_reply is True def test_spaced_counter(self) -> None: parsed = parse_patch_subject('[PATCH 2 / 7] spaced out') assert (parsed.counter, parsed.expected) == (2, 7) def test_version_is_case_insensitive(self) -> None: assert parse_patch_subject('[PATCH V4 1/2] x').version == 4 def test_resend_is_a_prefix_not_a_version(self) -> None: parsed = parse_patch_subject('[PATCH RESEND 1/3] x') assert parsed.prefixes == ('RESEND',) assert parsed.version == 1 def test_glued_version(self) -> None: # "[PATCHv3]" gets written without the space often enough that b4 # repairs it before tokenising; so do we. parsed = parse_patch_subject('[PATCHv3 2/7] usb: fix the thing') assert parsed.is_patch is True assert parsed.version == 3 assert parsed.counter == 2 assert parsed.expected == 7 assert parsed.prefixes == () def test_punctuation_between_chunks(self) -> None: parsed = parse_patch_subject('[PATCH, v2, 1/2] usb: fix the thing') assert parsed.version == 2 assert parsed.counter == 1 assert parsed.prefixes == () def test_foreign_reply_marker(self) -> None: # A reply marker in a language we do not know, sitting directly in # front of the bracket group. parsed = parse_patch_subject('Отв: [PATCH 1/2] usb: fix the thing') assert parsed.is_reply is True assert parsed.is_patch is True assert parsed.counter == 1 def test_subsystem_is_not_a_reply_marker(self) -> None: # "mm:" looks like a two-letter reply marker, but it is the # subsystem; with no bracket after it, it stays in the subject. parsed = parse_patch_subject('mm: fix the widget') assert parsed.is_reply is False assert parsed.title == 'mm: fix the widget' parsed = parse_patch_subject('Re: net: fix the socket') assert parsed.is_reply is True assert parsed.title == 'net: fix the socket' def test_oversized_counter_is_not_a_counter(self) -> None: # Bounded to four digits, so a date cannot pose as "n/m". parsed = parse_patch_subject('[PATCH 12345/67890] usb: fix the thing') assert parsed.counters_inferred is True assert parsed.prefixes == ('12345/67890',) def test_nested_brackets_collapse(self) -> None: parsed = parse_patch_subject('[[PATCH v2 1/2]] usb: fix the thing') assert parsed.is_patch is True assert parsed.version == 2 assert parsed.counter == 1 def test_unbalanced_bracket_is_not_a_prefix(self) -> None: parsed = parse_patch_subject('[PATCH v2 usb: fix the thing') assert parsed.is_patch is False assert parsed.title == '[PATCH v2 usb: fix the thing' class TestExtractTrailers: def test_column_zero_trailers_are_kept(self, make_msg: MsgFactory) -> None: msg = make_msg( body='Some text\n\nSigned-off-by: Alice \n' 'Reviewed-by: Bob \n' + PATCH_TAIL ) assert [(t.name, t.value) for t in extract_trailers(msg)] == [ ('Signed-off-by', 'Alice '), ('Reviewed-by', 'Bob '), ] def test_quoted_trailer_is_skipped(self, make_msg: MsgFactory) -> None: msg = make_msg(body='> Reviewed-by: Bob \n\nI agree.\n') assert extract_trailers(msg) == [] def test_indented_trailer_is_kept(self, make_msg: MsgFactory) -> None: # Some maintainers send their trailers with leading whitespace. msg = make_msg( from_addr=('Bob', 'bob@example.com'), body='Thanks!\n\n Acked-by: Bob \n', ) assert [(t.name, t.value) for t in extract_trailers(msg)] == [ ('Acked-by', 'Bob '), ] def test_diff_content_produces_no_trailers(self, make_msg: MsgFactory) -> None: # Everything below the "---" separator is the basement, so no # amount of diff content can produce a trailer. msg = make_msg( body=( 'usb: fix it\n\n' 'Signed-off-by: Alice \n' '---\n' ' drivers/usb/core.c | 2 +-\n' '--- a/drivers/usb/core.c\n' '+++ b/drivers/usb/core.c\n' '@@ -1,3 +1,3 @@\n' '-\tint foo: 1;\n' '+\tint bar: 2;\n' ' context: line\n' ) ) assert [t.name for t in extract_trailers(msg)] == ['Signed-off-by'] def test_commit_subject_is_not_a_trailer(self, make_msg: MsgFactory) -> None: # 62% of recent linux.git subjects match the bare trailer shape, # so the unhyphenated subsystem prefix must not be picked up. msg = make_msg( body='usb: fix the thing\n\nSome explanation.\n\n' 'Signed-off-by: Alice \n' + PATCH_TAIL ) assert [t.name for t in extract_trailers(msg)] == ['Signed-off-by'] def test_unhyphenated_trailers_are_kept(self, make_msg: MsgFactory) -> None: msg = make_msg( body=( 'net: fix it\n\n' 'Fixes: deadbeef1234 ("net: break it")\n' 'Cc: stable@vger.kernel.org\n' 'Link: https://lore.kernel.org/r/x@y\n' '---\n' ' net/core.c | 2 +-\n' ' 1 file changed, 1 insertion(+), 1 deletion(-)\n' '--- a/net/core.c\n' '+++ b/net/core.c\n' ) ) assert [t.name for t in extract_trailers(msg)] == ['Fixes', 'Cc', 'Link'] def test_trailer_needs_a_value(self, make_msg: MsgFactory) -> None: msg = make_msg(body='Reviewed-by:\nAcked-by: \n') assert extract_trailers(msg) == [] def test_key_is_lowercased(self, make_msg: MsgFactory) -> None: msg = make_msg( from_addr=('Bob', 'bob@example.com'), body='Reviewed-By: Bob \n', ) assert extract_trailers(msg)[0].key == 'reviewed-by' def test_wrapped_fixes_trailer_is_rejoined(self, make_msg: MsgFactory) -> None: # Mail clients love to wrap a long Fixes: line; b4 stitches it # back together before scanning. msg = make_msg( body=( 'usb: fix it\n\n' 'Fixes: 1234abcd5678 ("usb: introduce the thing\n' 'that later broke")\n' ) ) assert [(t.name, t.value) for t in extract_trailers(msg)] == [ ('Fixes', '1234abcd5678 ("usb: introduce the thing that later broke")'), ] def test_wrapped_signoff_address_is_rejoined(self, make_msg: MsgFactory) -> None: msg = make_msg( body=('usb: fix it\n\nSigned-off-by: Alice Longname\n\n') + PATCH_TAIL ) assert [(t.name, t.value) for t in extract_trailers(msg)] == [ ('Signed-off-by', 'Alice Longname '), ] def test_contact_details_are_not_trailers(self, make_msg: MsgFactory) -> None: msg = make_msg( body=( 'usb: fix it\n\n' 'Signed-off-by: Alice \n' 'Phone: +1 555 0100\n' 'E-mail: alice@example.com\n' ) + PATCH_TAIL ) assert [t.name for t in extract_trailers(msg)] == ['Signed-off-by'] def test_fixes_value_must_look_like_a_hash(self, make_msg: MsgFactory) -> None: msg = make_msg(body='usb: fix it\n\nFixes: the thing I broke earlier\n') assert extract_trailers(msg) == [] def test_extinfo_comment_is_stripped(self, make_msg: MsgFactory) -> None: # Stripping the comment also means the same trailer with and # without one deduplicates to a single entry. msg = make_msg( body=( 'usb: fix it\n\n' 'Cc: stable@vger.kernel.org # 5.10+\n' '---\n' ' net/core.c | 2 +-\n' ' 1 file changed, 1 insertion(+), 1 deletion(-)\n' '--- a/net/core.c\n' '+++ b/net/core.c\n' ) ) assert [(t.name, t.value) for t in extract_trailers(msg)] == [ ('Cc', 'stable@vger.kernel.org'), ] def test_extinfo_continuation_is_not_a_trailer(self, make_msg: MsgFactory) -> None: msg = make_msg( body=( 'usb: fix it\n\nAcked-by: Bob \n[for the usb bits]\n' ) + PATCH_TAIL ) assert [t.name for t in extract_trailers(msg)] == ['Acked-by'] def test_separator_without_a_diff_is_not_a_basement( self, make_msg: MsgFactory ) -> None: # A bare "---" in a free-form reply is just a visual separator, so # trailers below it still count. msg = make_msg( from_addr=('Bob', 'bob@example.com'), body='Looks good to me.\n---\nReviewed-by: Bob \n', ) assert [t.name for t in extract_trailers(msg)] == ['Reviewed-by'] def test_signature_block_is_ignored(self, make_msg: MsgFactory) -> None: msg = make_msg( from_addr=('Alice', 'alice@example.com'), body=( 'usb: fix it\n\n' 'Signed-off-by: Alice \n' '-- \n' 'Cc: not-a-real-trailer@example.com\n' ), ) assert [t.name for t in extract_trailers(msg)] == ['Signed-off-by'] def test_reply_trailer_mid_body_is_found(self, make_msg: MsgFactory) -> None: # Reviewers put their tag wherever they like, so a reply is # scanned whole rather than only in its last paragraph. msg = make_msg( from_addr=('Bob', 'bob@example.com'), body=( 'Looks good to me overall.\n\n' 'Reviewed-by: Bob \n\n' 'But please fix the typo in the comment.\n' ), ) assert [(t.name, t.value) for t in extract_trailers(msg)] == [ ('Reviewed-by', 'Bob '), ] def test_reply_quoted_trailer_still_skipped(self, make_msg: MsgFactory) -> None: # The quoted tag is Bob's own, so only the quoting excludes it. msg = make_msg( from_addr=('Bob', 'bob@example.com'), body=( 'On Mon, Alice wrote:\n' '> Reviewed-by: Bob \n\n' 'Acked-by: Bob \n' ), ) assert [t.name for t in extract_trailers(msg)] == ['Acked-by'] def test_reply_person_trailer_needs_an_address(self, make_msg: MsgFactory) -> None: msg = make_msg(body='Nice.\n\nAssisted-by: LLM\n\nBut fix the typo.\n') assert extract_trailers(msg) == [] def test_reply_url_only_allowed_in_link_trailers( self, make_msg: MsgFactory ) -> None: msg = make_msg( body=( 'See below.\n\n' 'Link: https://lore.kernel.org/r/x@y\n' 'Reviewed-by: https://example.com/not-a-person\n' ) ) assert [t.name for t in extract_trailers(msg)] == ['Link'] def test_reply_patch_metadata_is_not_feedback(self, make_msg: MsgFactory) -> None: # Quoting a patch's own headers is not the same as offering them. msg = make_msg( from_addr=('Bob', 'bob@example.com'), body=( 'Here is what I applied:\n\n' 'From: Alice \n' 'Subject: [PATCH] usb: fix it\n' 'base-commit: deadbeef1234\n\n' 'Tested-by: Bob \n' ), ) assert [t.name for t in extract_trailers(msg)] == ['Tested-by'] class TestSelfAttestedTrailers: """A reply's person trailer counts only when the sender signs it.""" def _names( self, sender: tuple[str, str], body: str, make_msg: MsgFactory ) -> list[str]: return [t.name for t in extract_trailers(make_msg(from_addr=sender, body=body))] def test_tagging_somebody_else_is_dropped(self, make_msg: MsgFactory) -> None: assert ( self._names( ('Bob', 'bob@example.com'), 'Alice liked it too:\n\nReviewed-by: Alice \n', make_msg, ) == [] ) def test_an_affiliation_does_not_break_attestation( self, make_msg: MsgFactory ) -> None: # The name carries the employer and the address is unrelated to # the one in the tag, so only the name can rescue this. assert self._names( ('Bob Reviewer (Arm)', 'bob@example.arm.com'), 'lgtm\n\nReviewed-by: Bob Reviewer \n', make_msg, ) == ['Reviewed-by'] def test_an_inbody_from_does_not_name_the_sender( self, make_msg: MsgFactory ) -> None: # Who sent the mail is a header question. An in-body From: is # body text the sender wrote, so letting it answer would let # anybody tag on anybody's behalf. assert ( self._names( ('Mallory', 'mallory@example.net'), 'From: Ann Submitter \n\nlgtm\n\n' 'Reviewed-by: Ann Submitter \n', make_msg, ) == [] ) def test_pasted_snippet_is_not_a_trailer(self, make_msg: MsgFactory) -> None: # A device tree snippet quoted in a reply reads as a trailer # whose value happens to look like an address. assert ( self._names( ('David', 'david@example.com'), 'How about this:\n\n\ti2c0: i2c@... {\n\t\t#size-cells = <0>;\n', make_msg, ) == [] ) def test_extended_local_part_still_matches(self, make_msg: MsgFactory) -> None: assert self._names( ('Bob', 'bob+kernel@example.com'), 'Acked-by: Bob \n', make_msg, ) == ['Acked-by'] def test_subdomain_still_matches(self, make_msg: MsgFactory) -> None: assert self._names( ('Bob', 'bob@example.com'), 'Acked-by: Bob \n', make_msg, ) == ['Acked-by'] def test_name_match_rescues_a_different_address(self, make_msg: MsgFactory) -> None: # Maintainers often post from one address and tag from another. assert self._names( ('Mikael Renberg', 'mrenberg@example.com'), 'Acked-by: Mikael Renberg \n', make_msg, ) == ['Acked-by'] def test_comma_reversed_name_matches(self, make_msg: MsgFactory) -> None: assert self._names( ('Okonkwo, Amara', 'aokonkwo@example.com'), 'Reviewed-by: Amara Okonkwo \n', make_msg, ) == ['Reviewed-by'] def test_comma_reversed_name_matches_either_way_round( self, make_msg: MsgFactory ) -> None: # Name matching is symmetric, so it does not matter which of the # two spellings is the comma-separated one. assert self._names( ('Amara Okonkwo', 'aokonkwo@example.com'), 'Reviewed-by: Okonkwo, Amara \n', make_msg, ) == ['Reviewed-by'] def test_utility_trailers_need_no_attestation(self, make_msg: MsgFactory) -> None: # There is no identity in a Fixes: or a Link:, so nothing to match. assert self._names( ('Bob', 'bob@example.com'), 'This one:\n\nFixes: deadbeef1234 ("usb: break it")\n', make_msg, ) == ['Fixes'] def test_a_patch_is_not_subject_to_the_rule(self, make_msg: MsgFactory) -> None: # A patch collects everybody's tags; the sender signs for them. assert self._names( ('Alice', 'alice@example.com'), 'usb: fix it\n\nSigned-off-by: Alice \n' 'Reviewed-by: Bob \n' + PATCH_TAIL, make_msg, ) == ['Signed-off-by', 'Reviewed-by'] class TestInbodyAuthor: def test_inbody_from_overrides_header(self, make_msg: MsgFactory) -> None: msg = make_msg( from_addr=('Mailing List', 'list@vger.kernel.org'), body='From: Real Author \n\nusb: fix it\n', ) assert msg_get_inbody_author(msg) == ('Real Author', 'real@example.com') assert msg_get_author(msg) == ('Mailing List', 'list@vger.kernel.org') assert msg_get_author(msg, use_inbody=True) == ( 'Real Author', 'real@example.com', ) def test_summary_author_and_sender_may_differ(self, make_msg: MsgFactory) -> None: # A patch relayed by a co-maintainer: git am would record the # in-body author, while the sender is whoever posted it. Both # answers are right, to two different questions. msg = make_msg( subject='[PATCH 1/1] usb: fix it', from_addr=('Relaying Maintainer', 'maint@example.com'), body='From: Ann Submitter \n\nusb: fix it\n\n' 'Signed-off-by: Ann Submitter \n' + PATCH_TAIL, ) assert summarize_message(msg).author == ('Ann Submitter', 'ann@example.com') assert msg_get_author(msg) == ('Relaying Maintainer', 'maint@example.com') def test_no_inbody_from(self, make_msg: MsgFactory) -> None: msg = make_msg(body='usb: fix it\n\nSome commit message.\n') assert msg_get_inbody_author(msg) is None def test_commit_subject_is_not_a_header(self, make_msg: MsgFactory) -> None: # "usb: fix it" matches "Name: value" shape but is not a known # header, so scanning must stop rather than walk into the body. msg = make_msg(body='usb: fix it\n\nFrom: Not The Author \n') assert msg_get_inbody_author(msg) is None def test_block_ends_at_blank_line(self, make_msg: MsgFactory) -> None: msg = make_msg( body='Subject: [PATCH] x\n\nBody text\nFrom: Late \n' ) assert msg_get_inbody_author(msg) is None def test_leading_blank_lines_are_allowed(self, make_msg: MsgFactory) -> None: msg = make_msg(body='\n\nFrom: Real \n\nbody\n') assert msg_get_inbody_author(msg) == ('Real', 'real@example.com') class TestMungedSender: """Lists that rewrite From for DMARC keep the sender elsewhere.""" def _munged(self, make_msg: MsgFactory, body: str) -> EmailMessage: msg = make_msg( subject='Re: [PATCH 1/1] usb: fix it', from_addr=('Bob Reviewer via list', 'list@lists.example.org'), body=body, ) msg['X-Original-From'] = 'Bob Reviewer ' return msg def test_original_from_names_the_sender(self, make_msg: MsgFactory) -> None: msg = self._munged(make_msg, 'Looks fine.\n') assert msg_get_author(msg) == ('Bob Reviewer', 'bob@example.com') assert msg_get_author(msg, use_inbody=True) == ( 'Bob Reviewer', 'bob@example.com', ) def test_a_munged_reply_keeps_its_trailer(self, make_msg: MsgFactory) -> None: # Without X-Original-From the sender looks like the list itself, # and a perfectly good tag would be dropped as unattested. msg = self._munged( make_msg, 'Looks fine.\n\nReviewed-by: Bob Reviewer \n', ) assert [t.name for t in extract_trailers(msg)] == ['Reviewed-by'] class TestGroupByThread: def test_splits_two_threads(self, make_msg: MsgFactory) -> None: a1 = make_msg(subject='A', msgid='a1@x', date=DATE1) a2 = make_msg(subject='Re: A', msgid='a2@x', in_reply_to='a1@x', date=DATE2) b1 = make_msg(subject='B', msgid='b1@x', date=DATE3) threads = group_by_thread([a2, b1, a1]) assert [[m['Message-Id'] for m in t] for t in threads] == [ ['', ''], [''], ] def test_groups_via_references_when_root_is_missing( self, make_msg: MsgFactory ) -> None: # Two siblings whose parent is not in the bag still belong together. c1 = make_msg(msgid='c1@x', references=['root@x'], date=DATE1) c2 = make_msg(msgid='c2@x', references=['root@x'], date=DATE2) assert len(group_by_thread([c1, c2])) == 1 def test_duplicate_msgids_are_collapsed(self, make_msg: MsgFactory) -> None: one = make_msg(msgid='dup@x', date=DATE1) two = make_msg(msgid='dup@x', date=DATE1) threads = group_by_thread([one, two]) assert len(threads) == 1 assert len(threads[0]) == 1 def test_empty_input(self) -> None: assert group_by_thread([]) == [] class TestSummarizeThread: def test_rejects_empty_thread(self) -> None: with pytest.raises(ValueError, match='empty thread'): summarize_thread([]) def test_plain_discussion(self, make_msg: MsgFactory) -> None: first = make_msg( subject='Question about USB', from_addr=('Alice', 'alice@example.com'), msgid='q1@x', date=DATE1, ) reply = make_msg( subject='Re: Question about USB', from_addr=('Bob', 'bob@example.com'), msgid='q2@x', in_reply_to='q1@x', date=DATE2, ) summary = summarize_thread([first, reply]) assert summary.msgid == 'q1@x' assert summary.subject == 'Question about USB' assert summary.count == 2 assert summary.is_patch_series is False assert summary.patch_count == 0 assert summary.expected == 0 assert summary.participants == ( ('Alice', 'alice@example.com'), ('Bob', 'bob@example.com'), ) def test_lone_patch_is_a_one_patch_series(self, make_msg: MsgFactory) -> None: msg = make_msg(subject='[PATCH] usb: fix it', msgid='p1@x', date=DATE1) summary = summarize_thread([msg]) assert summary.is_patch_series is True assert summary.patch_count == 1 assert summary.expected == 1 assert summary.has_cover is False def test_complete_series_with_cover(self, make_msg: MsgFactory) -> None: msgs = [ make_msg(subject='[PATCH v2 0/3] usb: a series', msgid='s0@x', date=DATE1) ] msgs += [ make_msg( subject=f'[PATCH v2 {n}/3] usb: part {n}', msgid=f's{n}@x', in_reply_to='s0@x', date=DATE1, ) for n in (1, 2, 3) ] summary = summarize_thread(msgs) assert summary.is_patch_series is True assert summary.has_cover is True assert summary.patch_count == 3 assert summary.expected == 3 assert summary.version == 2 def test_incomplete_series_is_reported_not_judged( self, make_msg: MsgFactory ) -> None: msgs = [ make_msg(subject=f'[PATCH {n}/7] x', msgid=f'i{n}@x', date=DATE1) for n in (1, 2) ] summary = summarize_thread(msgs) assert (summary.patch_count, summary.expected) == (2, 7) def test_replies_do_not_inflate_patch_count(self, make_msg: MsgFactory) -> None: patch = make_msg(subject='[PATCH 1/1] usb: fix it', msgid='r1@x', date=DATE1) reply = make_msg( subject='Re: [PATCH 1/1] usb: fix it', from_addr=('Bob', 'bob@example.com'), msgid='r2@x', in_reply_to='r1@x', date=DATE2, body='Reviewed-by: Bob \n', ) summary = summarize_thread([patch, reply]) assert summary.count == 2 assert summary.patch_count == 1 assert summary.trailer_names == {'reviewed-by'} def test_resent_patch_counts_once(self, make_msg: MsgFactory) -> None: first = make_msg(subject='[PATCH 1/2] x', msgid='d1@x', date=DATE1) again = make_msg(subject='[PATCH 1/2] x', msgid='d2@x', date=DATE2) other = make_msg(subject='[PATCH 2/2] y', msgid='d3@x', date=DATE3) assert summarize_thread([first, again, other]).patch_count == 2 def test_timestamps_span_the_thread(self, make_msg: MsgFactory) -> None: first = make_msg(msgid='t1@x', date=DATE1) last = make_msg(msgid='t2@x', in_reply_to='t1@x', date=DATE3) summary = summarize_thread([last, first]) assert summary.first is not None and summary.last is not None assert summary.first.hour == 10 assert summary.last.hour == 12 def test_undated_messages_do_not_crash(self, make_msg: MsgFactory) -> None: summary = summarize_thread([make_msg(msgid='u1@x')]) assert summary.first is None assert summary.last is None def test_participants_deduped_and_ordered(self, make_msg: MsgFactory) -> None: one = make_msg( from_addr=('Alice', 'alice@example.com'), msgid='x1@x', date=DATE1 ) two = make_msg(from_addr=('Bob', 'bob@example.com'), msgid='x2@x', date=DATE2) three = make_msg( from_addr=('Alice', 'Alice@Example.com'), msgid='x3@x', date=DATE3 ) summary = summarize_thread([three, one, two]) assert summary.participants == ( ('Alice', 'alice@example.com'), ('Bob', 'bob@example.com'), ) def test_participant_uses_inbody_from(self, make_msg: MsgFactory) -> None: msg = make_msg( subject='[PATCH] usb: fix it', from_addr=('Remailer', 'list@vger.kernel.org'), msgid='rm@x', date=DATE1, body='From: Real Author \n\nusb: fix it\n', ) assert summarize_thread([msg]).participants == ( ('Real Author', 'real@example.com'), ) def test_trailers_are_deduped_across_the_thread(self, make_msg: MsgFactory) -> None: patch = make_msg( subject='[PATCH] x', msgid='td1@x', date=DATE1, body='x\n\nSigned-off-by: Alice \n' + PATCH_TAIL, ) replies = [ make_msg( subject='Re: [PATCH] x', from_addr=('Bob', 'bob@example.com'), msgid=f'td{n}@x', in_reply_to='td1@x', date=DATE2, body='Acked-by: Bob \n', ) for n in (2, 3) ] summary = summarize_thread([patch, *replies]) assert [(t.name, t.value) for t in summary.trailers] == [ ('Signed-off-by', 'Alice '), ('Acked-by', 'Bob '), ] def test_prefixes_collected_in_order(self, make_msg: MsgFactory) -> None: msgs = [ make_msg(subject='[RFC PATCH net-next 1/2] a', msgid='pf1@x', date=DATE1), make_msg(subject='[RFC PATCH net-next 2/2] b', msgid='pf2@x', date=DATE2), ] assert summarize_thread(msgs).prefixes == ('RFC', 'net-next') class TestTrailerTypes: """b4 sorts trailers into people, utilities and everything else.""" def _trailers(self, body: str, make_msg: MsgFactory) -> dict[str, Trailer]: patch = make_msg( subject='[PATCH 1/1] x', msgid='tt1@x', date=DATE1, body=body + PATCH_TAIL, ) return {t.key: t for t in summarize_thread([patch]).trailers} def test_person_trailer_carries_the_address(self, make_msg: MsgFactory) -> None: found = self._trailers('Signed-off-by: Alice \n', make_msg) trailer = found['signed-off-by'] assert trailer.type == 'person' assert trailer.is_person and not trailer.is_utility assert trailer.addr == ('Alice', 'alice@example.com') def test_stray_whitespace_in_address_is_normalised( self, make_msg: MsgFactory ) -> None: found = self._trailers( 'Tested-by: Fuad Tabba < fuad.tabba@linux.dev>\n', make_msg ) assert found['tested-by'].value == 'Fuad Tabba ' def test_mailto_wrapper_is_stripped(self, make_msg: MsgFactory) -> None: # Some clients round-trip through HTML and mangle the address. found = self._trailers( 'Reviewed-by: Bob \n', make_msg, ) assert found['reviewed-by'].value == 'Bob ' assert found['reviewed-by'].addr == ('Bob', 'bob@example.com') def test_known_utility_names_are_utilities(self, make_msg: MsgFactory) -> None: found = self._trailers( 'Fixes: deadbeef1234 ("thing: do it")\n' 'Link: https://lore.kernel.org/r/foo@bar\n', make_msg, ) assert found['fixes'].type == 'utility' assert found['link'].is_utility assert found['link'].addr is None def test_any_url_value_is_a_utility(self, make_msg: MsgFactory) -> None: found = self._trailers('Suggested-by: https://example.com/x\n', make_msg) assert found['suggested-by'].type == 'utility' def test_addressless_trailer_stays_unknown(self, make_msg: MsgFactory) -> None: found = self._trailers('Suggested-by: Somebody Nice\n', make_msg) trailer = found['suggested-by'] assert trailer.type == 'unknown' assert not trailer.is_person and not trailer.is_utility assert trailer.addr is None def test_summary_splits_people_from_utilities(self, make_msg: MsgFactory) -> None: patch = make_msg( subject='[PATCH 1/1] x', msgid='ts1@x', date=DATE1, body='Fixes: deadbeef1234 ("thing: do it")\n' 'Signed-off-by: Alice \n' 'Suggested-by: Somebody Nice\n' + PATCH_TAIL, ) summary = summarize_thread([patch]) assert [t.name for t in summary.person_trailers] == ['Signed-off-by'] assert [t.name for t in summary.utility_trailers] == ['Fixes'] # The unknown one is still in the full list, just in neither bucket. assert len(summary.trailers) == 3 class TestMsgids: def test_msgids_are_in_date_order(self, make_msg: MsgFactory) -> None: root = make_msg(subject='[PATCH 1/1] x', msgid='m1@x', date=DATE1) late = make_msg( subject='Re: [PATCH 1/1] x', msgid='m3@x', in_reply_to='m1@x', date=DATE3, ) early = make_msg( subject='Re: [PATCH 1/1] x', msgid='m2@x', in_reply_to='m1@x', date=DATE2, ) summary = summarize_thread([late, root, early]) assert summary.msgids == ('m1@x', 'm2@x', 'm3@x') assert len(summary.msgids) == summary.count assert summary.msgids[0] == summary.msgid def test_root_leads_even_when_it_is_not_the_oldest( self, make_msg: MsgFactory ) -> None: # A cover letter can be stamped a second after its first patch. cover = make_msg(subject='[PATCH 0/1] x', msgid='c0@x', date=DATE2) patch = make_msg( subject='[PATCH 1/1] x', msgid='c1@x', in_reply_to='c0@x', date=DATE1 ) summary = summarize_thread([cover, patch]) assert summary.msgids == ('c0@x', 'c1@x') assert summary.msgids[0] == summary.msgid def test_series_sent_in_the_same_second_keeps_its_order( self, make_msg: MsgFactory ) -> None: # git send-email stamps a whole series within one second, so the # n/m counter is what actually orders it. msgs = [ make_msg(subject='[PATCH 3/3] c', msgid='s3@x', date=DATE1), make_msg(subject='[PATCH 1/3] a', msgid='s1@x', date=DATE1), make_msg(subject='[PATCH 2/3] b', msgid='s2@x', date=DATE1), ] assert summarize_thread(msgs).msgids == ('s1@x', 's2@x', 's3@x') def test_msgids_run_parallel_to_messages(self, make_msg: MsgFactory) -> None: root = make_msg(subject='[PATCH 1/1] x', msgid='p1@x', date=DATE1) reply = make_msg( subject='Re: [PATCH 1/1] x', msgid='p2@x', in_reply_to='p1@x', date=DATE2, ) summary = summarize_thread([reply, root]) assert summary.msgids == tuple(m.msgid for m in summary.messages) def test_a_message_with_no_msgid_keeps_its_place( self, make_msg: MsgFactory ) -> None: # Every message counts, so msgids stays the same length as # messages and holds an empty string where the id is missing. root = make_msg(subject='[PATCH 1/1] x', msgid='n1@x', date=DATE1) reply = make_msg( subject='Re: [PATCH 1/1] x', msgid='n2@x', in_reply_to='n1@x', date=DATE2, ) del reply['Message-Id'] summary = summarize_thread([root, reply]) assert summary.count == 2 assert summary.msgids == ('n1@x', '') assert summary.msgids == tuple(m.msgid for m in summary.messages) def test_a_root_with_no_msgid_still_leads(self, make_msg: MsgFactory) -> None: root = make_msg(subject='[PATCH 1/1] x', msgid='r1@x', date=DATE1) reply = make_msg( subject='Re: [PATCH 1/1] x', msgid='r2@x', in_reply_to='r1@x', date=DATE2, ) del root['Message-Id'] summary = summarize_thread([root, reply]) assert summary.msgid == '' assert summary.msgids == ('', 'r2@x') assert summary.messages[0].subject == '[PATCH 1/1] x' assert summary.msgids == tuple(m.msgid for m in summary.messages) class TestSerialization: def test_to_dict_is_json_safe(self, make_msg: MsgFactory) -> None: patch = make_msg(subject='[PATCH v2 1/1] x', msgid='j1@x', date=DATE1) reply = make_msg( subject='Re: [PATCH v2 1/1] x', from_addr=('Bob', 'bob@example.com'), msgid='j2@x', in_reply_to='j1@x', date=DATE2, body='Reviewed-by: Bob \n', ) payload = summarize_thread([patch, reply]).to_dict() # Must survive a round trip through JSON unchanged. assert json.loads(json.dumps(payload)) == payload assert payload['trailers'] == [ { 'name': 'Reviewed-by', 'value': 'Bob ', 'type': 'person', 'email': 'bob@example.com', 'msgid': 'j2@x', 'date': '2026-09-15T11:00:00+00:00', } ] assert payload['msgids'] == ['j1@x', 'j2@x'] assert payload['schema'] == SCHEMA_VERSION assert payload['author'] == { 'name': 'Test User', 'email': 'test@example.com', } assert [m['msgid'] for m in payload['messages']] == ['j1@x', 'j2@x'] assert payload['participants'][0] == { 'name': 'Test User', 'email': 'test@example.com', } assert payload['first'].startswith('2026-09-15T10:00:00') def test_key_set_is_fixed(self, make_msg: MsgFactory) -> None: # Every key is always present, so consumers never probe. plain = summarize_thread([make_msg(msgid='k1@x')]).to_dict() patched = summarize_thread( [make_msg(subject='[PATCH 1/1] x', msgid='k2@x', date=DATE1)] ).to_dict() assert plain.keys() == patched.keys() # Every field is serialised, plus the schema version, which is a # property of the shape rather than of any one summary. assert set(plain) == { f.name for f in ThreadSummary.__dataclass_fields__.values() } | {'schema'} def test_message_key_set_is_fixed(self, make_msg: MsgFactory) -> None: plain = summarize_message(make_msg(msgid='km1@x')) patched = summarize_message( make_msg(subject='[PATCH v2 2/3] x', msgid='km2@x', date=DATE1) ) assert plain.to_dict().keys() == patched.to_dict().keys() assert json.loads(json.dumps(patched.to_dict())) == patched.to_dict() class TestSummarizeThreads: def test_end_to_end_query_shape(self, make_msg: MsgFactory) -> None: """The MCP query: complete series, reviewed, from a mixed bag.""" reviewed = [ make_msg(subject='[PATCH 1/2] good', msgid='g1@x', date=DATE1), make_msg( subject='[PATCH 2/2] good', msgid='g2@x', in_reply_to='g1@x', date=DATE1, ), make_msg( subject='Re: [PATCH 1/2] good', from_addr=('Bob', 'bob@example.com'), msgid='g3@x', in_reply_to='g1@x', date=DATE2, body='Reviewed-by: Bob \n', ), ] incomplete = [make_msg(subject='[PATCH 1/5] partial', msgid='h1@x', date=DATE1)] chatter = [make_msg(subject='Just chatting', msgid='n1@x', date=DATE3)] summaries = summarize_threads(reviewed + incomplete + chatter) assert len(summaries) == 3 wanted = {'reviewed-by', 'acked-by'} hits = [ s for s in summaries if s.is_patch_series and s.patch_count == s.expected and bool(wanted & s.trailer_names) ] assert [s.msgid for s in hits] == ['g1@x'] ME = ('Me Maintainer', 'me@example.com') SASHIKO = ('Sashiko Tanaka', 'sashiko@example.com') ANN = ('Ann Submitter', 'ann@example.com') class TestMessageSummaries: def test_messages_match_msgids_root_first(self, make_msg: MsgFactory) -> None: # The cover letter is stamped a second after patch 1/2, so date # order and thread order genuinely differ here. cover = make_msg(subject='[PATCH 0/2] set', msgid='c0@x', date=DATE2) one = make_msg( subject='[PATCH 1/2] a', msgid='c1@x', in_reply_to='c0@x', date=DATE1 ) summary = summarize_thread([one, cover]) assert summary.msgids == ('c0@x', 'c1@x') assert [m.msgid for m in summary.messages] == list(summary.msgids) assert summary.messages[0].patch.is_cover is True def test_message_carries_author_date_and_patch(self, make_msg: MsgFactory) -> None: msg = make_msg( subject='[PATCH v3 2/5] thing', from_addr=ANN, msgid='m1@x', date=DATE1, body='Body\n' + PATCH_TAIL, ) summary = summarize_message(msg) assert summary.author == ANN assert summary.date is not None assert summary.has_diff is True assert summary.is_submission is True assert (summary.patch.version, summary.patch.counter) == (3, 2) def test_reply_carrying_brackets_is_not_a_submission( self, make_msg: MsgFactory ) -> None: reply = make_msg(subject='Re: [PATCH 1/1] a', msgid='r1@x', date=DATE2) assert summarize_message(reply).is_submission is False def test_trailer_records_the_message_that_offered_it( self, make_msg: MsgFactory ) -> None: patch = make_msg( subject='[PATCH 1/1] a', from_addr=ANN, msgid='p1@x', date=DATE1 ) review = make_msg( subject='Re: [PATCH 1/1] a', from_addr=SASHIKO, msgid='p2@x', in_reply_to='p1@x', date=DATE2, body='Reviewed-by: Sashiko Tanaka \n', ) summary = summarize_thread([patch, review]) (trailer,) = [t for t in summary.trailers if t.key == 'reviewed-by'] assert trailer.msgid == 'p2@x' assert trailer.date is not None # And the same trailer is on the message that made it. offered = summary.messages_by('sashiko@example.com') assert [t.key for t in offered[0].trailers] == ['reviewed-by'] class TestAuthorAndRepliers: def test_author_is_the_thread_root(self, make_msg: MsgFactory) -> None: patch = make_msg( subject='[PATCH 1/1] a', from_addr=ANN, msgid='a1@x', date=DATE1 ) reply = make_msg( subject='Re: [PATCH 1/1] a', from_addr=SASHIKO, msgid='a2@x', in_reply_to='a1@x', date=DATE2, ) summary = summarize_thread([patch, reply]) assert summary.author == ANN assert summary.repliers == (SASHIKO,) def test_author_answering_their_own_thread_is_not_a_replier( self, make_msg: MsgFactory ) -> None: # The whole point of keeping author apart from participants: Ann # is in participants twice over and still has replied to nobody. patch = make_msg( subject='[PATCH 1/1] a', from_addr=ANN, msgid='b1@x', date=DATE1 ) followup = make_msg( subject='Re: [PATCH 1/1] a', from_addr=ANN, msgid='b2@x', in_reply_to='b1@x', date=DATE2, ) summary = summarize_thread([patch, followup]) assert summary.participants == (ANN,) assert summary.repliers == () def test_messages_by_matches_addresses(self, make_msg: MsgFactory) -> None: patch = make_msg( subject='[PATCH 1/1] a', from_addr=ANN, msgid='d1@x', date=DATE1 ) reply = make_msg( subject='Re: [PATCH 1/1] a', from_addr=('Ann Submitter', 'ANN@example.com'), msgid='d2@x', in_reply_to='d1@x', date=DATE2, ) summary = summarize_thread([patch, reply]) assert len(summary.messages_by('ann@EXAMPLE.com')) == 2 assert summary.messages_by('ann@elsewhere.org') == () def _reviewed_by_awaiting_reply( summaries: list[ThreadSummary], reviewer: Identity, me: Identity ) -> list[str]: """The example query, written the way a caller would write it. "Threads reviewed by *reviewer* that have not yet had a reply from *me*" -- meaning I have sent nothing in the thread since the review landed. Every judgement here is the caller's; the module only supplies the facts it leans on, and answers both "who is this?" questions the same way. """ hits: list[str] = [] for summary in summaries: reviews = [ trailer.date for trailer in summary.trailers_by(reviewer) if trailer.key == 'reviewed-by' and trailer.date is not None ] if not reviews: continue latest = max(reviews) mine = summary.messages_by(me) if any(m.date is not None and m.date > latest for m in mine): continue hits.append(summary.msgid) return hits class TestReviewedAwaitingReplyQuery: def _corpus(self, make_msg: MsgFactory) -> list[ThreadSummary]: def review(msgid: str, parent: str) -> EmailMessage: return make_msg( subject=f'Re: [PATCH 1/1] {parent}', from_addr=SASHIKO, msgid=msgid, in_reply_to=parent, date=DATE2, body='Reviewed-by: Sashiko Tanaka \n', ) msgs = [ # Reviewed, no reply from me -- the answer. make_msg(subject='[PATCH 1/1] t1', from_addr=ANN, msgid='t1', date=DATE1), review('t1r', 't1'), # Reviewed, and I answered afterwards. make_msg(subject='[PATCH 1/1] t2', from_addr=ANN, msgid='t2', date=DATE1), review('t2r', 't2'), make_msg( subject='Re: [PATCH 1/1] t2', from_addr=ME, msgid='t2m', in_reply_to='t2', date=DATE3, body='Applied, thanks.\n', ), # My own series, reviewed, not answered by me since. make_msg(subject='[PATCH 1/1] t3', from_addr=ME, msgid='t3', date=DATE1), review('t3r', 't3'), # Reviewed by somebody else entirely. make_msg(subject='[PATCH 1/1] t4', from_addr=ANN, msgid='t4', date=DATE1), make_msg( subject='Re: [PATCH 1/1] t4', from_addr=('Bob Other', 'bob@example.com'), msgid='t4r', in_reply_to='t4', date=DATE2, body='Reviewed-by: Bob Other \n', ), ] return summarize_threads(msgs) def test_finds_only_the_unanswered_reviewed_threads( self, make_msg: MsgFactory ) -> None: hits = _reviewed_by_awaiting_reply( self._corpus(make_msg), Identity.from_addr(SASHIKO), Identity.from_addr(ME) ) # t2 is answered; t4 is not Sashiko's. t3 is mine and I have # still not replied to the review, which is why being in # participants cannot stand in for having replied. assert sorted(hits) == ['t1', 't3'] def test_a_reply_before_the_review_does_not_count( self, make_msg: MsgFactory ) -> None: msgs = [ make_msg(subject='[PATCH 1/1] e1', from_addr=ANN, msgid='e1', date=DATE1), make_msg( subject='Re: [PATCH 1/1] e1', from_addr=ME, msgid='e1m', in_reply_to='e1', date=DATE2, body='Looks reasonable.\n', ), make_msg( subject='Re: [PATCH 1/1] e1', from_addr=SASHIKO, msgid='e1r', in_reply_to='e1', date=DATE3, body='Reviewed-by: Sashiko Tanaka \n', ), ] hits = _reviewed_by_awaiting_reply( summarize_threads(msgs), Identity.from_addr(SASHIKO), Identity.from_addr(ME) ) assert hits == ['e1'] class TestEmailMatches: def test_identical_and_case_insensitive(self) -> None: assert email_matches('Bob@Example.COM', 'bob@example.com') is True def test_extended_local_part(self) -> None: assert email_matches('foo+kernel@example.com', 'foo@example.com') is True def test_one_level_of_subdomain(self) -> None: assert email_matches('foo@linux.intel.com', 'foo@intel.com') is True def test_two_levels_is_too_far(self) -> None: assert email_matches('foo@a.b.intel.com', 'foo@intel.com') is False def test_different_local_parts_never_match(self) -> None: assert email_matches('foo@intel.com', 'bar@intel.com') is False def test_unrelated_domains_do_not_match(self) -> None: assert email_matches('foo@example.com', 'foo@example.org') is False def test_a_non_address_does_not_crash(self) -> None: assert email_matches('not-an-address', 'foo@example.com') is False class TestNameMatches: def test_exact_ignoring_case_and_space(self) -> None: assert name_matches(' Ann Submitter ', 'ann submitter') is True def test_comma_form_matches_either_direction(self) -> None: straight, comma = 'Amara Okonkwo', 'Okonkwo, Amara' assert name_matches(straight, comma) is True assert name_matches(comma, straight) is True def test_empty_matches_nothing(self) -> None: assert name_matches('', '') is False assert name_matches('', 'Ann Submitter') is False def test_different_people_do_not_match(self) -> None: assert name_matches('Ann Submitter', 'Bob Other') is False def test_comma_form_matches_whole_words_only(self) -> None: # A shorter name that merely spells the start of a longer one is # a different person, however the comma falls. assert name_matches('Alexander Smithson', 'Smith, Alex') is False assert name_matches('Smith, Alex', 'Alexander Smithson') is False assert name_matches('Bob Ngyuen', 'Ng, Bo') is False def test_a_middle_name_does_not_stop_the_match(self) -> None: assert name_matches('Amara C. Okonkwo', 'Okonkwo, Amara') is True def test_an_aside_in_parentheses_is_ignored(self) -> None: # Kernel senders often carry their employer in the From line. assert name_matches('Bob Reviewer (Arm)', 'Bob Reviewer') is True assert name_matches('Bob Reviewer', 'Bob Reviewer (Arm)') is True assert name_matches('Reviewer, Bob (Arm)', 'Bob Reviewer') is True assert name_matches('Bob Reviewer (Arm)', 'Alice Other') is False def test_a_name_that_is_only_an_aside_matches_nothing(self) -> None: assert name_matches('(Arm)', 'Bob Reviewer') is False assert name_matches('(Arm)', '(Arm)') is False class TestIdentity: def test_matches_a_bare_address(self) -> None: person = Identity(addrs=('ann@example.com',)) assert person.matches('ANN@example.com') is True assert person.matches('bob@example.com') is False def test_a_bare_address_never_matches_on_name(self) -> None: # A string is an address, so a name cannot sneak in through it. person = Identity(names=('Ann Submitter',)) assert person.matches('Ann Submitter') is False def test_matches_a_pair_on_either_half(self) -> None: person = Identity(addrs=('ann@example.com',), names=('Ann Submitter',)) assert person.matches(('Someone Else', 'ann@example.com')) is True assert person.matches(('Ann Submitter', 'ann@elsewhere.org')) is True assert person.matches(('Bob Other', 'bob@example.com')) is False def test_spans_several_unrelated_addresses(self) -> None: # The whole point: only a caller's directory knows these two # addresses are one human, and this is where it says so. person = Identity(addrs=('ann@example.com', 'ann@corp.invalid')) assert person.matches('ann@corp.invalid') is True def test_matches_another_identity(self) -> None: one = Identity(addrs=('ann@example.com',)) two = Identity(addrs=('ann+lists@example.com',)) assert one.matches(two) is True assert one.matches(Identity(addrs=('bob@example.com',))) is False def test_from_addr_drops_empty_halves(self) -> None: person = Identity.from_addr(('', 'ann@example.com')) assert person.names == () assert person.addrs == ('ann@example.com',) def test_to_dict_is_json_safe(self) -> None: person = Identity(addrs=('ann@example.com',), names=('Ann Submitter',)) payload = person.to_dict() assert json.loads(json.dumps(payload)) == payload class TestIdentityOnThreads: def _thread(self, make_msg: MsgFactory) -> ThreadSummary: patch = make_msg( subject='[PATCH 1/1] a', from_addr=ANN, msgid='i1@x', date=DATE1 ) review = make_msg( subject='Re: [PATCH 1/1] a', from_addr=SASHIKO, msgid='i2@x', in_reply_to='i1@x', date=DATE2, body='Reviewed-by: Sashiko Tanaka \n', ) # Same person as ANN, writing from an address a directory would # have to know about -- but under the same display name. again = make_msg( subject='Re: [PATCH 1/1] a', from_addr=('Ann Submitter', 'ann@corp.invalid'), msgid='i3@x', in_reply_to='i1@x', date=DATE3, ) return summarize_thread([patch, review, again]) def test_messages_by_accepts_an_identity(self, make_msg: MsgFactory) -> None: summary = self._thread(make_msg) assert len(summary.messages_by('ann@example.com')) == 1 both = Identity(addrs=('ann@example.com', 'ann@corp.invalid')) assert len(summary.messages_by(both)) == 2 def test_repliers_drops_the_author_under_another_address( self, make_msg: MsgFactory ) -> None: summary = self._thread(make_msg) # Ann spoke twice from two addresses; both are participants, and # neither counts as somebody having replied to her. assert len(summary.participants) == 3 assert summary.repliers == (SASHIKO,) def test_trailers_by_attributes_to_the_named_person( self, make_msg: MsgFactory ) -> None: summary = self._thread(make_msg) assert [t.key for t in summary.trailers_by('sashiko@example.com')] == [ 'reviewed-by' ] assert summary.trailers_by('ann@example.com') == () def test_trailers_by_matches_a_name_when_the_address_differs( self, make_msg: MsgFactory ) -> None: summary = self._thread(make_msg) by_name = Identity(names=('Sashiko Tanaka',)) assert [t.key for t in summary.trailers_by(by_name)] == ['reviewed-by'] python-liblore-0.10.1/tests/test_thread.py000066400000000000000000000111061526032434100205610ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later """Tests for liblore.thread.""" from __future__ import annotations from conftest import MsgFactory from liblore.utils import get_clean_msgid, get_strict_thread class TestGetStrictThread: def test_simple_thread(self, make_msg: MsgFactory) -> None: root = make_msg(msgid='root@x.com', subject='Root') reply = make_msg( msgid='reply@x.com', subject='Re: Root', in_reply_to='root@x.com', ) unrelated = make_msg(msgid='other@x.com', subject='Other') result = get_strict_thread([root, reply, unrelated], 'root@x.com') assert result is not None ids = {get_clean_msgid(m) for m in result} assert ids == {'root@x.com', 'reply@x.com'} def test_returns_none_for_missing_msgid(self, make_msg: MsgFactory) -> None: msg = make_msg(msgid='exists@x.com') result = get_strict_thread([msg], 'nonexistent@x.com') assert result is None def test_noparent(self, make_msg: MsgFactory) -> None: parent = make_msg(msgid='parent@x.com') child = make_msg( msgid='child@x.com', in_reply_to='parent@x.com', ) grandchild = make_msg( msgid='grandchild@x.com', in_reply_to='child@x.com', ) # Start at child with noparent — should exclude parent result = get_strict_thread( [parent, child, grandchild], 'child@x.com', noparent=True ) assert result is not None ids = {get_clean_msgid(m) for m in result} assert 'parent@x.com' not in ids assert 'child@x.com' in ids assert 'grandchild@x.com' in ids def test_noparent_self_referencing_cover(self, make_msg: MsgFactory) -> None: # Some tooling emits a cover letter whose In-Reply-To/References point # at its own Message-ID. Breaking the thread there must not treat the # cover's own msgid as a reference to ignore, or every patch that # legitimately replies to the cover gets dropped. cover = make_msg( msgid='cover@x.com', subject='[PATCH 0/2] Something', in_reply_to='cover@x.com', references=['cover@x.com'], ) patch1 = make_msg( msgid='p1@x.com', subject='[PATCH 1/2] First', in_reply_to='cover@x.com', references=['cover@x.com'], ) patch2 = make_msg( msgid='p2@x.com', subject='[PATCH 2/2] Second', in_reply_to='cover@x.com', references=['cover@x.com'], ) review = make_msg( msgid='review@x.com', subject='Re: [PATCH 2/2] Second', in_reply_to='p2@x.com', references=['cover@x.com', 'p2@x.com'], ) want = {'cover@x.com', 'p1@x.com', 'p2@x.com', 'review@x.com'} # Cover first in the list is the worst case: without the fix only the # cover survives. Ordering must not matter, so check both. for msgs in ([cover, patch1, patch2, review], [patch1, cover, patch2, review]): result = get_strict_thread(msgs, 'cover@x.com', noparent=True) assert result is not None assert {get_clean_msgid(m) for m in result} == want def test_self_reference_is_not_a_parent(self, make_msg: MsgFactory) -> None: # A self-referencing message must not be treated as a reply to itself, # even without noparent: it is a thread root, and starting the walk at # its child still has to pull it in. root = make_msg( msgid='root@x.com', in_reply_to='root@x.com', references=['root@x.com'], ) reply = make_msg(msgid='reply@x.com', in_reply_to='root@x.com') result = get_strict_thread([root, reply], 'reply@x.com') assert result is not None assert {get_clean_msgid(m) for m in result} == {'root@x.com', 'reply@x.com'} def test_references_chain(self, make_msg: MsgFactory) -> None: msg1 = make_msg(msgid='a@x.com') msg2 = make_msg(msgid='b@x.com', references=['a@x.com']) msg3 = make_msg( msgid='c@x.com', references=['a@x.com', 'b@x.com'], ) result = get_strict_thread([msg1, msg2, msg3], 'a@x.com') assert result is not None assert len(result) == 3 def test_single_message(self, make_msg: MsgFactory) -> None: msg = make_msg(msgid='solo@x.com') result = get_strict_thread([msg], 'solo@x.com') assert result is not None assert len(result) == 1 python-liblore-0.10.1/typings/000077500000000000000000000000001526032434100162355ustar00rootroot00000000000000python-liblore-0.10.1/typings/authheaders/000077500000000000000000000000001526032434100205325ustar00rootroot00000000000000python-liblore-0.10.1/typings/authheaders/__init__.pyi000066400000000000000000000003431526032434100230140ustar00rootroot00000000000000""" This type stub file was generated by pyright. """ def authenticate_message( msg: bytes, authserv_id: str, *, dkim: bool = ..., dmarc: bool = ..., arc: bool = ..., spf: bool = ..., ) -> str: ... python-liblore-0.10.1/uv.lock000066400000000000000000007117531526032434100160620ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", "python_full_version < '3.10'", ] [[package]] name = "ast-serialize" version = "0.11.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" }, { url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" }, { url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" }, { url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" }, { url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" }, { url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" }, { url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" }, { url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" }, { url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" }, { url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" }, { url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" }, { url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" }, { url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" }, { url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" }, { url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" }, { url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" }, { url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" }, { url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" }, { url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, { url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" }, { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, ] [[package]] name = "authheaders" version = "0.16.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authres" }, { name = "dkimpy" }, { name = "dnspython", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "publicsuffix2" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/3b/50fb88a96976e14730bb0eb699be194ed66146489a64ddb3ccfed5b75e85/authheaders-0.16.3.tar.gz", hash = "sha256:118edc6d3cb1ad18ffab3d788ccc562fca2854cd645faab08fc47033c648148a", size = 115428, upload-time = "2024-06-24T12:44:18.977Z" } [[package]] name = "authres" version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6a/29/e28209b5d3d56c102845cca5a4a3abaf2724f79e83476a52b94d775ecce6/authres-1.2.0.tar.gz", hash = "sha256:93d1b995ad7ce21e62db649f361048125dd6022563a0ae8a23909465f1fd25b7", size = 23226, upload-time = "2019-08-03T19:38:00.494Z" } [[package]] name = "backports-asyncio-runner" version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] [[package]] name = "build" version = "1.4.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ { name = "colorama", marker = "os_name == 'nt'" }, { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, { name = "packaging" }, { name = "pyproject-hooks" }, { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/ec/bf5ae0a7e5ab57abe8aabdd0759c971883895d1a20c49ae99f8146840c3c/build-1.4.4.tar.gz", hash = "sha256:f832ae053061f3fb524af812dc94b8b84bac6880cd587630e3b5d91a6a9c1703", size = 89220, upload-time = "2026-04-22T20:53:44.807Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fa/88/6764e7a109dd84294850741501145da90d13cdeac9d4e614929464a37420/build-1.4.4-py3-none-any.whl", hash = "sha256:8c3f48a6090b39edec1a273d2d57949aaf13723b01e02f9d518396887519f64d", size = 25921, upload-time = "2026-04-22T20:53:43.251Z" }, ] [[package]] name = "build" version = "1.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ { name = "colorama", marker = "os_name == 'nt'" }, { name = "importlib-metadata", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10.2'" }, { name = "packaging" }, { name = "pyproject-hooks" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz", hash = "sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53", size = 113427, upload-time = "2026-09-10T07:56:00.417Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl", hash = "sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7", size = 31179, upload-time = "2026-09-10T07:55:59.148Z" }, ] [[package]] name = "certifi" version = "2026.7.22" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "charset-normalizer" version = "3.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/98/77/46e87bcfc45d25ab4db7cfc9bb544bfa3ffd302289ed31ae93f5433eb899/charset_normalizer-3.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3", size = 373348, upload-time = "2026-09-30T04:34:42.843Z" }, { url = "https://files.pythonhosted.org/packages/52/fc/e518013affcc43c9f919c3ba41bffe9b4ee4aceb0a6462a6243efcca5f2d/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e", size = 260860, upload-time = "2026-09-30T04:34:44.817Z" }, { url = "https://files.pythonhosted.org/packages/d5/ad/2a895c945ee61988dfd9ccee64f0b78dc09f29c9b34d1dd545246d78e0ad/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5", size = 246491, upload-time = "2026-09-30T04:34:46.227Z" }, { url = "https://files.pythonhosted.org/packages/c9/05/5d958bc8ea503e26be25ada5430fd409cfb45dc22ff33f9f96e649613c99/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa", size = 290875, upload-time = "2026-09-30T04:34:47.691Z" }, { url = "https://files.pythonhosted.org/packages/55/e2/06bad57dfdb49cad92c0ba85b6b4fa5827a67df37897287cfef0553843c2/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320", size = 286987, upload-time = "2026-09-30T04:34:49.356Z" }, { url = "https://files.pythonhosted.org/packages/c4/9f/9f52d2886d52645987d603425482c63c5045a3005db1354a7097e5ed1ae9/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249", size = 271105, upload-time = "2026-09-30T04:34:50.957Z" }, { url = "https://files.pythonhosted.org/packages/4a/41/e05e19578b7b87e7db2c3ecb884bd09d065274ab41e1f535542e8bae9b06/charset_normalizer-3.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e", size = 269833, upload-time = "2026-09-30T04:34:52.623Z" }, { url = "https://files.pythonhosted.org/packages/c2/cd/fc7152414561ff78f9c61a4a627025f65fd244abaefc99ac4e75c6169b33/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9", size = 263048, upload-time = "2026-09-30T04:34:54.013Z" }, { url = "https://files.pythonhosted.org/packages/71/71/fb379e399b1013962716a059d551e03bd80b3e02f05d81246886af6c0958/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5", size = 249237, upload-time = "2026-09-30T04:34:55.373Z" }, { url = "https://files.pythonhosted.org/packages/33/4f/aeadccd6d20882909eb2597ec40eccc05d505e2262f14dc76d7620700657/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c", size = 290756, upload-time = "2026-09-30T04:34:56.867Z" }, { url = "https://files.pythonhosted.org/packages/b9/7b/e8a92613236b257d3b75d532496eb21f68ad8231d7d56df94497e460112e/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c", size = 269026, upload-time = "2026-09-30T04:34:58.278Z" }, { url = "https://files.pythonhosted.org/packages/ff/ac/21d5c6b972285c5f095ff78afdc99f3539e3985dfcf7ff1cfbe9e772f529/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be", size = 287601, upload-time = "2026-09-30T04:34:59.703Z" }, { url = "https://files.pythonhosted.org/packages/59/72/263491ec1494a194b16fcbff88a0220f2af633738c79e92b4d7189226322/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45", size = 272660, upload-time = "2026-09-30T04:35:01.26Z" }, { url = "https://files.pythonhosted.org/packages/38/7a/81f1186c57e406233969ae96b5fd0c907254ab8b6e47da8ae24cd20fb9d7/charset_normalizer-3.5.2-cp310-cp310-win32.whl", hash = "sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4", size = 187301, upload-time = "2026-09-30T04:35:02.659Z" }, { url = "https://files.pythonhosted.org/packages/ae/ab/fb8b5178fdf7bf68efa61cbe892286bc9f73846f26147d4725fb89388475/charset_normalizer-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275", size = 215446, upload-time = "2026-09-30T04:35:04.011Z" }, { url = "https://files.pythonhosted.org/packages/36/40/e270b74e0d3f583d78d902960845a149e80eb7c737ee5613ccc6efc7264b/charset_normalizer-3.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c", size = 203389, upload-time = "2026-09-30T04:35:05.577Z" }, { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" }, { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" }, { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" }, { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" }, { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" }, { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" }, { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" }, { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" }, { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" }, { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" }, { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" }, { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" }, { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" }, { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, { url = "https://files.pythonhosted.org/packages/0b/f6/c57b072e0f764c29bc2bda17baaa01ea1a8d36874d9efbc0cfa2efb1b0e1/charset_normalizer-3.5.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac", size = 374315, upload-time = "2026-09-30T04:38:53.075Z" }, { url = "https://files.pythonhosted.org/packages/7a/44/a9ddc117221405ab41ba66653013f0e78e5c2cb9a5b22060ca1e7dccb1ea/charset_normalizer-3.5.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848", size = 261362, upload-time = "2026-09-30T04:38:54.937Z" }, { url = "https://files.pythonhosted.org/packages/05/22/6adc5456b690c7ec9316df39a7fe741c6d81ec91b9f9f8a798211e642784/charset_normalizer-3.5.2-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341", size = 246737, upload-time = "2026-09-30T04:38:56.572Z" }, { url = "https://files.pythonhosted.org/packages/52/4b/26dba4f3354ce6328c8cb13628b17202305f2172a352a32e10d677edb130/charset_normalizer-3.5.2-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4", size = 291601, upload-time = "2026-09-30T04:38:58.511Z" }, { url = "https://files.pythonhosted.org/packages/e8/6f/127c3697761666e246f44ab52d8c88ababf80c61fb70b3261b53a94c0d5c/charset_normalizer-3.5.2-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849", size = 287685, upload-time = "2026-09-30T04:39:00.101Z" }, { url = "https://files.pythonhosted.org/packages/96/dd/dddf3848de54804e5543750f390fce1f9bc3fa5404f58f87be262199bf6e/charset_normalizer-3.5.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2", size = 271408, upload-time = "2026-09-30T04:39:02.054Z" }, { url = "https://files.pythonhosted.org/packages/9f/de/21dd363e7ffac0f02caee93cc9bdb63b04adaf9bf7ce46dea742c9a51aad/charset_normalizer-3.5.2-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0", size = 270615, upload-time = "2026-09-30T04:39:03.855Z" }, { url = "https://files.pythonhosted.org/packages/6f/9f/bab04a5bcecc5c3fda3130b80131608e3f801de97753b2f1348b7c4ebdf7/charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f", size = 263768, upload-time = "2026-09-30T04:39:05.74Z" }, { url = "https://files.pythonhosted.org/packages/6e/31/b1e0a89e6cd464adbb8ebc674635834746bb8fa22631a243676121fbaf60/charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2", size = 249711, upload-time = "2026-09-30T04:39:07.494Z" }, { url = "https://files.pythonhosted.org/packages/5d/3b/7bd36236e57d4266ffad810c62b40a08001622d95195e575d60e78201f69/charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a", size = 291499, upload-time = "2026-09-30T04:39:09.558Z" }, { url = "https://files.pythonhosted.org/packages/90/8a/cee8e3024a1179723f9a96eafa6242e574527abc7e0492a2fa65fa988987/charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50", size = 269913, upload-time = "2026-09-30T04:39:11.121Z" }, { url = "https://files.pythonhosted.org/packages/7e/3e/53e12f2d5fda61dec0265e57b6abc17468381bfb77d78ad4b11cd1f5c45c/charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a", size = 288289, upload-time = "2026-09-30T04:39:12.946Z" }, { url = "https://files.pythonhosted.org/packages/c3/86/7b5cdf05e635c17c5a3f1e1b1ffc142b60e6f37b64af2e1979bd00ca8570/charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d", size = 273027, upload-time = "2026-09-30T04:39:14.639Z" }, { url = "https://files.pythonhosted.org/packages/8e/cb/489a7ae61ca4603a813c0a297b3a0dfa891597cbaf6ca13e428ab3ce80d4/charset_normalizer-3.5.2-cp39-cp39-win32.whl", hash = "sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03", size = 187705, upload-time = "2026-09-30T04:39:16.508Z" }, { url = "https://files.pythonhosted.org/packages/8d/44/94b15f1d938bc4b8411c483f947ba32952b71154c5550924be7031407d33/charset_normalizer-3.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787", size = 215771, upload-time = "2026-09-30T04:39:18.358Z" }, { url = "https://files.pythonhosted.org/packages/a8/a1/b983b62df4aedd9a740d2ae1eee43d48e2dc7f68695a1fbed9b773ccea23/charset_normalizer-3.5.2-cp39-cp39-win_arm64.whl", hash = "sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc", size = 203762, upload-time = "2026-09-30T04:39:19.983Z" }, { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "dkimpy" version = "1.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dnspython", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/6f/84e91828186bbfcedd7f9385ef5e0d369632444195c20e08951b7ffe0481/dkimpy-1.1.8.tar.gz", hash = "sha256:b5f60fb47bbf5d8d762f134bcea0c388eba6b498342a682a21f1686545094b77", size = 66979, upload-time = "2024-07-04T22:16:38.321Z" } [[package]] name = "dnspython" version = "2.7.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] [[package]] name = "dnspython" version = "2.8.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", ] sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "idna" version = "3.20" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] [[package]] name = "importlib-metadata" version = "9.0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ { name = "zipp", version = "4.1.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/55/ecca97ae19075f1fac62def77731e7f535e6c1fb8f92ff08160c5e6dade8/importlib_metadata-9.0.1-py3-none-any.whl", hash = "sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0", size = 27920, upload-time = "2026-08-28T15:30:33.433Z" }, ] [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", ] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "liblore" version = "0.10.1" source = { editable = "." } dependencies = [ { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] [package.optional-dependencies] auth = [ { name = "authheaders" }, ] [package.dev-dependencies] dev = [ { name = "authheaders" }, { name = "build", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "build", version = "1.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "mypy", version = "1.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "mypy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pyright" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest-asyncio", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "responses" }, { name = "ruff" }, { name = "ty" }, { name = "types-requests", version = "2.32.4.20260107", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "types-requests", version = "2.33.0.20260906", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "typing-extensions" }, ] [package.metadata] requires-dist = [ { name = "authheaders", marker = "extra == 'auth'", specifier = ">=0.15" }, { name = "requests", specifier = ">=2.31" }, ] provides-extras = ["auth"] [package.metadata.requires-dev] dev = [ { name = "authheaders", specifier = ">=0.15" }, { name = "build" }, { name = "mypy" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "responses" }, { name = "ruff" }, { name = "ty" }, { name = "types-requests" }, { name = "typing-extensions" }, ] [[package]] name = "librt" version = "0.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/77/900294b3c9a81415323d81b3f3f271a7f5d7ed9d01e824fc38ffa49470b6/librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6", size = 148668, upload-time = "2026-09-29T00:44:20.282Z" }, { url = "https://files.pythonhosted.org/packages/55/3b/c301e8cc47758e4518b93e926a0111080155cc79fa03651d202ac2025065/librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978", size = 153562, upload-time = "2026-09-29T00:44:21.672Z" }, { url = "https://files.pythonhosted.org/packages/62/b1/4d5b98114a58bf17e3832cd33400e40f2582f68c2fb7ce25b4468cb1afd3/librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd", size = 494337, upload-time = "2026-09-29T00:44:23.096Z" }, { url = "https://files.pythonhosted.org/packages/88/36/14223c9b0dc38982eb4f399732c8cb27b19c7e027ae0faef5ca51d08d19f/librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144", size = 485440, upload-time = "2026-09-29T00:44:24.555Z" }, { url = "https://files.pythonhosted.org/packages/ec/9e/d7155e6800d0c33e297e529b217649f7361d706491ebf9d2fc0fc627309e/librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671", size = 498363, upload-time = "2026-09-29T00:44:26.03Z" }, { url = "https://files.pythonhosted.org/packages/d3/ce/d5f1fb9f913ac0979d6fc5db9fb9c0bf28cc3470988528957922f15ed40c/librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262", size = 515453, upload-time = "2026-09-29T00:44:27.566Z" }, { url = "https://files.pythonhosted.org/packages/b4/d4/ca0fca1a27c26080d1a25b2b31f271e532078a5719878c3daca0c3d4c539/librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a", size = 509537, upload-time = "2026-09-29T00:44:29.14Z" }, { url = "https://files.pythonhosted.org/packages/ee/c0/26807bf2221a53a2c66f2e23a5c4d57f1c0eaa4afed7cb81d841cf3d1903/librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c", size = 532541, upload-time = "2026-09-29T00:44:30.745Z" }, { url = "https://files.pythonhosted.org/packages/0e/50/c329d509907e1bc061abeec3796f94d255d43ba25d28c3854d2138ed5ab2/librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726", size = 537059, upload-time = "2026-09-29T00:44:32.102Z" }, { url = "https://files.pythonhosted.org/packages/84/d6/4571b9c9e65d98e5503734166dce2fd3e216f526dc3c59a3773faa8a60e3/librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a", size = 536614, upload-time = "2026-09-29T00:44:33.653Z" }, { url = "https://files.pythonhosted.org/packages/c8/d3/a2ed8a292e4ab7889792a9c3f4ad5d1caf6557afce7f6b452e77f8ed20b7/librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1", size = 517199, upload-time = "2026-09-29T00:44:35.115Z" }, { url = "https://files.pythonhosted.org/packages/9e/24/276e5218b4b7adb6258a42b7403cb1b7cef25616238d0c2152288a9d2f53/librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143", size = 558746, upload-time = "2026-09-29T00:44:36.656Z" }, { url = "https://files.pythonhosted.org/packages/7c/5c/9156df81a1726e1efc9567379def2b8777a6d56f464d2f1b760bf951b8af/librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba", size = 104424, upload-time = "2026-09-29T00:44:38.123Z" }, { url = "https://files.pythonhosted.org/packages/0b/62/4fd172cb3f49ab2cf5ef17de519a93cfb935202c1270ba9d44c6a64178c3/librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155", size = 124267, upload-time = "2026-09-29T00:44:39.455Z" }, { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, { url = "https://files.pythonhosted.org/packages/42/f0/99f4942445dc37d9ea5a415fcc28dd9ceaec454c1e3953775b31d289c2d1/librt-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e1967e36ac4cae0c7e9615ad32e1a513cdacff79f9e8afb28bedc91caf48b4f3", size = 149546, upload-time = "2026-09-29T00:55:09.13Z" }, { url = "https://files.pythonhosted.org/packages/89/d9/7ccbf15bcfb132d022cdd40b6e46774d0f415fa0c8f893a9afd847480971/librt-0.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:20fe0bf9053885e21c62b3e091fb5e73e1c54d1daf2e70eb388d70763bcd4220", size = 154485, upload-time = "2026-09-29T00:55:10.602Z" }, { url = "https://files.pythonhosted.org/packages/ae/dc/2f47b74df1c9892978a95071b7c29937aebc99768eb9250fdd43a4a88ab3/librt-0.16.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a63610fa76524edfa605b5b259a603915c7a6e10e54f003e5503030506e81de", size = 493650, upload-time = "2026-09-29T00:55:12.17Z" }, { url = "https://files.pythonhosted.org/packages/80/15/dac01582ca498d79a06d080bf534851849c6c2561905d150f641370953b9/librt-0.16.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:845a511b60ca43b9880dcc84a9784c891d6a2098c829130b320846c69c9c0c68", size = 484442, upload-time = "2026-09-29T00:55:14.103Z" }, { url = "https://files.pythonhosted.org/packages/ca/39/e045152cb06664d6e3cc94df33f14eae16b7a6d01cb08ba83ed6978ddd3c/librt-0.16.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d608f0bf3b8cbddd0067fe02cb8cab7d13e8eb9386b23a1843d4363044fd9e22", size = 497487, upload-time = "2026-09-29T00:55:15.813Z" }, { url = "https://files.pythonhosted.org/packages/07/a1/13eae7b3d7cb436abdf1e682811ed46f09263b44d4e8b4461924e4e84942/librt-0.16.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df183721229ae51eef90108c115b43e98cb169b6155d34480338e5fc6616df00", size = 515086, upload-time = "2026-09-29T00:55:17.475Z" }, { url = "https://files.pythonhosted.org/packages/80/27/2a54834df1a27a119c32b1c61a0d772ae328e8909362f2b0264a2265483d/librt-0.16.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b6183e2e2e0ee00aac2ec07c7f7d151c97e85666b304f574b71cff0f9fccc4e", size = 509090, upload-time = "2026-09-29T00:55:19.335Z" }, { url = "https://files.pythonhosted.org/packages/08/49/8c3804b07aa808cf5ca5bb3b733f6a0e716a5df9e4eac50388a1faa5c9fb/librt-0.16.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:273d00be33792a15189331df10f1f1331621b043881e66c6c4377f7f776e1291", size = 531826, upload-time = "2026-09-29T00:55:21.313Z" }, { url = "https://files.pythonhosted.org/packages/fd/a0/c84aa0ef18d184b6b90a2c8443a1bf8dab8348ba5345251f15ec8dcca8d4/librt-0.16.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aa9357a1b4d4fc787bb718a59cb1112c28c8a976d6bfa268b71cc0a4ab8f3a94", size = 536895, upload-time = "2026-09-29T00:55:23.237Z" }, { url = "https://files.pythonhosted.org/packages/67/2e/480556b9043c4f774867b629b562af5a814434fe86b26cad80e15735b5cf/librt-0.16.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:e9ce0bc440e7fd09b5f51f372f0f6640658f854b8fb05260f819cc669c93c42d", size = 536003, upload-time = "2026-09-29T00:55:24.812Z" }, { url = "https://files.pythonhosted.org/packages/ca/20/bf46a5c1307353ae8bf7e738da944e875c47d336bddf08846ff7d02532e8/librt-0.16.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:6c8893eae2fd13c5488d94056f3e6e5cf3142bfb1c4acaf136cb33d760c5964b", size = 516696, upload-time = "2026-09-29T00:55:26.397Z" }, { url = "https://files.pythonhosted.org/packages/7f/7d/93187c5e619a980058caa6d8755e31655ea3d7277c077ac7ec33db0a30b3/librt-0.16.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7393c9a48dcce4817dbd4b0d8ff6237efe9b0a0609f5b0adaef315f8541726b5", size = 558589, upload-time = "2026-09-29T00:55:28.151Z" }, { url = "https://files.pythonhosted.org/packages/6b/f6/33f84f9816c65896250a6414cce852aa325272dcef3d67c08d89f67cfcd1/librt-0.16.0-cp39-cp39-win32.whl", hash = "sha256:71b93b42784e25b975079573c642a8fedb049a7bb31d70a51721b1666b3b2ced", size = 104947, upload-time = "2026-09-29T00:55:29.968Z" }, { url = "https://files.pythonhosted.org/packages/46/92/993b8c561f0225d84a1d56628c0a618a10088b3ca73153a0fc68c9d7541b/librt-0.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:5750a105b42a416f930edc59054927a406effb2550cd5bab92ad7a5842ed5d05", size = 125682, upload-time = "2026-09-29T00:55:31.417Z" }, ] [[package]] name = "mypy" version = "1.19.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "tomli" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, { url = "https://files.pythonhosted.org/packages/b5/f7/88436084550ca9af5e610fa45286be04c3b63374df3e021c762fe8c4369f/mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3", size = 13102606, upload-time = "2025-12-15T05:02:46.833Z" }, { url = "https://files.pythonhosted.org/packages/ca/a5/43dfad311a734b48a752790571fd9e12d61893849a01bff346a54011957f/mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a", size = 12164496, upload-time = "2025-12-15T05:03:41.947Z" }, { url = "https://files.pythonhosted.org/packages/88/f0/efbfa391395cce2f2771f937e0620cfd185ec88f2b9cd88711028a768e96/mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67", size = 12772068, upload-time = "2025-12-15T05:02:53.689Z" }, { url = "https://files.pythonhosted.org/packages/25/05/58b3ba28f5aed10479e899a12d2120d582ba9fa6288851b20bf1c32cbb4f/mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e", size = 13520385, upload-time = "2025-12-15T05:02:38.328Z" }, { url = "https://files.pythonhosted.org/packages/c5/a0/c006ccaff50b31e542ae69b92fe7e2f55d99fba3a55e01067dd564325f85/mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376", size = 13796221, upload-time = "2025-12-15T05:03:22.147Z" }, { url = "https://files.pythonhosted.org/packages/b2/ff/8bdb051cd710f01b880472241bd36b3f817a8e1c5d5540d0b761675b6de2/mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24", size = 10055456, upload-time = "2025-12-15T05:03:35.169Z" }, { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] name = "mypy" version = "2.3.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "nodeenv" version = "1.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, ] [[package]] name = "packaging" version = "26.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } 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/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 = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "publicsuffix2" version = "2.20191221" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/04/1759906c4c5b67b2903f546de234a824d4028ef24eb0b1122daa43376c20/publicsuffix2-2.20191221.tar.gz", hash = "sha256:00f8cc31aa8d0d5592a5ced19cccba7de428ebca985db26ac852d920ddd6fe7b", size = 99592, upload-time = "2019-12-21T11:30:44.863Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9d/16/053c2945c5e3aebeefb4ccd5c5e7639e38bc30ad1bdc7ce86c6d01707726/publicsuffix2-2.20191221-py2.py3-none-any.whl", hash = "sha256:786b5e36205b88758bd3518725ec8cfe7a8173f5269354641f581c6b80a99893", size = 89033, upload-time = "2019-12-21T11:30:41.744Z" }, ] [[package]] name = "pygments" version = "2.21.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] name = "pyproject-hooks" version = "1.3.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6d/5d/f2ddeef4a855a102aaae5e97826a0260007522ab504421b75addfdb1517c/pyproject_hooks-1.3.3.tar.gz", hash = "sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43", size = 21013, upload-time = "2026-09-16T08:58:03.999Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl", hash = "sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b", size = 10724, upload-time = "2026-09-16T08:58:02.96Z" }, ] [[package]] name = "pyright" version = "1.1.414" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, ] [[package]] name = "pytest" version = "8.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup" }, { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "pytest-asyncio" version = "1.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ { name = "backports-asyncio-runner" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" } }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] [[package]] name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" } }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] [[package]] name = "requests" version = "2.32.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3", version = "2.8.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "responses" version = "0.26.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "urllib3", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/47/f216a33221db8eff328987661cf18371afee89c62a62b434b963d6b509c9/responses-0.26.3.tar.gz", hash = "sha256:b0c11ca8131b8b227b8d5108e6ed39772222bd5aab030ed430e8f99057c4c409", size = 86335, upload-time = "2026-08-26T19:17:24.373Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/86/ca7958de70cb0752350575e98229368a3a2f746a2942034b3364e17312bb/responses-0.26.3-py3-none-any.whl", hash = "sha256:74474f799334ac4f37d93b6437ecc3bb1bb5c77a8d31780a338643be2dce0af8", size = 36289, upload-time = "2026-08-26T19:17:23.176Z" }, ] [[package]] name = "ruff" version = "0.16.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, ] [[package]] name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "ty" version = "0.0.84" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" }, { url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" }, { url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" }, { url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" }, { url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" }, { url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" }, { url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" }, { url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" }, { url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" }, { url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" }, { url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" }, { url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" }, { url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" }, { url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" }, { url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" }, { url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" }, { url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" }, ] [[package]] name = "types-requests" version = "2.32.4.20260107" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, ] [[package]] name = "types-requests" version = "2.33.0.20260906" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ { name = "urllib3", version = "2.8.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/18/4c2c0290953f8b3b9612adfcb07b57f144ade3ad32a76764fca42b77c5f3/types_requests-2.33.0.20260906.tar.gz", hash = "sha256:76ab8a0fb736744a0c3deee7aa57b2927e301f078d9e61f5391b3e92002416b9", size = 25263, upload-time = "2026-09-06T06:35:47.707Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/60/4c/51ec821d22a45b4162fa3f3e9e94ea4c0f8c49e82363b10955baa5438391/types_requests-2.33.0.20260906-py3-none-any.whl", hash = "sha256:9f53622652bd921ead7a54d665be1b8d518165c8b51cc9d68d944cd6b6bfa8fb", size = 21461, upload-time = "2026-09-06T06:35:45.856Z" }, ] [[package]] name = "typing-extensions" version = "4.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "urllib3" version = "2.6.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] name = "urllib3" version = "2.8.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", ] sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] name = "zipp" version = "3.23.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, ] [[package]] name = "zipp" version = "4.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.15'", ] sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ]