diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c28a314 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +--- +name: "Release" +on: + push: + tags: + - "v*" +jobs: + build: + runs-on: "ubuntu-22.04" + steps: + - name: Check out repository + uses: "actions/checkout@v4" + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + version: latest + - name: Build package + run: uv build + - name: "Save distribution directory" + uses: "actions/upload-artifact@v4" + with: + name: dist + path: dist + upload: + needs: "build" + runs-on: "ubuntu-22.04" + environment: "release" + permissions: + id-token: "write" + steps: + - name: "Restore distribution directory" + uses: "actions/download-artifact@v4" + with: + name: dist + path: dist + - name: "Upload distribution packages to PyPI" + uses: "pypa/gh-action-pypi-publish@release/v1" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1c6e10f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,44 @@ +--- +name: "Test" +on: + pull_request: + push: + branches: + - "main" +env: + CLAMD_UNIX_SOCKET: "/tmp/clamd.socket" + CLAMD_TCP_PORT: "3310" +jobs: + test: + name: "Test" + runs-on: "ubuntu-22.04" + steps: + - name: "Check out repository" + uses: "actions/checkout@v4" + - name: Start ClamAV daemon clamd + uses: toblux/start-clamd-github-action@bae519cc165de29b89cbb9c4528f61c34b1c848b # v0.2.1 + with: + unix_socket: ${{ env.CLAMD_UNIX_SOCKET }} + tcp_port: ${{ env.CLAMD_TCP_PORT }} + stream_max_length: 1M + db_main: true + db_daily: true + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + version: latest + - name: Run tests + run: ./test.sh + - name: "Upload coverage report" + if: github.repository == 'artefactual-labs/clamav-client' + uses: "codecov/codecov-action@v4" + with: + files: ./coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + files: ./junit.xml + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index ded6067..86217bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,9 @@ +.venv/ +__pycache__/ *.py[cod] - -# C extensions -*.so - -# Packages -*.egg -*.egg-info -dist -build -eggs -parts -bin -var -sdist -develop-eggs -.installed.cfg -lib -lib64 -__pycache__ - -# Installer logs -pip-log.txt - -# Unit test / coverage reports +*.egg-info/ +dist/ .coverage -.tox -nosetests.xml - -# Translations -*.mo - -# Mr Developer -.mr.developer.cfg -.project -.pydevproject +coverage.xml +htmlcov/ +junit.xml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3fc87ab..0000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: python -python: - - "2.5" - - "2.6" - - "2.7" - - "pypy" - - "3.3" - - "3.4" -install: - - sudo apt-get install clamav-daemon clamav-freshclam clamav-unofficial-sigs - - sudo freshclam --verbose - - sudo service clamav-daemon start - - pip install . -script: python setup.py nosetests -matrix: - allow_failures: - - python: "2.5" diff --git a/CHANGES.rst b/CHANGES.rst deleted file mode 100644 index 0477c20..0000000 --- a/CHANGES.rst +++ /dev/null @@ -1,40 +0,0 @@ -Changes -========= - -1.0.3 (unreleased) ------------------- - -- Nothing changed yet. - - -1.0.2 (2014-08-21) ------------------- - -- Remove all dependencies. clamd is now standalone! -- Use plain setuptools no d2to1. -- Create universal wheel. - - -1.0.1 (2013-03-06) ------------------- - -- Updated d2to1 dependency - - -1.0.0 (2013-02-08) ------------------- - -- Change public interface, including exceptions -- Support Python 3.3, withdraw 2.5 support - - -0.3.4 (2013-02-01) ------------------- - -- Use regex to parse file status reponse instead of complicated string split/join - - -0.3.3 (2013-01-28) ------------------- - -- First version of clamd that can be installed from PyPI diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + 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, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +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 + Ty Coon, 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. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index ad7f912..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,6 +0,0 @@ -include CHANGES.rst -include README.rst -include ez_setup.py - -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4116efb --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# clamav-client + +[![PyPI version](https://badge.fury.io/py/clamav-client.svg)](https://badge.fury.io/py/clamav-client) +[![GitHub CI](https://github.com/artefactual-labs/clamav-client/actions/workflows/test.yml/badge.svg)](https://github.com/artefactual-labs/clamav-client/actions/workflows/test.yml) +[![codecov](https://codecov.io/gh/artefactual-labs/clamav-client/branch/main/graph/badge.svg?token=ldznzhNTG0)](https://app.codecov.io/gh/artefactual-labs/clamav-client/tree/main) + +`clamav-client` is a portable Python module to use the ClamAV anti-virus engine +on Windows, Linux, MacOSX and other platforms. It requires a running instance +of the `clamd` daemon. + +This is a fork of [clamd] ([5c5e33b2]) created by Thomas Grainger. It introduces +type hints and tests exclusively against supported Python versions. + +## Basic usage + +The `clamav_client.clamd` module offers a client for the `clamd` daemon, +supporting both TCP and Unix sockets. + +The `clamav_client.scanner` module provides a high-level file scanner that works +with both `clamd` and `clamscan`. Use `clamav_client.get_scanner()` to configure +it. + + +[clamd]: https://github.com/graingert/python-clamd +[5c5e33b2]: https://github.com/graingert/python-clamd/commit/5c5e33b2dfd0499470e15abeb83efb6531ef9ab7 diff --git a/README.rst b/README.rst deleted file mode 100644 index 6fd999e..0000000 --- a/README.rst +++ /dev/null @@ -1,53 +0,0 @@ -clamd -===== - -.. image:: https://travis-ci.org/graingert/python-clamd.png?branch=master - :alt: travis build status - :target: https://travis-ci.org/graingert/python-clamd - -About ------ -`clamd` is a portable Python module to use the ClamAV anti-virus engine on -Windows, Linux, MacOSX and other platforms. It requires a running instance of -the `clamd` daemon. - -This is a fork of pyClamd v0.2.0 created by Philippe Lagadec and published on his website: http://www.decalage.info/en/python/pyclamd which in turn is a slightly improved version of pyClamd v0.1.1 created by Alexandre Norman and published on his website: http://xael.org/norman/python/pyclamd/ - -Usage ------ - -To use with a unix socket:: - - >>> import clamd - >>> cd = clamd.ClamdUnixSocket() - >>> cd.ping() - 'PONG' - >>> cd.version() # doctest: +ELLIPSIS - 'ClamAV ... - >>> cd.reload() - 'RELOADING' - -To scan a file:: - - >>> open('/tmp/EICAR','wb').write(clamd.EICAR) - >>> cd.scan('/tmp/EICAR') - {'/tmp/EICAR': ('FOUND', 'Eicar-Test-Signature')} - -To scan a stream:: - - >>> from six import BytesIO - >>> cd.instream(BytesIO(clamd.EICAR)) - {'stream': ('FOUND', 'Eicar-Test-Signature')} - - -License -------- -`clamd` is released as open-source software under the LGPL license. - -clamd Install -------------- -How to install the ClamAV daemon `clamd` under Ubuntu:: - - sudo apt-get install clamav-daemon clamav-freshclam clamav-unofficial-sigs - sudo freshclam - sudo service clamav-daemon start diff --git a/clamav_client/__init__.py b/clamav_client/__init__.py new file mode 100644 index 0000000..3ef29c2 --- /dev/null +++ b/clamav_client/__init__.py @@ -0,0 +1,9 @@ +import importlib.metadata + +from clamav_client.scanner import get_scanner + +__version__ = importlib.metadata.version("clamav_client") + +__all__ = [ + "get_scanner", +] diff --git a/src/clamd/__init__.py b/clamav_client/clamd.py similarity index 53% rename from src/clamd/__init__.py rename to clamav_client/clamd.py index b085886..5c58e4b 100644 --- a/src/clamd/__init__.py +++ b/clamav_client/clamd.py @@ -1,25 +1,27 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +"""A client for the ClamAV daemon (clamd), supporting both TCP and Unix socket +connections. -try: - __version__ = __import__('pkg_resources').get_distribution('clamd').version -except: - __version__ = '' +This module stays as close as possible to its original counterpart, the clamd +project on which this code is based, to maintain backward compatibility. +""" -# $Source$ - - -import socket -import sys -import struct import contextlib import re -import base64 +import socket +import struct +from typing import Any +from typing import BinaryIO +from typing import Optional +from typing import Union + +scan_response = re.compile( + r"^(?P[^:]+): ((?P.+?) )?(?P(FOUND|OK|ERROR))$" +) + -scan_response = re.compile(r"^(?P.*): ((?P.+) )?(?P(FOUND|OK|ERROR))$") -EICAR = base64.b64decode(b'WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E' \ - b'QVJELUFOVElWSVJVUy1URVNU\nLUZJTEUhJEgrSCo=\n') +ScanStatus = str +ScanResult = tuple[ScanStatus, Optional[str]] +ScanResults = dict[str, ScanResult] class ClamdError(Exception): @@ -34,15 +36,18 @@ class BufferTooLongError(ResponseError): """Class for errors with clamd using INSTREAM with a buffer lenght > StreamMaxLength in /etc/clamav/clamd.conf""" -class ConnectionError(ClamdError): +class CommunicationError(ClamdError): """Class for errors communication with clamd""" -class ClamdNetworkSocket(object): +class ClamdNetworkSocket: """ Class for using clamd with a network socket """ - def __init__(self, host='127.0.0.1', port=3310, timeout=None): + + def __init__( + self, host: str = "127.0.0.1", port: int = 3310, timeout: Optional[float] = None + ) -> None: """ class initialisation @@ -50,51 +55,39 @@ class initialisation port (int) : TCP port timeout (float or None) : socket timeout """ - self.host = host self.port = port self.timeout = timeout - def _init_socket(self): + def _init_socket(self) -> None: """ internal use only """ try: self.clamd_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.clamd_socket.connect((self.host, self.port)) self.clamd_socket.settimeout(self.timeout) + self.clamd_socket.connect((self.host, self.port)) + except OSError as err: + raise CommunicationError(self._error_message(err)) from err - except socket.error: - e = sys.exc_info()[1] - raise ConnectionError(self._error_message(e)) - - def _error_message(self, exception): + def _error_message(self, exception: BaseException) -> str: # args for socket.error can either be (errno, "message") # or just "message" if len(exception.args) == 1: - return "Error connecting to {host}:{port}. {msg}.".format( - host=self.host, - port=self.port, - msg=exception.args[0] - ) + return f"Error connecting to {self.host}:{self.port}. {exception.args[0]}." else: - return "Error {erno} connecting {host}:{port}. {msg}.".format( - erno=exception.args[0], - host=self.host, - port=self.port, - msg=exception.args[1] - ) - - def ping(self): + return f"Error {exception.args[0]} connecting {self.host}:{self.port}. {exception.args[1]}." + + def ping(self) -> str: return self._basic_command("PING") - def version(self): + def version(self) -> str: return self._basic_command("VERSION") - def reload(self): + def reload(self) -> str: return self._basic_command("RELOAD") - def shutdown(self): + def shutdown(self) -> None: """ Force Clamd to shutdown and exit @@ -105,36 +98,38 @@ def shutdown(self): """ try: self._init_socket() - self._send_command('SHUTDOWN') - # result = self._recv_response() + self._send_command("SHUTDOWN") finally: self._close_socket() - def scan(self, file): - return self._file_system_scan('SCAN', file) + def scan(self, file: str) -> ScanResults: + return self._file_system_scan("SCAN", file) - def contscan(self, file): - return self._file_system_scan('CONTSCAN', file) + def contscan(self, file: str) -> ScanResults: + return self._file_system_scan("CONTSCAN", file) - def multiscan(self, file): - return self._file_system_scan('MULTISCAN', file) + def multiscan(self, file: str) -> ScanResults: + return self._file_system_scan("MULTISCAN", file) - def _basic_command(self, command): + def _basic_command(self, command: str) -> str: """ Send a command to the clamav server, and return the reply. """ self._init_socket() try: self._send_command(command) - response = self._recv_response().rsplit("ERROR", 1) - if len(response) > 1: - raise ResponseError(response[0]) + response = self._recv_response() + if response is None: + raise ResponseError() + error = response.rsplit("ERROR", 1) + if len(error) > 1: + raise ResponseError(error[0]) else: - return response[0] + return error[0] finally: self._close_socket() - def _file_system_scan(self, command, file): + def _file_system_scan(self, command: str, file: str) -> ScanResults: """ Scan a file or directory given by filename using multiple threads (faster on SMP machines). Do not stop on error or virus found. @@ -148,23 +143,22 @@ def _file_system_scan(self, command, file): May raise: - ConnectionError: in case of communication problem """ - try: self._init_socket() self._send_command(command, file) - dr = {} - for result in self._recv_response_multiline().split('\n'): + response = self._recv_response_multiline() + if response is None: + raise ResponseError() + for result in response.split("\n"): if result: filename, reason, status = self._parse_response(result) dr[filename] = (status, reason) - return dr - finally: self._close_socket() - def instream(self, buff): + def instream(self, buff: BinaryIO) -> ScanResults: """ Scan a buffer @@ -177,33 +171,28 @@ def instream(self, buff): - BufferTooLongError: if the buffer size exceeds clamd limits - ConnectionError: in case of communication problem """ - try: self._init_socket() - self._send_command('INSTREAM') - + self._send_command("INSTREAM") max_chunk_size = 1024 # MUST be < StreamMaxLength in /etc/clamav/clamd.conf - chunk = buff.read(max_chunk_size) while chunk: - size = struct.pack(b'!L', len(chunk)) - self.clamd_socket.send(size + chunk) + size = struct.pack(b"!L", len(chunk)) + self.clamd_socket.sendall(size + chunk) chunk = buff.read(max_chunk_size) - - self.clamd_socket.send(struct.pack(b'!L', 0)) - + self.clamd_socket.sendall(struct.pack(b"!L", 0)) result = self._recv_response() - if len(result) > 0: - if result == 'INSTREAM size limit exceeded. ERROR': + if result == "INSTREAM size limit exceeded. ERROR": raise BufferTooLongError(result) - filename, reason, status = self._parse_response(result) return {filename: (status, reason)} + else: + return {} finally: self._close_socket() - def stats(self): + def stats(self) -> str: """ Get Clamscan stats @@ -214,59 +203,59 @@ def stats(self): """ self._init_socket() try: - self._send_command('STATS') + self._send_command("STATS") return self._recv_response_multiline() finally: self._close_socket() - def _send_command(self, cmd, *args): + def _send_command(self, cmd: str, *args: str) -> None: """ `man clamd` recommends to prefix commands with z, but we will use \n terminated strings, as python<->clamd has some problems with \0x00 """ - concat_args = '' + concat_args = "" if args: - concat_args = ' ' + ' '.join(args) + concat_args = " " + " ".join(args) + send = f"n{cmd}{concat_args}\n".encode() + self.clamd_socket.sendall(send) - cmd = 'n{cmd}{args}\n'.format(cmd=cmd, args=concat_args).encode('utf-8') - self.clamd_socket.send(cmd) - - def _recv_response(self): + def _recv_response(self) -> str: """ receive line from clamd """ try: - with contextlib.closing(self.clamd_socket.makefile('rb')) as f: - return f.readline().decode('utf-8').strip() - except (socket.error, socket.timeout): - e = sys.exc_info()[1] - raise ConnectionError("Error while reading from socket: {0}".format(e.args)) - - def _recv_response_multiline(self): + with contextlib.closing(self.clamd_socket.makefile("rb")) as f: + return f.readline().decode("utf-8").strip() + except (OSError, socket.timeout) as err: + raise CommunicationError( + f"Error while reading from socket: {err.args}" + ) from err + + def _recv_response_multiline(self) -> str: """ receive multiple line response from clamd and strip all whitespace characters """ try: - with contextlib.closing(self.clamd_socket.makefile('rb')) as f: - return f.read().decode('utf-8') - except (socket.error, socket.timeout): - e = sys.exc_info()[1] - raise ConnectionError("Error while reading from socket: {0}".format(e.args)) - - def _close_socket(self): + with contextlib.closing(self.clamd_socket.makefile("rb")) as f: + return f.read().decode("utf-8") + except (OSError, socket.timeout) as err: + raise CommunicationError( + f"Error while reading from socket: {err.args}" + ) from err + + def _close_socket(self) -> None: """ close clamd socket """ self.clamd_socket.close() - return - def _parse_response(self, msg): + def _parse_response(self, msg: str) -> tuple[Union[str, Any], ...]: """ parses responses for SCAN, CONTSCAN, MULTISCAN and STREAM commands. """ - try: - return scan_response.match(msg).group("path", "virus", "status") - except AttributeError: + if match := scan_response.match(msg): + return match.group("path", "virus", "status") + else: raise ResponseError(msg.rsplit("ERROR", 1)[0]) @@ -274,7 +263,10 @@ class ClamdUnixSocket(ClamdNetworkSocket): """ Class for using clamd with an unix socket """ - def __init__(self, path="/var/run/clamav/clamd.ctl", timeout=None): + + def __init__( + self, path: str = "/var/run/clamav/clamd.ctl", timeout: Optional[int] = None + ) -> None: """ class initialisation @@ -282,10 +274,13 @@ class initialisation timeout (float or None) : socket timeout """ + scheme = "unix://" + if path.startswith(scheme): + path = path[len(scheme) :] self.unix_socket = path self.timeout = timeout - def _init_socket(self): + def _init_socket(self) -> None: """ internal use only """ @@ -293,21 +288,13 @@ def _init_socket(self): self.clamd_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.clamd_socket.connect(self.unix_socket) self.clamd_socket.settimeout(self.timeout) - except socket.error: - e = sys.exc_info()[1] - raise ConnectionError(self._error_message(e)) + except OSError as err: + raise CommunicationError(self._error_message(err)) from err - def _error_message(self, exception): + def _error_message(self, exception: BaseException) -> str: # args for socket.error can either be (errno, "message") # or just "message" if len(exception.args) == 1: - return "Error connecting to {path}. {msg}.".format( - path=self.unix_socket, - msg=exception.args[0] - ) + return f"Error connecting to {self.unix_socket}. {exception.args[0]}." else: - return "Error {erno} connecting {path}. {msg}.".format( - erno=exception.args[0], - path=self.unix_socket, - msg=exception.args[1] - ) + return f"Error {exception.args[0]} connecting {self.unix_socket}. {exception.args[1]}." diff --git a/clamav_client/py.typed b/clamav_client/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py new file mode 100644 index 0000000..b246847 --- /dev/null +++ b/clamav_client/scanner.py @@ -0,0 +1,258 @@ +"""A general-purpose scanner compatible with both ``clamd`` and ``clamscan``.""" + +import abc +import re +from dataclasses import dataclass +from errno import EPIPE +from subprocess import STDOUT +from subprocess import CalledProcessError +from subprocess import check_output +from typing import Any +from typing import Literal +from typing import Optional +from typing import TypedDict +from typing import Union +from typing import cast +from urllib.parse import urlparse + +from clamav_client.clamd import BufferTooLongError +from clamav_client.clamd import ClamdNetworkSocket +from clamav_client.clamd import ClamdUnixSocket +from clamav_client.clamd import CommunicationError +from clamav_client.clamd import ScanResults + +ProgramName = Literal[ + "ClamAV (clamd)", + "ClamAV (clamscan)", +] + + +@dataclass +class ScannerInfo: + """ + Provides information of the ClamAV backend. + """ + + name: ProgramName + version: str + virus_definitions: Optional[str] + + +ScanResultState = Optional[Literal["ERROR", "OK", "FOUND"]] +ScanResultDetails = Optional[str] + + +@dataclass +class ScanResult: + """ + Represents the result of a file scan operation. + + The ``filename`` is the name of the file scanned. The ``state`` of the scan + can be ``None`` if the scan has not been completed yet, or one of ``ERROR``, + ``OK``, or ``FOUND`` if the scan finished. The ``details`` field may be + provided by the implementor to include error messages, detected threats, or + additional information. + """ + + filename: str + state: ScanResultState + details: ScanResultDetails + err: Optional[Exception] + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, ScanResult): + return NotImplemented + return ( + self.filename == other.filename + and self.state == other.state + and self.details == other.details + and str(self.err) == str(other.err) + ) + + def update( + self, + state: ScanResultState, + details: ScanResultDetails, + err: Optional[Exception] = None, + ) -> "ScanResult": + self.state = state + self.details = details + self.err = err + return self + + @property + def passed(self) -> Optional[bool]: + """Indicates whether the file passed the virus scan. + + The ``passed`` property returns ``True`` if the scan completed + successfully and no virus was found (``state == "OK"``), ``False`` if + the scan failed due to a virus or another error, and ``None`` if the + scan was not performed, typically due to issues such as the stream + exceeding its maximum length or connection-related errors. + """ + if self.err is None: + return self.state == "OK" + elif isinstance(self.err, (BufferTooLongError, CommunicationError)): + return None + elif isinstance(self.err, OSError) and self.err.errno == EPIPE: + return None + else: + return False + + +class Scanner(abc.ABC): + _info: dict[ProgramName, ScannerInfo] + _program: ProgramName + + @abc.abstractmethod + def scan(self, filename: str) -> ScanResult: + """Scan a file.""" + + def info(self) -> ScannerInfo: + """Fetch information of the current backend.""" + if not hasattr(self, "_info"): + self._info = {} + try: + return self._info[self._program] + except KeyError: + self._info[self._program] = self._parse_version(self._get_version()) + return self._info[self._program] + + @abc.abstractmethod + def _get_version(self) -> str: + """Return the program version details.""" + + def _parse_version(self, version: str) -> ScannerInfo: + parts = version.strip().split("/") + n = len(parts) + if n == 1: + version = parts[0] + if re.match("^ClamAV", version): + return ScannerInfo(self._program, version, None) + elif n == 3: + version, defs, date = parts + return ScannerInfo(self._program, version, f"{defs}/{date}") + raise ValueError("Cannot extract scanner information.") + + +class ClamdScannerConfig(TypedDict, total=False): + backend: Literal["clamd"] + address: str + timeout: float + stream: bool + + +class ClamdScanner(Scanner): + _program = "ClamAV (clamd)" + + def __init__(self, config: ClamdScannerConfig): + self.address = config.get("address", "/var/run/clamav/clamd.ctl") + self.timeout = config.get("timeout", float(86400)) + self.stream = config.get("stream", True) + self.client = self.get_client() + + def get_client(self) -> Union["ClamdNetworkSocket", "ClamdUnixSocket"]: + parsed = urlparse(f"//{self.address}", scheme="dummy") + if parsed.scheme == "unix" or not parsed.hostname: + return ClamdUnixSocket(path=self.address, timeout=int(self.timeout)) + elif parsed.hostname and parsed.port: + return ClamdNetworkSocket( + host=parsed.hostname, port=parsed.port, timeout=self.timeout + ) + else: + raise ValueError(f"Invalid address format: {self.address}") + + def scan(self, filename: str) -> ScanResult: + result = ScanResult(filename=filename, state=None, details=None, err=None) + method_name = "_pass_by_stream" if self.stream else "_pass_by_reference" + report_key = "stream" if self.stream else filename + try: + method = getattr(self, method_name) + report = method(filename) + except Exception as err: + return result.update(state="ERROR", details=str(err), err=err) + file_report = report.get(report_key) + if file_report is None: + return result + state, details = file_report + return result.update(state, details) + + def _get_version(self) -> str: + return self.client.version() + + def _pass_by_reference(self, filename: str) -> ScanResults: + return self.client.scan(filename) + + def _pass_by_stream(self, filename: str) -> ScanResults: + return self.client.instream(open(filename, "rb")) + + +class ClamscanScannerConfig(TypedDict, total=False): + backend: Literal["clamscan"] + max_file_size: float + max_scan_size: float + + +class ClamscanScanner(Scanner): + _program = "ClamAV (clamscan)" + _command = "clamscan" + + found_pattern = re.compile(r":\s([A-Za-z0-9._-]+)\sFOUND") + + def __init__(self, config: ClamscanScannerConfig) -> None: + self.max_file_size = config.get("max_file_size", float(2000)) + self.max_scan_size = config.get("max_scan_size", float(2000)) + + def _call(self, *args: str) -> bytes: + return check_output((self._command,) + args, stderr=STDOUT) + + def scan(self, filename: str) -> ScanResult: + result = ScanResult(filename=filename, state=None, details=None, err=None) + max_file_size = f"--max-filesize={int(self.max_file_size)}M" + max_scan_size = f"--max-scansize={int(self.max_scan_size)}M" + try: + self._call(max_file_size, max_scan_size, "--no-summary", filename) + except CalledProcessError as err: + if err.returncode == 1: + result.update("FOUND", self._parse_found(err.output)) + else: + result.update("ERROR", self._parse_error(err.output)) + else: + result.update("OK", None) + return result + + def _get_version(self) -> str: + return self._call("-V").decode("utf-8") + + def _parse_error(self, output: Any) -> Optional[str]: + if output is None or not isinstance(output, bytes): + return None + try: + decoded: str = output.decode("utf-8") + return decoded.split("\n")[0] + except Exception: + return None + + def _parse_found(self, output: Any) -> Optional[str]: + if output is None or not isinstance(output, bytes): + return None + try: + stdout = output.decode("utf-8") + match = self.found_pattern.search(stdout) + return match.group(1) if match else None + except Exception: + return None + + +ScannerConfig = Union[ClamdScannerConfig, ClamscanScannerConfig] + + +def get_scanner(config: Optional[ScannerConfig] = None) -> Scanner: + if config is None: + config = {"backend": "clamscan"} + backend = config.get("backend") + if backend == "clamscan": + return ClamscanScanner(cast(ClamscanScannerConfig, config)) + elif backend == "clamd": + return ClamdScanner(cast(ClamdScannerConfig, config)) + raise ValueError(f"Unsupported backend type: {backend}") diff --git a/ez_setup.py b/ez_setup.py deleted file mode 100644 index b2435fe..0000000 --- a/ez_setup.py +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env python -"""Bootstrap setuptools installation - -To use setuptools in your package's setup.py, include this -file in the same directory and add this to the top of your setup.py:: - - from ez_setup import use_setuptools - use_setuptools() - -To require a specific version of setuptools, set a download -mirror, or use an alternate download directory, simply supply -the appropriate options to ``use_setuptools()``. - -This file can also be run as a script to install or upgrade setuptools. -""" -import os -import shutil -import sys -import tempfile -import zipfile -import optparse -import subprocess -import platform -import textwrap -import contextlib - -from distutils import log - -try: - from urllib.request import urlopen -except ImportError: - from urllib2 import urlopen - -try: - from site import USER_SITE -except ImportError: - USER_SITE = None - -DEFAULT_VERSION = "5.4.2" -DEFAULT_URL = "https://pypi.python.org/packages/source/s/setuptools/" - -def _python_cmd(*args): - """ - Return True if the command succeeded. - """ - args = (sys.executable,) + args - return subprocess.call(args) == 0 - - -def _install(archive_filename, install_args=()): - with archive_context(archive_filename): - # installing - log.warn('Installing Setuptools') - if not _python_cmd('setup.py', 'install', *install_args): - log.warn('Something went wrong during the installation.') - log.warn('See the error message above.') - # exitcode will be 2 - return 2 - - -def _build_egg(egg, archive_filename, to_dir): - with archive_context(archive_filename): - # building an egg - log.warn('Building a Setuptools egg in %s', to_dir) - _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) - # returning the result - log.warn(egg) - if not os.path.exists(egg): - raise IOError('Could not build the egg.') - - -class ContextualZipFile(zipfile.ZipFile): - """ - Supplement ZipFile class to support context manager for Python 2.6 - """ - - def __enter__(self): - return self - - def __exit__(self, type, value, traceback): - self.close() - - def __new__(cls, *args, **kwargs): - """ - Construct a ZipFile or ContextualZipFile as appropriate - """ - if hasattr(zipfile.ZipFile, '__exit__'): - return zipfile.ZipFile(*args, **kwargs) - return super(ContextualZipFile, cls).__new__(cls) - - -@contextlib.contextmanager -def archive_context(filename): - # extracting the archive - tmpdir = tempfile.mkdtemp() - log.warn('Extracting in %s', tmpdir) - old_wd = os.getcwd() - try: - os.chdir(tmpdir) - with ContextualZipFile(filename) as archive: - archive.extractall() - - # going in the directory - subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) - os.chdir(subdir) - log.warn('Now working in %s', subdir) - yield - - finally: - os.chdir(old_wd) - shutil.rmtree(tmpdir) - - -def _do_download(version, download_base, to_dir, download_delay): - egg = os.path.join(to_dir, 'setuptools-%s-py%d.%d.egg' - % (version, sys.version_info[0], sys.version_info[1])) - if not os.path.exists(egg): - archive = download_setuptools(version, download_base, - to_dir, download_delay) - _build_egg(egg, archive, to_dir) - sys.path.insert(0, egg) - - # Remove previously-imported pkg_resources if present (see - # https://bitbucket.org/pypa/setuptools/pull-request/7/ for details). - if 'pkg_resources' in sys.modules: - del sys.modules['pkg_resources'] - - import setuptools - setuptools.bootstrap_install_from = egg - - -def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, - to_dir=os.curdir, download_delay=15): - to_dir = os.path.abspath(to_dir) - rep_modules = 'pkg_resources', 'setuptools' - imported = set(sys.modules).intersection(rep_modules) - try: - import pkg_resources - except ImportError: - return _do_download(version, download_base, to_dir, download_delay) - try: - pkg_resources.require("setuptools>=" + version) - return - except pkg_resources.DistributionNotFound: - return _do_download(version, download_base, to_dir, download_delay) - except pkg_resources.VersionConflict as VC_err: - if imported: - msg = textwrap.dedent(""" - The required version of setuptools (>={version}) is not available, - and can't be installed while this script is running. Please - install a more recent version first, using - 'easy_install -U setuptools'. - - (Currently using {VC_err.args[0]!r}) - """).format(VC_err=VC_err, version=version) - sys.stderr.write(msg) - sys.exit(2) - - # otherwise, reload ok - del pkg_resources, sys.modules['pkg_resources'] - return _do_download(version, download_base, to_dir, download_delay) - -def _clean_check(cmd, target): - """ - Run the command to download target. If the command fails, clean up before - re-raising the error. - """ - try: - subprocess.check_call(cmd) - except subprocess.CalledProcessError: - if os.access(target, os.F_OK): - os.unlink(target) - raise - -def download_file_powershell(url, target): - """ - Download the file at url to target using Powershell (which will validate - trust). Raise an exception if the command cannot complete. - """ - target = os.path.abspath(target) - ps_cmd = ( - "[System.Net.WebRequest]::DefaultWebProxy.Credentials = " - "[System.Net.CredentialCache]::DefaultCredentials; " - "(new-object System.Net.WebClient).DownloadFile(%(url)r, %(target)r)" - % vars() - ) - cmd = [ - 'powershell', - '-Command', - ps_cmd, - ] - _clean_check(cmd, target) - -def has_powershell(): - if platform.system() != 'Windows': - return False - cmd = ['powershell', '-Command', 'echo test'] - with open(os.path.devnull, 'wb') as devnull: - try: - subprocess.check_call(cmd, stdout=devnull, stderr=devnull) - except Exception: - return False - return True - -download_file_powershell.viable = has_powershell - -def download_file_curl(url, target): - cmd = ['curl', url, '--silent', '--output', target] - _clean_check(cmd, target) - -def has_curl(): - cmd = ['curl', '--version'] - with open(os.path.devnull, 'wb') as devnull: - try: - subprocess.check_call(cmd, stdout=devnull, stderr=devnull) - except Exception: - return False - return True - -download_file_curl.viable = has_curl - -def download_file_wget(url, target): - cmd = ['wget', url, '--quiet', '--output-document', target] - _clean_check(cmd, target) - -def has_wget(): - cmd = ['wget', '--version'] - with open(os.path.devnull, 'wb') as devnull: - try: - subprocess.check_call(cmd, stdout=devnull, stderr=devnull) - except Exception: - return False - return True - -download_file_wget.viable = has_wget - -def download_file_insecure(url, target): - """ - Use Python to download the file, even though it cannot authenticate the - connection. - """ - src = urlopen(url) - try: - # Read all the data in one block. - data = src.read() - finally: - src.close() - - # Write all the data in one block to avoid creating a partial file. - with open(target, "wb") as dst: - dst.write(data) - -download_file_insecure.viable = lambda: True - -def get_best_downloader(): - downloaders = ( - download_file_powershell, - download_file_curl, - download_file_wget, - download_file_insecure, - ) - viable_downloaders = (dl for dl in downloaders if dl.viable()) - return next(viable_downloaders, None) - -def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, - to_dir=os.curdir, delay=15, downloader_factory=get_best_downloader): - """ - Download setuptools from a specified location and return its filename - - `version` should be a valid setuptools version number that is available - as an egg for download under the `download_base` URL (which should end - with a '/'). `to_dir` is the directory where the egg will be downloaded. - `delay` is the number of seconds to pause before an actual download - attempt. - - ``downloader_factory`` should be a function taking no arguments and - returning a function for downloading a URL to a target. - """ - # making sure we use the absolute path - to_dir = os.path.abspath(to_dir) - zip_name = "setuptools-%s.zip" % version - url = download_base + zip_name - saveto = os.path.join(to_dir, zip_name) - if not os.path.exists(saveto): # Avoid repeated downloads - log.warn("Downloading %s", url) - downloader = downloader_factory() - downloader(url, saveto) - return os.path.realpath(saveto) - -def _build_install_args(options): - """ - Build the arguments to 'python setup.py install' on the setuptools package - """ - return ['--user'] if options.user_install else [] - -def _parse_args(): - """ - Parse the command line for options - """ - parser = optparse.OptionParser() - parser.add_option( - '--user', dest='user_install', action='store_true', default=False, - help='install in user site package (requires Python 2.6 or later)') - parser.add_option( - '--download-base', dest='download_base', metavar="URL", - default=DEFAULT_URL, - help='alternative URL from where to download the setuptools package') - parser.add_option( - '--insecure', dest='downloader_factory', action='store_const', - const=lambda: download_file_insecure, default=get_best_downloader, - help='Use internal, non-validating downloader' - ) - parser.add_option( - '--version', help="Specify which version to download", - default=DEFAULT_VERSION, - ) - options, args = parser.parse_args() - # positional arguments are ignored - return options - -def main(): - """Install or upgrade setuptools and EasyInstall""" - options = _parse_args() - archive = download_setuptools( - version=options.version, - download_base=options.download_base, - downloader_factory=options.downloader_factory, - ) - return _install(archive, _build_install_args(options)) - -if __name__ == '__main__': - sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cdbdef2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "clamav-client" +dynamic = ["version"] +description = "Python client library for the ClamAV antivirus." +readme = "README.md" +license = { text = "GPL-2.0" } +requires-python = ">=3.9" +dependencies = [] + +[project.urls] +Homepage = "https://github.com/artefactual-labs/clamav-client" +Documentation = "https://github.com/artefactual-labs/clamav-client/blob/main/README.md" +Changes = "https://github.com/artefactual-labs/clamav-client/deployments" +Source = "https://github.com/artefactual-labs/clamav-client" + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "vcs" + +[tool.pytest.ini_options] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] + +[tool.uv] +dev-dependencies = [ + "mypy>=1.15.0", + "pytest-cov>=6.1.1", + "pytest>=8.3.5", + "ruff>=0.11.9", +] + +[tool.coverage.run] +branch = true +source_pkgs = ["clamav_client"] + +[tool.mypy] +files = ["clamav_client", "tests"] +strict = true + +[tool.ruff.lint] +extend-select = ["A", "B", "I", "Q", "UP"] + +[tool.ruff.lint.isort] +force-single-line = true diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index c5f2335..0000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[nosetests] -with-doctest=1 - -[wheel] -universal=1 diff --git a/setup.py b/setup.py deleted file mode 100755 index f9501a6..0000000 --- a/setup.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python -from ez_setup import use_setuptools -use_setuptools("0.7.0") - -from setuptools import setup, find_packages - -readme = open('README.rst').read() -history = open('CHANGES.rst').read().replace('.. :changelog:', '') - -setup( - name="clamd", - version='1.0.3.dev0', - author="Thomas Grainger", - author_email="python-clamd@graingert.co.uk", - maintainer="Thomas Grainger", - maintainer_email = "python-clamd@graingert.co.uk", - keywords = "python, clamav, antivirus, scanner, virus, libclamav, clamd", - description = "Clamd is a python interface to Clamd (Clamav daemon).", - long_description=readme + '\n\n' + history, - url="https://github.com/graingert/python-clamd", - package_dir={'': 'src'}, - packages=find_packages('src', exclude="tests"), - classifiers = [ - "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", - ], - tests_require = ( - "nose==1.3.3", - "six==1.7.3", - ), - test_suite='nose.collector', - zip_safe=True, - include_package_data=False, -) diff --git a/src/tests/test_api.py b/src/tests/test_api.py deleted file mode 100644 index f550a0b..0000000 --- a/src/tests/test_api.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -from __future__ import unicode_literals -import clamd -from six import BytesIO -from contextlib import contextmanager -import tempfile -import shutil -import os -import stat - -from nose.tools import ok_, eq_, assert_true, raises - -mine = (stat.S_IREAD | stat.S_IWRITE) -other = stat.S_IROTH -execute = (stat.S_IEXEC | stat.S_IXOTH) - - -@contextmanager -def mkdtemp(*args, **kwargs): - temp_dir = tempfile.mkdtemp(*args, **kwargs) - try: - yield temp_dir - finally: - shutil.rmtree(temp_dir) - - -class TestUnixSocket(object): - def __init__(self): - self.kwargs = {} - - def setup(self): - self.cd = clamd.ClamdUnixSocket(**self.kwargs) - - def test_ping(self): - assert_true(self.cd.ping()) - - def test_version(self): - ok_(self.cd.version().startswith("ClamAV")) - - def test_reload(self): - eq_(self.cd.reload(), 'RELOADING') - - def test_scan(self): - with tempfile.NamedTemporaryFile('wb', prefix="python-clamd") as f: - f.write(clamd.EICAR) - f.flush() - os.fchmod(f.fileno(), (mine | other)) - eq_(self.cd.scan(f.name), - {f.name: ('FOUND', 'Eicar-Test-Signature')} - ) - - def test_unicode_scan(self): - with tempfile.NamedTemporaryFile('wb', prefix=u"python-clamdλ") as f: - f.write(clamd.EICAR) - f.flush() - os.fchmod(f.fileno(), (mine | other)) - eq_(self.cd.scan(f.name), - {f.name: ('FOUND', 'Eicar-Test-Signature')} - ) - - def test_multiscan(self): - expected = {} - with mkdtemp(prefix="python-clamd") as d: - for i in range(10): - with open(os.path.join(d, "file" + str(i)), 'wb') as f: - f.write(clamd.EICAR) - os.fchmod(f.fileno(), (mine | other)) - expected[f.name] = ('FOUND', 'Eicar-Test-Signature') - os.chmod(d, (mine | other | execute)) - - eq_(self.cd.multiscan(d), expected) - - def test_instream(self): - eq_( - self.cd.instream(BytesIO(clamd.EICAR)), - {'stream': ('FOUND', 'Eicar-Test-Signature')} - ) - - def test_insteam_success(self): - eq_( - self.cd.instream(BytesIO(b"foo")), - {'stream': ('OK', None)} - ) - - -class TestUnixSocketTimeout(TestUnixSocket): - def __init__(self): - self.kwargs = {"timeout": 20} - - -@raises(clamd.ConnectionError) -def test_cannot_connect(): - clamd.ClamdUnixSocket(path="/tmp/404").ping() - - -# class TestNetworkSocket(TestUnixSocket): -# def setup(self): -# self.cd = clamd.ClamdNetworkSocket() diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..8305bc9 --- /dev/null +++ b/test.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -euo pipefail + +versions=( + "3.9" + "3.10" + "3.11" + "3.12" + "3.13" +) + +prereleases=( + "3.14" +) + +print_status() { + echo -en "\n➡️ $1\n\n" +} + +if ! command -v uv > /dev/null; then + echo "Error: 'uv' is not installed or not in the PATH." + echo "To install it, run:" + echo " $ curl -LsSf https://astral.sh/uv/install.sh | sh" + exit 1 +fi + +curdir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +markers="" +if [[ " $@ " =~ " --fast " ]]; then + markers="not slow" +fi + +latest="${versions[${#versions[@]}-1]}" + +if [[ " $@ " =~ " --latest " ]]; then + versions=("3.13") + prereleases=() +elif [[ " $@ " =~ " --pre " ]]; then + versions=() + prereleases=("3.14") +fi + +combined=("${versions[@]}" "${prereleases[@]}") + +for version in "${combined[@]}"; do + print_status "Running \`pytest\` using Python $version..." + env \ + UV_PROJECT_ENVIRONMENT="$curdir/.venv/test-runner/$version/" \ + VIRTUAL_ENV="$curdir/.venv/test-runner/$version/" \ + uv run --frozen --python "$version" -- \ + pytest -m "$markers" \ + --junitxml=junit.xml \ + --override-ini=junit_family=legacy \ + --cov \ + --cov-append \ + --cov-report xml:coverage.xml \ + --cov-report html +done + +export UV_PROJECT_ENVIRONMENT="$curdir/.venv/test-runner/$latest/" \ +export VIRTUAL_ENV="$curdir/.venv/test-runner/$latest/" \ + +print_status "Running \`ruff check\` using Python $latest..." +uv run --frozen --python "$latest" -- ruff check + +print_status "Running \`ruff format --check\` using Python $latest..." +uv run --frozen --python "$latest" -- ruff format --check + +print_status "Running \`mypy\` using Python $latest..." +uv run --frozen --python "$latest" -- mypy diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..61f9e8e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,135 @@ +import stat +from base64 import b64decode +from io import BytesIO +from os import chmod +from os import environ +from os import getenv +from os import stat as os_stat +from pathlib import Path +from typing import Callable + +import pytest + +from clamav_client.clamd import ClamdNetworkSocket +from clamav_client.clamd import ClamdUnixSocket +from clamav_client.scanner import ClamdScannerConfig +from clamav_client.scanner import ClamscanScannerConfig +from clamav_client.scanner import Scanner +from clamav_client.scanner import get_scanner + +CI = True if "CI" in environ or "GITHUB_REF" in environ else False + + +@pytest.fixture +def ci() -> bool: + return CI + + +@pytest.fixture +def eicar_name() -> str: + return "Win.Test.EICAR_HDB-1" + + +@pytest.fixture +def eicar() -> bytes: + return b64decode( + b"WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E" + b"QVJELUFOVElWSVJVUy1URVNU\nLUZJTEUhJEgrSCo=\n" + ) + + +@pytest.fixture +def eicar_file( + perms_updater: Callable[[Path], None], tmp_path: Path, eicar: bytes +) -> Path: + perms_updater(tmp_path) + f = tmp_path / "file" + f.write_bytes(eicar) + f.chmod(0o644) + return f + + +@pytest.fixture +def clean_file( + perms_updater: Callable[[Path], None], tmp_path: Path, eicar: bytes +) -> Path: + perms_updater(tmp_path) + f = tmp_path / "file" + f.write_bytes(b"hello world") + return f + + +@pytest.fixture +def really_big_file() -> BytesIO: + # Generate a stream of 4M to exceed StreamMaxLength (set to 2M in CI). + return BytesIO(b"\x00" * (4 * 1024 * 1024)) + + +@pytest.fixture +def clamd_unix_client() -> ClamdUnixSocket: + path = getenv("CLAMD_UNIX_SOCKET", "/var/run/clamav/clamd.ctl") + return ClamdUnixSocket(path=path) + + +@pytest.fixture +def clamd_net_client() -> ClamdNetworkSocket: + port = getenv("CLAMD_TCP_PORT", "3310") + return ClamdNetworkSocket(host="127.0.0.1", port=int(port)) + + +@pytest.fixture +def perms_updater() -> Callable[[Path], None]: + """Update perms so ClamAV can traverse and read.""" + + def update(temp_file: Path) -> None: + stop_at = temp_file.parent.parent.parent + for parent in [temp_file] + list(temp_file.parents): + if parent == stop_at: + break + mode = os_stat(parent).st_mode + chmod( + parent, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH | stat.S_IROTH + ) + + return update + + +@pytest.fixture +def clamscan_scanner() -> Scanner: + config: ClamscanScannerConfig = { + "backend": "clamscan", + } + return get_scanner(config) + + +@pytest.fixture +def clamd_scanner() -> Scanner: + address = getenv("CLAMD_UNIX_SOCKET", "/var/run/clamav/clamd.ctl") + config: ClamdScannerConfig = { + "backend": "clamd", + "address": address, + "stream": False, + } + return get_scanner(config) + + +@pytest.fixture +def clamd_scanner_with_streaming() -> Scanner: + address = getenv("CLAMD_UNIX_SOCKET", "/var/run/clamav/clamd.ctl") + config: ClamdScannerConfig = { + "backend": "clamd", + "address": address, + "stream": True, + } + return get_scanner(config) + + +@pytest.fixture +def clamd_scanner_over_tcp() -> Scanner: + port = getenv("CLAMD_TCP_PORT", "3310") + config: ClamdScannerConfig = { + "backend": "clamd", + "address": f"127.0.0.1:{port}", + "stream": True, + } + return get_scanner(config) diff --git a/tests/integration/test_clamd_net.py b/tests/integration/test_clamd_net.py new file mode 100644 index 0000000..316a7d2 --- /dev/null +++ b/tests/integration/test_clamd_net.py @@ -0,0 +1,50 @@ +from io import BytesIO + +import pytest + +from clamav_client.clamd import BufferTooLongError +from clamav_client.clamd import ClamdNetworkSocket +from clamav_client.clamd import CommunicationError + + +def test_cannot_connect() -> None: + with pytest.raises(CommunicationError): + ClamdNetworkSocket("127.0.0.1", 999).ping() + + +def test_ping(clamd_net_client: ClamdNetworkSocket) -> None: + clamd_net_client.ping() + + +def test_version(clamd_net_client: ClamdNetworkSocket) -> None: + assert clamd_net_client.version().startswith("ClamAV") + + +def test_reload(clamd_net_client: ClamdNetworkSocket) -> None: + assert clamd_net_client.reload() == "RELOADING" + + +def test_stats(clamd_net_client: ClamdNetworkSocket) -> None: + assert "END" in clamd_net_client.stats() + + +def test_instream_found( + clamd_net_client: ClamdNetworkSocket, + eicar: bytes, + eicar_name: str, +) -> None: + expected = {"stream": ("FOUND", eicar_name)} + assert clamd_net_client.instream(BytesIO(eicar)) == expected + + +def test_instream_ok(clamd_net_client: ClamdNetworkSocket) -> None: + assert clamd_net_client.instream(BytesIO(b"foo")) == {"stream": ("OK", None)} + + +@pytest.mark.xfail +def test_instream_exceeded( + clamd_net_client: ClamdNetworkSocket, really_big_file: BytesIO +) -> None: + """TODO: this is raising BrokenPipeError instead of BufferTooLongError.""" + with pytest.raises(BufferTooLongError): + clamd_net_client.instream(really_big_file) diff --git a/tests/integration/test_clamd_unix.py b/tests/integration/test_clamd_unix.py new file mode 100644 index 0000000..05120ac --- /dev/null +++ b/tests/integration/test_clamd_unix.py @@ -0,0 +1,97 @@ +import os +import pathlib +from io import BytesIO +from typing import Callable + +import pytest + +from clamav_client.clamd import BufferTooLongError +from clamav_client.clamd import ClamdUnixSocket +from clamav_client.clamd import CommunicationError + + +def test_address_using_unix_scheme() -> None: + path = os.getenv("CLAMD_UNIX_SOCKET", "/var/run/clamav/clamd.ctl") + if not path.startswith("unix://"): + path = f"unix://{path}" + client = ClamdUnixSocket(path=path) + client.ping() + + +def test_cannot_connect() -> None: + with pytest.raises(CommunicationError): + ClamdUnixSocket(path="/tmp/404").ping() + + +def test_ping(clamd_unix_client: ClamdUnixSocket) -> None: + clamd_unix_client.ping() + + +def test_version(clamd_unix_client: ClamdUnixSocket) -> None: + assert clamd_unix_client.version().startswith("ClamAV") + + +def test_reload(clamd_unix_client: ClamdUnixSocket) -> None: + assert clamd_unix_client.reload() == "RELOADING" + + +def test_stats(clamd_unix_client: ClamdUnixSocket) -> None: + assert "END" in clamd_unix_client.stats() + + +def test_scan( + perms_updater: Callable[[pathlib.Path], None], + clamd_unix_client: ClamdUnixSocket, + tmp_path: pathlib.Path, + eicar: bytes, + eicar_name: str, +) -> None: + perms_updater(tmp_path) + file = tmp_path / "file" + file.write_bytes(eicar) + file.chmod(0o644) + expected = {str(file): ("FOUND", eicar_name)} + assert clamd_unix_client.scan(str(file)) == expected + + +def test_multiscan( + perms_updater: Callable[[pathlib.Path], None], + clamd_unix_client: ClamdUnixSocket, + tmp_path: pathlib.Path, + eicar: bytes, + eicar_name: str, +) -> None: + perms_updater(tmp_path) + file1 = tmp_path / "file1" + file1.write_bytes(eicar) + file1.chmod(0o644) + file2 = tmp_path / "file2" + file2.write_bytes(eicar) + file2.chmod(0o644) + expected = { + str(file1): ("FOUND", eicar_name), + str(file2): ("FOUND", eicar_name), + } + assert clamd_unix_client.multiscan(str(file1.parent)) == expected + + +def test_instream_found( + clamd_unix_client: ClamdUnixSocket, + eicar: bytes, + eicar_name: str, +) -> None: + expected = {"stream": ("FOUND", eicar_name)} + assert clamd_unix_client.instream(BytesIO(eicar)) == expected + + +def test_instream_ok(clamd_unix_client: ClamdUnixSocket) -> None: + assert clamd_unix_client.instream(BytesIO(b"foo")) == {"stream": ("OK", None)} + + +@pytest.mark.xfail +def test_instream_exceeded( + clamd_unix_client: ClamdUnixSocket, really_big_file: BytesIO +) -> None: + """TODO: this is raising ConnectionResetError instead of BufferTooLongError.""" + with pytest.raises(BufferTooLongError): + clamd_unix_client.instream(really_big_file) diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py new file mode 100644 index 0000000..37edfb6 --- /dev/null +++ b/tests/integration/test_scanner.py @@ -0,0 +1,172 @@ +from pathlib import Path + +import pytest + +from clamav_client import get_scanner +from clamav_client.clamd import CommunicationError +from clamav_client.scanner import ClamdScanner +from clamav_client.scanner import ClamscanScanner +from clamav_client.scanner import Scanner +from clamav_client.scanner import ScanResult + + +def test_get_scanner_provides_default() -> None: + scanner = get_scanner() + assert isinstance(scanner, ClamscanScanner) + + +def test_get_scanner_raises_value_error() -> None: + with pytest.raises(ValueError): + get_scanner({"backend": "unknown"}) # type: ignore[misc,arg-type] + with pytest.raises(ValueError): + get_scanner({"backend": "clamd", "address": "invalid"}) + + +@pytest.mark.slow +def test_clamscan_scanner_info(clamscan_scanner: Scanner) -> None: + info = clamscan_scanner.info() + + assert isinstance(clamscan_scanner, ClamscanScanner) + assert info.name == "ClamAV (clamscan)" + assert info.version.startswith("ClamAV 0.") + + +@pytest.mark.slow +def test_clamscan_scanner_scan_ok(clamscan_scanner: Scanner, clean_file: Path) -> None: + result = clamscan_scanner.scan(str(clean_file)) + + assert result == ScanResult( + filename=str(clean_file), + state="OK", + details=None, + err=None, + ) + + +@pytest.mark.slow +def test_clamscan_scanner_scan_found( + clamscan_scanner: Scanner, + eicar_file: Path, + eicar_name: str, +) -> None: + result = clamscan_scanner.scan(str(eicar_file)) + + assert result == ScanResult( + filename=str(eicar_file), + state="FOUND", + details=eicar_name, + err=None, + ) + + +@pytest.mark.slow +def test_clamscan_scanner_scan_error(clamscan_scanner: Scanner) -> None: + result = clamscan_scanner.scan("/tmp/notfound") + + assert result == ScanResult( + filename="/tmp/notfound", + state="ERROR", + details="/tmp/notfound: No such file or directory", + err=None, + ) + + +def test_clamd_scanner_info(clamd_scanner: Scanner) -> None: + info = clamd_scanner.info() + info_2 = clamd_scanner.info() # Activates caching code path. + + assert isinstance(clamd_scanner, ClamdScanner) + assert info.name == "ClamAV (clamd)" + assert info.version.startswith("ClamAV 0.") + + assert info == info_2 + + +def test_clamd_scanner_scan_ok(clamd_scanner: Scanner, clean_file: Path) -> None: + result = clamd_scanner.scan(str(clean_file)) + + assert result == ScanResult( + filename=str(clean_file), + state="OK", + details=None, + err=None, + ) + + +def test_clamd_scanner_scan_found( + clamd_scanner: Scanner, eicar_file: Path, eicar_name: str +) -> None: + result = clamd_scanner.scan(str(eicar_file)) + + assert result == ScanResult( + filename=str(eicar_file), + state="FOUND", + details=eicar_name, + err=None, + ) + + +def test_clamd_scanner_scan_error( + ci: bool, clamd_scanner: Scanner, tmp_path: Path +) -> None: + if ci: + pytest.skip("does not work as expected in GitHub runners") + + f = tmp_path / "file" + f.write_bytes(b"") + + result = clamd_scanner.scan(str(f)) + + assert result == ScanResult( + filename=str(f), + state="ERROR", + details="File path check failure: Permission denied.", + err=None, + ) + + +def test_clamd_scanner_scan_exception(eicar_file: Path) -> None: + scanner = get_scanner( + { + "backend": "clamd", + "address": "127.0.0.1:65000", # clamd isn't listening on this addr. + } + ) + + result = scanner.scan(str(eicar_file)) + + assert result == ScanResult( + filename=str(eicar_file), + state="ERROR", + details="Error 111 connecting 127.0.0.1:65000. Connection refused.", + err=CommunicationError( + "Error 111 connecting 127.0.0.1:65000. Connection refused." + ), + ) + assert result.passed is None + + +def test_clamd_scanner_instream_over_unix( + clamd_scanner_with_streaming: Scanner, eicar_file: Path, eicar_name: str +) -> None: + result = clamd_scanner_with_streaming.scan(str(eicar_file)) + + assert result == ScanResult( + filename=str(eicar_file), + state="FOUND", + details=eicar_name, + err=None, + ) + + +def test_clamd_scanner_instream_over_tcp( + clamd_scanner_over_tcp: Scanner, eicar_file: Path, eicar_name: str +) -> None: + result = clamd_scanner_over_tcp.scan(str(eicar_file)) + + assert result == ScanResult( + filename=str(eicar_file), + state="FOUND", + details=eicar_name, + err=None, + ) diff --git a/tests/unit/test_unit_scanner.py b/tests/unit/test_unit_scanner.py new file mode 100644 index 0000000..1992f9f --- /dev/null +++ b/tests/unit/test_unit_scanner.py @@ -0,0 +1,117 @@ +from errno import EPIPE +from unittest import mock + +import pytest + +from clamav_client.clamd import BufferTooLongError +from clamav_client.clamd import CommunicationError +from clamav_client.scanner import ClamdScanner +from clamav_client.scanner import ClamscanScanner +from clamav_client.scanner import Scanner +from clamav_client.scanner import ScannerInfo +from clamav_client.scanner import ScanResult + + +@mock.patch.object( + ClamdScanner, + "_pass_by_stream", + return_value={ + "/tmp/file": ScanResult( + filename="/tmp/file", state=None, details=None, err=None + ), + }, +) +def test_clamdscanner_missing_result_entry(mock: mock.Mock) -> None: + result = ClamdScanner({}).scan("/tmp/file") + + assert result == ScanResult( + filename="/tmp/file", state=None, details=None, err=None + ) + + +def test_scan_result_eq() -> None: + result1 = ScanResult(filename="f", state=None, details=None, err=None) + result2 = ScanResult(filename="f", state=None, details=None, err=None) + + assert result1 != object() + assert result1 == result2 + + +def test_scan_result_update() -> None: + result = ScanResult(filename="f", state=None, details=None, err=None) + result.update("FOUND", "virus_name", err=None) + + assert result == ScanResult( + filename="f", state="FOUND", details="virus_name", err=None + ) + + +def test_scan_result_passed() -> None: + assert ScanResult(filename="", state="OK", details=None, err=None).passed is True + assert ( + ScanResult(filename="", state="ERROR", details=None, err=None).passed is False + ) + assert ( + ScanResult( + filename="", state=None, details=None, err=BufferTooLongError() + ).passed + is None + ) + assert ( + ScanResult( + filename="", state=None, details=None, err=CommunicationError() + ).passed + is None + ) + assert ( + ScanResult( + filename="", state=None, details=None, err=OSError(EPIPE, "Broken pipe.") + ).passed + is None + ) + assert ( + ScanResult(filename="", state=None, details=None, err=ValueError()).passed + is False + ) + + +def test_parse_version() -> None: + scanner: Scanner = ClamscanScanner({}) + parse = scanner._parse_version + + parts = parse("ClamAV 0.103.12/27401/Tue Sep 17 10:31:21 2024") + assert parts == ScannerInfo( + name="ClamAV (clamscan)", + version="ClamAV 0.103.12", + virus_definitions="27401/Tue Sep 17 10:31:21 2024", + ) + + parts = parse("ClamAV 0.103.12") + assert parts == ScannerInfo( + name="ClamAV (clamscan)", version="ClamAV 0.103.12", virus_definitions=None + ) + + with pytest.raises(ValueError, match="Cannot extract scanner information."): + parse("Python 3.12.5") + + +def test_clamscan_scanner_parse_error() -> None: + scanner = ClamscanScanner({}) + parse = scanner._parse_error + + assert parse(None) is None + assert parse(1) is None + assert parse(b"\xc3\x28") is None # Invalid UTF-8 byte sequence. + assert parse(b"error") == "error" + + +def test_clamscan_scanner_parse_found() -> None: + scanner = ClamscanScanner({}) + parse = scanner._parse_found + + assert parse(None) is None + assert parse(1) is None + assert parse(b"\xc3\x28") is None # Invalid UTF-8 byte sequence. + assert parse(b"unmatched") is None + assert parse(b": FOUND") is None + assert parse(b"[...]: file.txt FOUND") == "file.txt" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..606c017 --- /dev/null +++ b/uv.lock @@ -0,0 +1,304 @@ +version = 1 +revision = 2 +requires-python = ">=3.9" + +[[package]] +name = "clamav-client" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.15.0" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "ruff", specifier = ">=0.11.9" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872, upload-time = "2025-03-30T20:36:45.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/01/1c5e6ee4ebaaa5e079db933a9a45f61172048c7efa06648445821a201084/coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe", size = 211379, upload-time = "2025-03-30T20:34:53.904Z" }, + { url = "https://files.pythonhosted.org/packages/e9/16/a463389f5ff916963471f7c13585e5f38c6814607306b3cb4d6b4cf13384/coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28", size = 211814, upload-time = "2025-03-30T20:34:56.959Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b1/77062b0393f54d79064dfb72d2da402657d7c569cfbc724d56ac0f9c67ed/coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3", size = 240937, upload-time = "2025-03-30T20:34:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/d7/54/c7b00a23150083c124e908c352db03bcd33375494a4beb0c6d79b35448b9/coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676", size = 238849, upload-time = "2025-03-30T20:35:00.521Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/a6b7cfebd34e7b49f844788fda94713035372b5200c23088e3bbafb30970/coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d", size = 239986, upload-time = "2025-03-30T20:35:02.307Z" }, + { url = "https://files.pythonhosted.org/packages/21/8c/c965ecef8af54e6d9b11bfbba85d4f6a319399f5f724798498387f3209eb/coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a", size = 239896, upload-time = "2025-03-30T20:35:04.141Z" }, + { url = "https://files.pythonhosted.org/packages/40/83/070550273fb4c480efa8381735969cb403fa8fd1626d74865bfaf9e4d903/coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c", size = 238613, upload-time = "2025-03-30T20:35:05.889Z" }, + { url = "https://files.pythonhosted.org/packages/07/76/fbb2540495b01d996d38e9f8897b861afed356be01160ab4e25471f4fed1/coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f", size = 238909, upload-time = "2025-03-30T20:35:07.76Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7e/76d604db640b7d4a86e5dd730b73e96e12a8185f22b5d0799025121f4dcb/coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f", size = 213948, upload-time = "2025-03-30T20:35:09.144Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a7/f8ce4aafb4a12ab475b56c76a71a40f427740cf496c14e943ade72e25023/coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23", size = 214844, upload-time = "2025-03-30T20:35:10.734Z" }, + { url = "https://files.pythonhosted.org/packages/2b/77/074d201adb8383addae5784cb8e2dac60bb62bfdf28b2b10f3a3af2fda47/coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27", size = 211493, upload-time = "2025-03-30T20:35:12.286Z" }, + { url = "https://files.pythonhosted.org/packages/a9/89/7a8efe585750fe59b48d09f871f0e0c028a7b10722b2172dfe021fa2fdd4/coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea", size = 211921, upload-time = "2025-03-30T20:35:14.18Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ef/96a90c31d08a3f40c49dbe897df4f1fd51fb6583821a1a1c5ee30cc8f680/coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7", size = 244556, upload-time = "2025-03-30T20:35:15.616Z" }, + { url = "https://files.pythonhosted.org/packages/89/97/dcd5c2ce72cee9d7b0ee8c89162c24972fb987a111b92d1a3d1d19100c61/coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040", size = 242245, upload-time = "2025-03-30T20:35:18.648Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7b/b63cbb44096141ed435843bbb251558c8e05cc835c8da31ca6ffb26d44c0/coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543", size = 244032, upload-time = "2025-03-30T20:35:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/97/e3/7fa8c2c00a1ef530c2a42fa5df25a6971391f92739d83d67a4ee6dcf7a02/coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2", size = 243679, upload-time = "2025-03-30T20:35:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b3/e0a59d8df9150c8a0c0841d55d6568f0a9195692136c44f3d21f1842c8f6/coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318", size = 241852, upload-time = "2025-03-30T20:35:23.525Z" }, + { url = "https://files.pythonhosted.org/packages/9b/82/db347ccd57bcef150c173df2ade97976a8367a3be7160e303e43dd0c795f/coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9", size = 242389, upload-time = "2025-03-30T20:35:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/21/f6/3f7d7879ceb03923195d9ff294456241ed05815281f5254bc16ef71d6a20/coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c", size = 213997, upload-time = "2025-03-30T20:35:26.914Z" }, + { url = "https://files.pythonhosted.org/packages/28/87/021189643e18ecf045dbe1e2071b2747901f229df302de01c998eeadf146/coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78", size = 214911, upload-time = "2025-03-30T20:35:28.498Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/4792669473297f7973518bec373a955e267deb4339286f882439b8535b39/coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc", size = 211684, upload-time = "2025-03-30T20:35:29.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/e1/2a4ec273894000ebedd789e8f2fc3813fcaf486074f87fd1c5b2cb1c0a2b/coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6", size = 211935, upload-time = "2025-03-30T20:35:31.912Z" }, + { url = "https://files.pythonhosted.org/packages/f8/3a/7b14f6e4372786709a361729164125f6b7caf4024ce02e596c4a69bccb89/coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d", size = 245994, upload-time = "2025-03-30T20:35:33.455Z" }, + { url = "https://files.pythonhosted.org/packages/54/80/039cc7f1f81dcbd01ea796d36d3797e60c106077e31fd1f526b85337d6a1/coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05", size = 242885, upload-time = "2025-03-30T20:35:35.354Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/dc8355f992b6cc2f9dcd5ef6242b62a3f73264893bc09fbb08bfcab18eb4/coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a", size = 245142, upload-time = "2025-03-30T20:35:37.121Z" }, + { url = "https://files.pythonhosted.org/packages/43/1b/33e313b22cf50f652becb94c6e7dae25d8f02e52e44db37a82de9ac357e8/coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6", size = 244906, upload-time = "2025-03-30T20:35:39.07Z" }, + { url = "https://files.pythonhosted.org/packages/05/08/c0a8048e942e7f918764ccc99503e2bccffba1c42568693ce6955860365e/coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47", size = 243124, upload-time = "2025-03-30T20:35:40.598Z" }, + { url = "https://files.pythonhosted.org/packages/5b/62/ea625b30623083c2aad645c9a6288ad9fc83d570f9adb913a2abdba562dd/coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe", size = 244317, upload-time = "2025-03-30T20:35:42.204Z" }, + { url = "https://files.pythonhosted.org/packages/62/cb/3871f13ee1130a6c8f020e2f71d9ed269e1e2124aa3374d2180ee451cee9/coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", size = 214170, upload-time = "2025-03-30T20:35:44.216Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/69fe1193ab0bfa1eb7a7c0149a066123611baba029ebb448500abd8143f9/coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", size = 214969, upload-time = "2025-03-30T20:35:45.797Z" }, + { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708, upload-time = "2025-03-30T20:35:47.417Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981, upload-time = "2025-03-30T20:35:49.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495, upload-time = "2025-03-30T20:35:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538, upload-time = "2025-03-30T20:35:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561, upload-time = "2025-03-30T20:35:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633, upload-time = "2025-03-30T20:35:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712, upload-time = "2025-03-30T20:35:57.801Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000, upload-time = "2025-03-30T20:35:59.378Z" }, + { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195, upload-time = "2025-03-30T20:36:01.005Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998, upload-time = "2025-03-30T20:36:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541, upload-time = "2025-03-30T20:36:04.638Z" }, + { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767, upload-time = "2025-03-30T20:36:06.503Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997, upload-time = "2025-03-30T20:36:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708, upload-time = "2025-03-30T20:36:09.781Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046, upload-time = "2025-03-30T20:36:11.409Z" }, + { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139, upload-time = "2025-03-30T20:36:13.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307, upload-time = "2025-03-30T20:36:16.074Z" }, + { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116, upload-time = "2025-03-30T20:36:18.033Z" }, + { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909, upload-time = "2025-03-30T20:36:19.644Z" }, + { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068, upload-time = "2025-03-30T20:36:21.282Z" }, + { url = "https://files.pythonhosted.org/packages/60/0c/5da94be095239814bf2730a28cffbc48d6df4304e044f80d39e1ae581997/coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f", size = 211377, upload-time = "2025-03-30T20:36:23.298Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cb/b9e93ebf193a0bb89dbcd4f73d7b0e6ecb7c1b6c016671950e25f041835e/coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a", size = 211803, upload-time = "2025-03-30T20:36:25.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/1a/cdbfe9e1bb14d3afcaf6bb6e1b9ba76c72666e329cd06865bbd241efd652/coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82", size = 240561, upload-time = "2025-03-30T20:36:27.548Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/57f1223f26ac018d7ce791bfa65b0c29282de3e041c1cd3ed430cfeac5a5/coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814", size = 238488, upload-time = "2025-03-30T20:36:29.175Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b1/0f25516ae2a35e265868670384feebe64e7857d9cffeeb3887b0197e2ba2/coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c", size = 239589, upload-time = "2025-03-30T20:36:30.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a4/99d88baac0d1d5a46ceef2dd687aac08fffa8795e4c3e71b6f6c78e14482/coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd", size = 239366, upload-time = "2025-03-30T20:36:32.563Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/1db89e135feb827a868ed15f8fc857160757f9cab140ffee21342c783ceb/coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4", size = 237591, upload-time = "2025-03-30T20:36:34.721Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/ac4d6fdfd0e201bc82d1b08adfacb1e34b40d21a22cdd62cfaf3c1828566/coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899", size = 238572, upload-time = "2025-03-30T20:36:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/25/5e/917cbe617c230f7f1745b6a13e780a3a1cd1cf328dbcd0fd8d7ec52858cd/coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f", size = 213966, upload-time = "2025-03-30T20:36:38.551Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/72b434fe550135869f9ea88dd36068af19afce666db576e059e75177e813/coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3", size = 214852, upload-time = "2025-03-30T20:36:40.209Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f1/1da77bb4c920aa30e82fa9b6ea065da3467977c2e5e032e38e66f1c57ffd/coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", size = 203443, upload-time = "2025-03-30T20:36:41.959Z" }, + { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435, upload-time = "2025-03-30T20:36:43.61Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +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 = "mypy" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433, upload-time = "2025-02-05T03:49:29.145Z" }, + { url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472, upload-time = "2025-02-05T03:49:16.986Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424, upload-time = "2025-02-05T03:49:46.908Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450, upload-time = "2025-02-05T03:50:05.89Z" }, + { url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765, upload-time = "2025-02-05T03:49:33.56Z" }, + { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701, upload-time = "2025-02-05T03:49:38.981Z" }, + { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338, upload-time = "2025-02-05T03:50:17.287Z" }, + { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540, upload-time = "2025-02-05T03:49:51.21Z" }, + { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051, upload-time = "2025-02-05T03:50:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751, upload-time = "2025-02-05T03:49:42.408Z" }, + { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783, upload-time = "2025-02-05T03:49:07.707Z" }, + { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618, upload-time = "2025-02-05T03:49:54.581Z" }, + { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981, upload-time = "2025-02-05T03:50:28.25Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175, upload-time = "2025-02-05T03:50:13.411Z" }, + { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675, upload-time = "2025-02-05T03:50:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020, upload-time = "2025-02-05T03:48:48.705Z" }, + { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582, upload-time = "2025-02-05T03:49:03.628Z" }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614, upload-time = "2025-02-05T03:50:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload-time = "2025-02-05T03:48:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload-time = "2025-02-05T03:48:44.581Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload-time = "2025-02-05T03:49:25.514Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload-time = "2025-02-05T03:49:57.623Z" }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload-time = "2025-02-05T03:48:52.361Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/79cf41a55b682794abe71372151dbbf856e3008f6767057229e6649d294a/mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078", size = 10737129, upload-time = "2025-02-05T03:50:24.509Z" }, + { url = "https://files.pythonhosted.org/packages/d3/33/dd8feb2597d648de29e3da0a8bf4e1afbda472964d2a4a0052203a6f3594/mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba", size = 9856335, upload-time = "2025-02-05T03:49:36.398Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b5/74508959c1b06b96674b364ffeb7ae5802646b32929b7701fc6b18447592/mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5", size = 11611935, upload-time = "2025-02-05T03:49:14.154Z" }, + { url = "https://files.pythonhosted.org/packages/6c/53/da61b9d9973efcd6507183fdad96606996191657fe79701b2c818714d573/mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b", size = 12365827, upload-time = "2025-02-05T03:48:59.458Z" }, + { url = "https://files.pythonhosted.org/packages/c1/72/965bd9ee89540c79a25778cc080c7e6ef40aa1eeac4d52cec7eae6eb5228/mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2", size = 12541924, upload-time = "2025-02-05T03:50:03.12Z" }, + { url = "https://files.pythonhosted.org/packages/46/d0/f41645c2eb263e6c77ada7d76f894c580c9ddb20d77f0c24d34273a4dab2/mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980", size = 9271176, upload-time = "2025-02-05T03:50:10.86Z" }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" }, +] + +[[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 = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857, upload-time = "2025-04-05T14:07:51.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, +] + +[[package]] +name = "ruff" +version = "0.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/e7/e55dda1c92cdcf34b677ebef17486669800de01e887b7831a1b8fdf5cb08/ruff-0.11.9.tar.gz", hash = "sha256:ebd58d4f67a00afb3a30bf7d383e52d0e036e6195143c6db7019604a05335517", size = 4132134, upload-time = "2025-05-09T16:19:41.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/71/75dfb7194fe6502708e547941d41162574d1f579c4676a8eb645bf1a6842/ruff-0.11.9-py3-none-linux_armv6l.whl", hash = "sha256:a31a1d143a5e6f499d1fb480f8e1e780b4dfdd580f86e05e87b835d22c5c6f8c", size = 10335453, upload-time = "2025-05-09T16:18:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/74/fc/ad80c869b1732f53c4232bbf341f33c5075b2c0fb3e488983eb55964076a/ruff-0.11.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:66bc18ca783b97186a1f3100e91e492615767ae0a3be584e1266aa9051990722", size = 11072566, upload-time = "2025-05-09T16:19:01.432Z" }, + { url = "https://files.pythonhosted.org/packages/87/0d/0ccececef8a0671dae155cbf7a1f90ea2dd1dba61405da60228bbe731d35/ruff-0.11.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd576cd06962825de8aece49f28707662ada6a1ff2db848d1348e12c580acbf1", size = 10435020, upload-time = "2025-05-09T16:19:03.897Z" }, + { url = "https://files.pythonhosted.org/packages/52/01/e249e1da6ad722278094e183cbf22379a9bbe5f21a3e46cef24ccab76e22/ruff-0.11.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b1d18b4be8182cc6fddf859ce432cc9631556e9f371ada52f3eaefc10d878de", size = 10593935, upload-time = "2025-05-09T16:19:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/40cf91f61e3003fe7bd43f1761882740e954506c5a0f9097b1cff861f04c/ruff-0.11.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f3f46f759ac623e94824b1e5a687a0df5cd7f5b00718ff9c24f0a894a683be7", size = 10172971, upload-time = "2025-05-09T16:19:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/61/12/d395203de1e8717d7a2071b5a340422726d4736f44daf2290aad1085075f/ruff-0.11.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f34847eea11932d97b521450cf3e1d17863cfa5a94f21a056b93fb86f3f3dba2", size = 11748631, upload-time = "2025-05-09T16:19:12.307Z" }, + { url = "https://files.pythonhosted.org/packages/66/d6/ef4d5eba77677eab511644c37c55a3bb8dcac1cdeb331123fe342c9a16c9/ruff-0.11.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f33b15e00435773df97cddcd263578aa83af996b913721d86f47f4e0ee0ff271", size = 12409236, upload-time = "2025-05-09T16:19:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8f/5a2c5fc6124dd925a5faf90e1089ee9036462118b619068e5b65f8ea03df/ruff-0.11.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b27613a683b086f2aca8996f63cb3dd7bc49e6eccf590563221f7b43ded3f65", size = 11881436, upload-time = "2025-05-09T16:19:17.063Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/9683f469ae0b99b95ef99a56cfe8c8373c14eba26bd5c622150959ce9f64/ruff-0.11.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e0d88756e63e8302e630cee3ce2ffb77859797cc84a830a24473939e6da3ca6", size = 13982759, upload-time = "2025-05-09T16:19:19.693Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0b/c53a664f06e0faab596397867c6320c3816df479e888fe3af63bc3f89699/ruff-0.11.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:537c82c9829d7811e3aa680205f94c81a2958a122ac391c0eb60336ace741a70", size = 11541985, upload-time = "2025-05-09T16:19:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/23/a0/156c4d7e685f6526a636a60986ee4a3c09c8c4e2a49b9a08c9913f46c139/ruff-0.11.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:440ac6a7029f3dee7d46ab7de6f54b19e34c2b090bb4f2480d0a2d635228f381", size = 10465775, upload-time = "2025-05-09T16:19:24.401Z" }, + { url = "https://files.pythonhosted.org/packages/43/d5/88b9a6534d9d4952c355e38eabc343df812f168a2c811dbce7d681aeb404/ruff-0.11.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:71c539bac63d0788a30227ed4d43b81353c89437d355fdc52e0cda4ce5651787", size = 10170957, upload-time = "2025-05-09T16:19:27.08Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b8/2bd533bdaf469dc84b45815ab806784d561fab104d993a54e1852596d581/ruff-0.11.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c67117bc82457e4501473c5f5217d49d9222a360794bfb63968e09e70f340abd", size = 11143307, upload-time = "2025-05-09T16:19:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d9/43cfba291788459b9bfd4e09a0479aa94d05ab5021d381a502d61a807ec1/ruff-0.11.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e4b78454f97aa454586e8a5557facb40d683e74246c97372af3c2d76901d697b", size = 11603026, upload-time = "2025-05-09T16:19:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e6/7ed70048e89b01d728ccc950557a17ecf8df4127b08a56944b9d0bae61bc/ruff-0.11.9-py3-none-win32.whl", hash = "sha256:7fe1bc950e7d7b42caaee2a8a3bc27410547cc032c9558ee2e0f6d3b209e845a", size = 10548627, upload-time = "2025-05-09T16:19:33.657Z" }, + { url = "https://files.pythonhosted.org/packages/90/36/1da5d566271682ed10f436f732e5f75f926c17255c9c75cefb77d4bf8f10/ruff-0.11.9-py3-none-win_amd64.whl", hash = "sha256:52edaa4a6d70f8180343a5b7f030c7edd36ad180c9f4d224959c2d689962d964", size = 11634340, upload-time = "2025-05-09T16:19:35.815Z" }, + { url = "https://files.pythonhosted.org/packages/40/f7/70aad26e5877c8f7ee5b161c4c9fa0100e63fc4c944dc6d97b9c7e871417/ruff-0.11.9-py3-none-win_arm64.whl", hash = "sha256:bcf42689c22f2e240f496d0c183ef2c6f7b35e809f12c1db58f75d9aa8d630ca", size = 10741080, upload-time = "2025-05-09T16:19:39.605Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +]