From 6e7ff5ebe7b7222a1700d7dc0c86379bf65f9ada Mon Sep 17 00:00:00 2001 From: Venelin Stoykov Date: Mon, 21 Mar 2016 15:17:17 +0200 Subject: [PATCH 01/64] Use default version in ez_setup which is greater than 0.7.0 Fixes #12 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f9501a6..94fd3ae 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ #!/usr/bin/env python from ez_setup import use_setuptools -use_setuptools("0.7.0") +use_setuptools() from setuptools import setup, find_packages From ec1f842651325d857f17f7e342d71c8d6367505a Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 21 Mar 2016 14:12:46 +0000 Subject: [PATCH 02/64] test using tox --- .gitignore | 87 +++++++++++++++++++++++++++++++++---------- .travis.yml | 22 +++++------ README.rst | 2 +- setup.py | 5 --- src/clamd/__init__.py | 6 ++- src/tests/test_api.py | 50 ++++++++++--------------- tox.ini | 15 ++++++++ 7 files changed, 117 insertions(+), 70 deletions(-) create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index ded6067..44ead8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,85 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ *.py[cod] +*$py.class # C extensions *.so -# Packages -*.egg -*.egg-info -dist -build -eggs -parts -bin -var -sdist -develop-eggs +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ .installed.cfg -lib -lib64 -__pycache__ +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec # Installer logs pip-log.txt +pip-delete-this-directory.txt # Unit test / coverage reports +htmlcov/ +.tox/ .coverage -.tox +.coverage.* +.cache nosetests.xml +coverage.xml +*,cover +.hypothesis/ # Translations *.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask instance folder +instance/ + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ -# Mr Developer -.mr.developer.cfg -.project -.pydevproject +# Spyder project settings +.spyderproject diff --git a/.travis.yml b/.travis.yml index 3fc87ab..06d046d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,15 @@ language: python -python: - - "2.5" - - "2.6" - - "2.7" - - "pypy" - - "3.3" - - "3.4" +python: 3.5 +env: + - TOX_ENV=py26 + - TOX_ENV=py27 + - TOX_ENV=pypy + - TOX_ENV=py34 + - TOX_ENV=py35 + - TOX_ENV=lint 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" + - pip install tox +script: tox -e $TOX_ENV diff --git a/README.rst b/README.rst index 6fd999e..4e9315d 100644 --- a/README.rst +++ b/README.rst @@ -35,7 +35,7 @@ To scan a file:: To scan a stream:: - >>> from six import BytesIO + >>> from io import BytesIO >>> cd.instream(BytesIO(clamd.EICAR)) {'stream': ('FOUND', 'Eicar-Test-Signature')} diff --git a/setup.py b/setup.py index 94fd3ae..901a206 100755 --- a/setup.py +++ b/setup.py @@ -23,11 +23,6 @@ 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/clamd/__init__.py b/src/clamd/__init__.py index b085886..92ff640 100644 --- a/src/clamd/__init__.py +++ b/src/clamd/__init__.py @@ -18,8 +18,10 @@ import base64 scan_response = re.compile(r"^(?P.*): ((?P.+) )?(?P(FOUND|OK|ERROR))$") -EICAR = base64.b64decode(b'WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E' \ - b'QVJELUFOVElWSVJVUy1URVNU\nLUZJTEUhJEgrSCo=\n') +EICAR = base64.b64decode( + b'WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E' + b'QVJELUFOVElWSVJVUy1URVNU\nLUZJTEUhJEgrSCo=\n' +) class ClamdError(Exception): diff --git a/src/tests/test_api.py b/src/tests/test_api.py index f550a0b..42423b6 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -2,14 +2,14 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals import clamd -from six import BytesIO +from io import BytesIO from contextlib import contextmanager import tempfile import shutil import os import stat -from nose.tools import ok_, eq_, assert_true, raises +import pytest mine = (stat.S_IREAD | stat.S_IWRITE) other = stat.S_IROTH @@ -26,38 +26,37 @@ def mkdtemp(*args, **kwargs): class TestUnixSocket(object): - def __init__(self): - self.kwargs = {} + kwargs = {} def setup(self): self.cd = clamd.ClamdUnixSocket(**self.kwargs) def test_ping(self): - assert_true(self.cd.ping()) + assert self.cd.ping() def test_version(self): - ok_(self.cd.version().startswith("ClamAV")) + assert self.cd.version().startswith("ClamAV") def test_reload(self): - eq_(self.cd.reload(), 'RELOADING') + assert 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')} - ) + expected = {f.name: ('FOUND', 'Eicar-Test-Signature')} + + assert self.cd.scan(f.name) == expected 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')} - ) + expected = {f.name: ('FOUND', 'Eicar-Test-Signature')} + + assert self.cd.scan(f.name) == expected def test_multiscan(self): expected = {} @@ -69,31 +68,20 @@ def test_multiscan(self): expected[f.name] = ('FOUND', 'Eicar-Test-Signature') os.chmod(d, (mine | other | execute)) - eq_(self.cd.multiscan(d), expected) + assert self.cd.multiscan(d) == expected def test_instream(self): - eq_( - self.cd.instream(BytesIO(clamd.EICAR)), - {'stream': ('FOUND', 'Eicar-Test-Signature')} - ) + expected = {'stream': ('FOUND', 'Eicar-Test-Signature')} + assert self.cd.instream(BytesIO(clamd.EICAR)) == expected def test_insteam_success(self): - eq_( - self.cd.instream(BytesIO(b"foo")), - {'stream': ('OK', None)} - ) + assert self.cd.instream(BytesIO(b"foo")) == {'stream': ('OK', None)} class TestUnixSocketTimeout(TestUnixSocket): - def __init__(self): - self.kwargs = {"timeout": 20} + 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() + with pytest.raises(clamd.ConnectionError): + clamd.ClamdUnixSocket(path="/tmp/404").ping() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..a7dbf0c --- /dev/null +++ b/tox.ini @@ -0,0 +1,15 @@ +[tox] +envlist = py{26,27,33,34,35}, lint + +[testenv] +commands = py.test {posargs} +deps = + pytest==2.8.2 + +[testenv:lint] +deps = + flake8==2.4.0 +commands=flake8 src + +[flake8] +max-line-length = 117 From 5996dfb6ba0a1658c51036459ddcbf2413eadad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 10:19:56 +0000 Subject: [PATCH 03/64] Bootstrap uv project --- README.md | 1 + pyproject.toml | 16 +++++++++ uv.lock | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..2589fac --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# clamd diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4de7ffe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "clamav-client" +version = "0.1.0" +description = "Python client library for the ClamAV antivirus." +readme = "README.md" +requires-python = ">=3.9" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +dev-dependencies = [ + "pytest>=8.3.2", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..46f2e52 --- /dev/null +++ b/uv.lock @@ -0,0 +1,88 @@ +version = 1 +requires-python = ">=3.9" + +[[package]] +name = "clamav-client" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.3.2" }] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "packaging" +version = "24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pytest" +version = "8.3.2" +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/b4/8c/9862305bdcd6020bc7b45b1b5e7397a6caf1a33d3025b9a003b39075ffb2/pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce", size = 1439314 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802 }, +] + +[[package]] +name = "tomli" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, +] From fa30daeaec6f045feef856a7dd609f09befccf3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 10:20:45 +0000 Subject: [PATCH 04/64] Remove Travis CI integration --- .travis.yml | 15 --------------- README.rst | 4 ---- 2 files changed, 19 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 06d046d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: python -python: 3.5 -env: - - TOX_ENV=py26 - - TOX_ENV=py27 - - TOX_ENV=pypy - - TOX_ENV=py34 - - TOX_ENV=py35 - - TOX_ENV=lint -install: - - sudo apt-get install clamav-daemon clamav-freshclam clamav-unofficial-sigs - - sudo freshclam --verbose - - sudo service clamav-daemon start - - pip install tox -script: tox -e $TOX_ENV diff --git a/README.rst b/README.rst index 4e9315d..0cbdcbf 100644 --- a/README.rst +++ b/README.rst @@ -1,10 +1,6 @@ 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 From 4230cb84b608c64538f48e8a2b58c5765c8c6ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 10:41:10 +0000 Subject: [PATCH 05/64] Describe project license (GPL-2.0) In his fork, Thomas Grainger wrote: > 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/. Philippe's fork was published under the `GPL-2.0` license. I'm not a lawyer but what I read is that Philippe re-licensed it under `GPL-2.0` and it cannot rever to `LGPL`. I'm using `GPL-2.0` to comply with the existing licensing chain. --- LICENSE | 339 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 12 +- README.rst | 49 ------- pyproject.toml | 1 + setup.py | 2 +- 5 files changed, 352 insertions(+), 51 deletions(-) create mode 100644 LICENSE delete mode 100644 README.rst 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/README.md b/README.md index 2589fac..ae3f949 100644 --- a/README.md +++ b/README.md @@ -1 +1,11 @@ -# clamd +# clamav-client + +`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. + + +[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 0cbdcbf..0000000 --- a/README.rst +++ /dev/null @@ -1,49 +0,0 @@ -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 io 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/pyproject.toml b/pyproject.toml index 4de7ffe..4fd6a4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,7 @@ name = "clamav-client" version = "0.1.0" description = "Python client library for the ClamAV antivirus." readme = "README.md" +license = {file = "LICENSE"} requires-python = ">=3.9" dependencies = [] diff --git a/setup.py b/setup.py index 901a206..da9b592 100755 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ package_dir={'': 'src'}, packages=find_packages('src', exclude="tests"), classifiers = [ - "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", + "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", ], zip_safe=True, include_package_data=False, From 8e589b6aebcf173ce6807368994ef2c911d5e5b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 10:57:22 +0000 Subject: [PATCH 06/64] Remove unneeded files --- .gitignore | 83 +------------ CHANGES.rst | 40 ------- MANIFEST.in | 6 - ez_setup.py | 332 ---------------------------------------------------- setup.cfg | 5 - setup.py | 28 ----- 6 files changed, 1 insertion(+), 493 deletions(-) delete mode 100644 CHANGES.rst delete mode 100644 MANIFEST.in delete mode 100644 ez_setup.py delete mode 100644 setup.cfg delete mode 100755 setup.py diff --git a/.gitignore b/.gitignore index 44ead8c..4a1788a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,85 +1,4 @@ -# Byte-compiled / optimized / DLL files +.venv/ __pycache__/ *.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ *.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask instance folder -instance/ - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# IPython Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# dotenv -.env - -# virtualenv -venv/ -ENV/ - -# Spyder project settings -.spyderproject 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/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/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/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 da9b592..0000000 --- a/setup.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python -from ez_setup import use_setuptools -use_setuptools() - -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 General Public License v2 (GPLv2)", - ], - zip_safe=True, - include_package_data=False, -) From 2756c9f94ad887d770fb62e1652287ec3eb2bc00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 10:58:25 +0000 Subject: [PATCH 07/64] Update project layout --- {src/clamd => clamav_client}/__init__.py | 7 ------ src/tests/test_api.py => tests/test_client.py | 23 ++++++++++--------- 2 files changed, 12 insertions(+), 18 deletions(-) rename {src/clamd => clamav_client}/__init__.py (98%) rename src/tests/test_api.py => tests/test_client.py (82%) diff --git a/src/clamd/__init__.py b/clamav_client/__init__.py similarity index 98% rename from src/clamd/__init__.py rename to clamav_client/__init__.py index 92ff640..8a0ec79 100644 --- a/src/clamd/__init__.py +++ b/clamav_client/__init__.py @@ -1,15 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - try: __version__ = __import__('pkg_resources').get_distribution('clamd').version except: __version__ = '' -# $Source$ - - import socket import sys import struct diff --git a/src/tests/test_api.py b/tests/test_client.py similarity index 82% rename from src/tests/test_api.py rename to tests/test_client.py index 42423b6..30a7bfb 100644 --- a/src/tests/test_api.py +++ b/tests/test_client.py @@ -1,7 +1,3 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -from __future__ import unicode_literals -import clamd from io import BytesIO from contextlib import contextmanager import tempfile @@ -11,6 +7,11 @@ import pytest +from clamav_client import ClamdUnixSocket +from clamav_client import EICAR +from clamav_client import ConnectionError + + mine = (stat.S_IREAD | stat.S_IWRITE) other = stat.S_IROTH execute = (stat.S_IEXEC | stat.S_IXOTH) @@ -29,7 +30,7 @@ class TestUnixSocket(object): kwargs = {} def setup(self): - self.cd = clamd.ClamdUnixSocket(**self.kwargs) + self.cd = ClamdUnixSocket(**self.kwargs) def test_ping(self): assert self.cd.ping() @@ -42,7 +43,7 @@ def test_reload(self): def test_scan(self): with tempfile.NamedTemporaryFile('wb', prefix="python-clamd") as f: - f.write(clamd.EICAR) + f.write(EICAR) f.flush() os.fchmod(f.fileno(), (mine | other)) expected = {f.name: ('FOUND', 'Eicar-Test-Signature')} @@ -51,7 +52,7 @@ def test_scan(self): def test_unicode_scan(self): with tempfile.NamedTemporaryFile('wb', prefix=u"python-clamdλ") as f: - f.write(clamd.EICAR) + f.write(EICAR) f.flush() os.fchmod(f.fileno(), (mine | other)) expected = {f.name: ('FOUND', 'Eicar-Test-Signature')} @@ -63,7 +64,7 @@ def test_multiscan(self): 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) + f.write(EICAR) os.fchmod(f.fileno(), (mine | other)) expected[f.name] = ('FOUND', 'Eicar-Test-Signature') os.chmod(d, (mine | other | execute)) @@ -72,7 +73,7 @@ def test_multiscan(self): def test_instream(self): expected = {'stream': ('FOUND', 'Eicar-Test-Signature')} - assert self.cd.instream(BytesIO(clamd.EICAR)) == expected + assert self.cd.instream(BytesIO(EICAR)) == expected def test_insteam_success(self): assert self.cd.instream(BytesIO(b"foo")) == {'stream': ('OK', None)} @@ -83,5 +84,5 @@ class TestUnixSocketTimeout(TestUnixSocket): def test_cannot_connect(): - with pytest.raises(clamd.ConnectionError): - clamd.ClamdUnixSocket(path="/tmp/404").ping() + with pytest.raises(ConnectionError): + ClamdUnixSocket(path="/tmp/404").ping() From a2c196c2d2de7df687e779ff828db7e927495f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 11:09:38 +0000 Subject: [PATCH 08/64] Use importlib.metadata to obtain the version number --- clamav_client/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/clamav_client/__init__.py b/clamav_client/__init__.py index 8a0ec79..077e6cb 100644 --- a/clamav_client/__init__.py +++ b/clamav_client/__init__.py @@ -1,8 +1,4 @@ -try: - __version__ = __import__('pkg_resources').get_distribution('clamd').version -except: - __version__ = '' - +import importlib.metadata import socket import sys import struct @@ -10,6 +6,8 @@ import re import base64 +__version__ = importlib.metadata.version('clamav_client') + scan_response = re.compile(r"^(?P.*): ((?P.+) )?(?P(FOUND|OK|ERROR))$") EICAR = base64.b64decode( b'WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E' From 44e703d5a5e547fd637b5682e163ab4eacccd5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 14:15:34 +0000 Subject: [PATCH 09/64] Implement tests using pytest functions --- tests/test_client.py | 104 +++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 59 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 30a7bfb..cc0a43d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,4 @@ from io import BytesIO -from contextlib import contextmanager -import tempfile -import shutil import os import stat @@ -12,77 +9,66 @@ from clamav_client import ConnectionError -mine = (stat.S_IREAD | stat.S_IWRITE) -other = stat.S_IROTH -execute = (stat.S_IEXEC | stat.S_IXOTH) +@pytest.fixture +def unix_socket_client(): + return ClamdUnixSocket() -@contextmanager -def mkdtemp(*args, **kwargs): - temp_dir = tempfile.mkdtemp(*args, **kwargs) - try: - yield temp_dir - finally: - shutil.rmtree(temp_dir) - - -class TestUnixSocket(object): - kwargs = {} +def test_cannot_connect(): + with pytest.raises(ConnectionError): + ClamdUnixSocket(path="/tmp/404").ping() - def setup(self): - self.cd = ClamdUnixSocket(**self.kwargs) - def test_ping(self): - assert self.cd.ping() +def test_ping(unix_socket_client): + unix_socket_client.ping() - def test_version(self): - assert self.cd.version().startswith("ClamAV") - def test_reload(self): - assert self.cd.reload() == 'RELOADING' +def test_version(unix_socket_client): + assert unix_socket_client.version().startswith("ClamAV") - def test_scan(self): - with tempfile.NamedTemporaryFile('wb', prefix="python-clamd") as f: - f.write(EICAR) - f.flush() - os.fchmod(f.fileno(), (mine | other)) - expected = {f.name: ('FOUND', 'Eicar-Test-Signature')} - assert self.cd.scan(f.name) == expected +def test_reload(unix_socket_client): + assert unix_socket_client.reload() == "RELOADING" - def test_unicode_scan(self): - with tempfile.NamedTemporaryFile('wb', prefix=u"python-clamdλ") as f: - f.write(EICAR) - f.flush() - os.fchmod(f.fileno(), (mine | other)) - expected = {f.name: ('FOUND', 'Eicar-Test-Signature')} - assert self.cd.scan(f.name) == expected +def test_scan(unix_socket_client, tmp_path): + update_tmp_path_perms(tmp_path) + file = tmp_path / "file" + file.write_bytes(EICAR) + file.chmod(0o644) + expected = {str(file): ('FOUND', 'Win.Test.EICAR_HDB-1')} + assert unix_socket_client.scan(str(file)) == expected - 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(EICAR) - os.fchmod(f.fileno(), (mine | other)) - expected[f.name] = ('FOUND', 'Eicar-Test-Signature') - os.chmod(d, (mine | other | execute)) - assert self.cd.multiscan(d) == expected +def test_multiscan(unix_socket_client, tmp_path): + update_tmp_path_perms(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', 'Win.Test.EICAR_HDB-1'), + str(file2): ('FOUND', 'Win.Test.EICAR_HDB-1'), + } + assert unix_socket_client.multiscan(str(file1.parent)) == expected - def test_instream(self): - expected = {'stream': ('FOUND', 'Eicar-Test-Signature')} - assert self.cd.instream(BytesIO(EICAR)) == expected - def test_insteam_success(self): - assert self.cd.instream(BytesIO(b"foo")) == {'stream': ('OK', None)} +def test_instream(unix_socket_client): + expected = {'stream': ('FOUND', "Win.Test.EICAR_HDB-1")} + assert unix_socket_client.instream(BytesIO(EICAR)) == expected -class TestUnixSocketTimeout(TestUnixSocket): - kwargs = {"timeout": 20} +def test_insteam_success(unix_socket_client): + assert unix_socket_client.instream(BytesIO(b"foo")) == {'stream': ('OK', None)} -def test_cannot_connect(): - with pytest.raises(ConnectionError): - ClamdUnixSocket(path="/tmp/404").ping() +def update_tmp_path_perms(temp_file): + """Update perms so ClamAV can traverse and read.""" + 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 + os.chmod(parent, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH | stat.S_IROTH) From fe677862fb43a3f0506869e814012b327fc7e27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 14:22:47 +0000 Subject: [PATCH 10/64] Fix optional member access --- clamav_client/__init__.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/clamav_client/__init__.py b/clamav_client/__init__.py index 077e6cb..b33f2f5 100644 --- a/clamav_client/__init__.py +++ b/clamav_client/__init__.py @@ -231,9 +231,8 @@ def _recv_response(self): 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)) + except (socket.error, socket.timeout) as err: + raise ConnectionError("Error while reading from socket: {0}".format(err.args)) def _recv_response_multiline(self): """ @@ -242,9 +241,8 @@ def _recv_response_multiline(self): 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)) + except (socket.error, socket.timeout) as err: + raise ConnectionError("Error while reading from socket: {0}".format(err.args)) def _close_socket(self): """ @@ -257,9 +255,9 @@ def _parse_response(self, msg): """ 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]) From c2b497ce455816bea0c79d9e1b58865389b4ff12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 14:23:38 +0000 Subject: [PATCH 11/64] Format code with ruff --- clamav_client/__init__.py | 69 ++++++++++++++++++++------------------- pyproject.toml | 1 + tests/test_client.py | 14 ++++---- uv.lock | 31 +++++++++++++++++- 4 files changed, 75 insertions(+), 40 deletions(-) diff --git a/clamav_client/__init__.py b/clamav_client/__init__.py index b33f2f5..75ffab1 100644 --- a/clamav_client/__init__.py +++ b/clamav_client/__init__.py @@ -6,12 +6,14 @@ import re import base64 -__version__ = importlib.metadata.version('clamav_client') +__version__ = importlib.metadata.version("clamav_client") -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' + b"WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E" + b"QVJELUFOVElWSVJVUy1URVNU\nLUZJTEUhJEgrSCo=\n" ) @@ -35,7 +37,8 @@ class ClamdNetworkSocket(object): """ Class for using clamd with a network socket """ - def __init__(self, host='127.0.0.1', port=3310, timeout=None): + + def __init__(self, host="127.0.0.1", port=3310, timeout=None): """ class initialisation @@ -66,16 +69,14 @@ def _error_message(self, exception): # 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] + host=self.host, port=self.port, msg=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] + msg=exception.args[1], ) def ping(self): @@ -98,19 +99,19 @@ def shutdown(self): """ try: self._init_socket() - self._send_command('SHUTDOWN') + self._send_command("SHUTDOWN") # result = self._recv_response() finally: self._close_socket() def scan(self, file): - return self._file_system_scan('SCAN', file) + return self._file_system_scan("SCAN", file) def contscan(self, file): - return self._file_system_scan('CONTSCAN', file) + return self._file_system_scan("CONTSCAN", file) def multiscan(self, file): - return self._file_system_scan('MULTISCAN', file) + return self._file_system_scan("MULTISCAN", file) def _basic_command(self, command): """ @@ -147,7 +148,7 @@ def _file_system_scan(self, command, file): self._send_command(command, file) dr = {} - for result in self._recv_response_multiline().split('\n'): + for result in self._recv_response_multiline().split("\n"): if result: filename, reason, status = self._parse_response(result) dr[filename] = (status, reason) @@ -173,22 +174,22 @@ def instream(self, buff): 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)) + size = struct.pack(b"!L", len(chunk)) self.clamd_socket.send(size + chunk) chunk = buff.read(max_chunk_size) - self.clamd_socket.send(struct.pack(b'!L', 0)) + self.clamd_socket.send(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) @@ -207,7 +208,7 @@ def stats(self): """ self._init_socket() try: - self._send_command('STATS') + self._send_command("STATS") return self._recv_response_multiline() finally: self._close_socket() @@ -217,11 +218,11 @@ def _send_command(self, cmd, *args): `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) - cmd = 'n{cmd}{args}\n'.format(cmd=cmd, args=concat_args).encode('utf-8') + cmd = "n{cmd}{args}\n".format(cmd=cmd, args=concat_args).encode("utf-8") self.clamd_socket.send(cmd) def _recv_response(self): @@ -229,20 +230,24 @@ def _recv_response(self): receive line from clamd """ try: - with contextlib.closing(self.clamd_socket.makefile('rb')) as f: - return f.readline().decode('utf-8').strip() + with contextlib.closing(self.clamd_socket.makefile("rb")) as f: + return f.readline().decode("utf-8").strip() except (socket.error, socket.timeout) as err: - raise ConnectionError("Error while reading from socket: {0}".format(err.args)) + raise ConnectionError( + "Error while reading from socket: {0}".format(err.args) + ) def _recv_response_multiline(self): """ 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') + with contextlib.closing(self.clamd_socket.makefile("rb")) as f: + return f.read().decode("utf-8") except (socket.error, socket.timeout) as err: - raise ConnectionError("Error while reading from socket: {0}".format(err.args)) + raise ConnectionError( + "Error while reading from socket: {0}".format(err.args) + ) def _close_socket(self): """ @@ -265,6 +270,7 @@ class ClamdUnixSocket(ClamdNetworkSocket): """ Class for using clamd with an unix socket """ + def __init__(self, path="/var/run/clamav/clamd.ctl", timeout=None): """ class initialisation @@ -293,12 +299,9 @@ def _error_message(self, exception): # or just "message" if len(exception.args) == 1: return "Error connecting to {path}. {msg}.".format( - path=self.unix_socket, - msg=exception.args[0] + path=self.unix_socket, msg=exception.args[0] ) else: return "Error {erno} connecting {path}. {msg}.".format( - erno=exception.args[0], - path=self.unix_socket, - msg=exception.args[1] + erno=exception.args[0], path=self.unix_socket, msg=exception.args[1] ) diff --git a/pyproject.toml b/pyproject.toml index 4fd6a4e..9ec65ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,4 +14,5 @@ build-backend = "hatchling.build" [tool.uv] dev-dependencies = [ "pytest>=8.3.2", + "ruff>=0.6.3", ] diff --git a/tests/test_client.py b/tests/test_client.py index cc0a43d..f3e4d7f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -36,7 +36,7 @@ def test_scan(unix_socket_client, tmp_path): file = tmp_path / "file" file.write_bytes(EICAR) file.chmod(0o644) - expected = {str(file): ('FOUND', 'Win.Test.EICAR_HDB-1')} + expected = {str(file): ("FOUND", "Win.Test.EICAR_HDB-1")} assert unix_socket_client.scan(str(file)) == expected @@ -49,19 +49,19 @@ def test_multiscan(unix_socket_client, tmp_path): file2.write_bytes(EICAR) file2.chmod(0o644) expected = { - str(file1): ('FOUND', 'Win.Test.EICAR_HDB-1'), - str(file2): ('FOUND', 'Win.Test.EICAR_HDB-1'), + str(file1): ("FOUND", "Win.Test.EICAR_HDB-1"), + str(file2): ("FOUND", "Win.Test.EICAR_HDB-1"), } assert unix_socket_client.multiscan(str(file1.parent)) == expected def test_instream(unix_socket_client): - expected = {'stream': ('FOUND', "Win.Test.EICAR_HDB-1")} + expected = {"stream": ("FOUND", "Win.Test.EICAR_HDB-1")} assert unix_socket_client.instream(BytesIO(EICAR)) == expected def test_insteam_success(unix_socket_client): - assert unix_socket_client.instream(BytesIO(b"foo")) == {'stream': ('OK', None)} + assert unix_socket_client.instream(BytesIO(b"foo")) == {"stream": ("OK", None)} def update_tmp_path_perms(temp_file): @@ -71,4 +71,6 @@ def update_tmp_path_perms(temp_file): if parent == stop_at: break mode = os.stat(parent).st_mode - os.chmod(parent, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH | stat.S_IROTH) + os.chmod( + parent, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH | stat.S_IROTH + ) diff --git a/uv.lock b/uv.lock index 46f2e52..688e799 100644 --- a/uv.lock +++ b/uv.lock @@ -9,12 +9,16 @@ source = { editable = "." } [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.3.2" }] +dev = [ + { name = "pytest", specifier = ">=8.3.2" }, + { name = "ruff", specifier = ">=0.6.3" }, +] [[package]] name = "colorama" @@ -78,6 +82,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802 }, ] +[[package]] +name = "ruff" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/f9/0b32e5d1c6f957df49398cd882a011e9488fcbca0d6acfeeea50ccd37a4d/ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983", size = 2463514 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/68/1da6a1e39a03a229ea57c511691d6225072759cc7764206c3f0989521194/ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3", size = 9696928 }, + { url = "https://files.pythonhosted.org/packages/6e/59/3b8b1d3a4271c6eb6ceecd3cef19a6d881639a0f18ad651563d6f619aaae/ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc", size = 9448462 }, + { url = "https://files.pythonhosted.org/packages/35/4f/b942ecb8bbebe53aa9b33e9b96df88acd50b70adaaed3070f1d92131a1cb/ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1", size = 9176190 }, + { url = "https://files.pythonhosted.org/packages/a0/20/b0bcb29d4ee437f3567b73b6905c034e2e94d29b9b826c66daecc1cf6388/ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1", size = 10108892 }, + { url = "https://files.pythonhosted.org/packages/9c/e3/211bc759f424e8823a9937e0f678695ca02113c621dfde1fa756f9f26f6d/ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672", size = 9476471 }, + { url = "https://files.pythonhosted.org/packages/b2/a3/2ec35a2d7a554364864206f0e46812b92a074ad8a014b923d821ead532aa/ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1", size = 10294802 }, + { url = "https://files.pythonhosted.org/packages/03/8b/56ef687b3489c88886dea48c78fb4969b6b65f18007d0ac450070edd1f58/ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384", size = 11022372 }, + { url = "https://files.pythonhosted.org/packages/a5/21/327d147feb442adb88975e81e2263102789eba9ad2afa102c661912a482f/ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a", size = 10596596 }, + { url = "https://files.pythonhosted.org/packages/6c/86/ff386de63729da3e08c8099c57f577a00ec9f3eea711b23ac07cf3588dc5/ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500", size = 11572830 }, + { url = "https://files.pythonhosted.org/packages/38/5d/b33284c108e3f315ddd09b70296fd76bd28ecf8965a520bc93f3bbd8ac40/ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470", size = 10262577 }, + { url = "https://files.pythonhosted.org/packages/29/99/9cdfad0d7f460e66567236eddc691473791afd9aff93a0dfcdef0462a6c7/ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f", size = 10098751 }, + { url = "https://files.pythonhosted.org/packages/a8/9f/f801a1619f5549e552f1f722f1db57eb39e7e1d83d482133142781d450de/ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5", size = 9563859 }, + { url = "https://files.pythonhosted.org/packages/0b/4d/fb2424faf04ffdb960ae2b3a1d991c5183dd981003de727d2d5cc38abc98/ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351", size = 9914291 }, + { url = "https://files.pythonhosted.org/packages/2e/dd/94fddf002a8f6152e8ebfbb51d3f93febc415c1fe694345623c31ce8b33b/ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8", size = 10331549 }, + { url = "https://files.pythonhosted.org/packages/b4/73/ca9c2f9237a430ca423b6dca83b77e9a428afeb7aec80596e86c369123fe/ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521", size = 7962163 }, + { url = "https://files.pythonhosted.org/packages/55/ce/061c605b1dfb52748d59bc0c7a8507546c178801156415773d18febfd71d/ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb", size = 8800901 }, + { url = "https://files.pythonhosted.org/packages/63/28/ae4ffe7d3b6134ca6d31ebef07447ef70097c4a9e8fbbc519b374c5c1559/ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82", size = 8229171 }, +] + [[package]] name = "tomli" version = "2.0.1" From 3d0526ca2b0f1545263661725656e42d84c5a123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 14:31:04 +0000 Subject: [PATCH 12/64] Add test workflow --- .github/workflows/test.yml | 34 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_client.py | 16 +++++++++++----- tox.ini | 15 --------------- uv.lock | 2 +- 5 files changed, 47 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/test.yml delete mode 100644 tox.ini diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e0e34ec --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +--- +name: "Test" +on: + pull_request: + push: + branches: + - "main" +env: + CLAMAV_SOCKET: /tmp/clamd.socket +jobs: + test: + name: "Test Python ${{ matrix.python-version }}" + 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.CLAMAV_SOCKET }} + tcp_port: 3310 + stream_max_length: 1M + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v1 + with: + enable-cache: true + - name: Run tests + run: | + versions=("3.8" "3.9" "3.10" "3.11" "3.12") + for version in "${versions[@]}"; do + uv run --frozen --python "$version" -- pytest + uv run --frozen --python "$version" -- ruff check + uv run --frozen --python "$version" -- ruff format --check + done diff --git a/pyproject.toml b/pyproject.toml index 9ec65ab..b8bb8a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "Python client library for the ClamAV antivirus." readme = "README.md" license = {file = "LICENSE"} -requires-python = ">=3.9" +requires-python = ">=3.8" dependencies = [] [build-system] diff --git a/tests/test_client.py b/tests/test_client.py index f3e4d7f..9c0086f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -9,9 +9,15 @@ from clamav_client import ConnectionError +EICAR_NAME = "Win.Test.EICAR_HDB-1" +if "CI" in os.environ: + EICAR_NAME = "Eicar-Signature" + + @pytest.fixture def unix_socket_client(): - return ClamdUnixSocket() + path = os.getenv("CLAMAV_SOCKET", "/var/run/clamav/clamd.ctl") + return ClamdUnixSocket(path=path) def test_cannot_connect(): @@ -36,7 +42,7 @@ def test_scan(unix_socket_client, tmp_path): file = tmp_path / "file" file.write_bytes(EICAR) file.chmod(0o644) - expected = {str(file): ("FOUND", "Win.Test.EICAR_HDB-1")} + expected = {str(file): ("FOUND", EICAR_NAME)} assert unix_socket_client.scan(str(file)) == expected @@ -49,14 +55,14 @@ def test_multiscan(unix_socket_client, tmp_path): file2.write_bytes(EICAR) file2.chmod(0o644) expected = { - str(file1): ("FOUND", "Win.Test.EICAR_HDB-1"), - str(file2): ("FOUND", "Win.Test.EICAR_HDB-1"), + str(file1): ("FOUND", EICAR_NAME), + str(file2): ("FOUND", EICAR_NAME), } assert unix_socket_client.multiscan(str(file1.parent)) == expected def test_instream(unix_socket_client): - expected = {"stream": ("FOUND", "Win.Test.EICAR_HDB-1")} + expected = {"stream": ("FOUND", EICAR_NAME)} assert unix_socket_client.instream(BytesIO(EICAR)) == expected diff --git a/tox.ini b/tox.ini deleted file mode 100644 index a7dbf0c..0000000 --- a/tox.ini +++ /dev/null @@ -1,15 +0,0 @@ -[tox] -envlist = py{26,27,33,34,35}, lint - -[testenv] -commands = py.test {posargs} -deps = - pytest==2.8.2 - -[testenv:lint] -deps = - flake8==2.4.0 -commands=flake8 src - -[flake8] -max-line-length = 117 diff --git a/uv.lock b/uv.lock index 688e799..8188d85 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -requires-python = ">=3.9" +requires-python = ">=3.8" [[package]] name = "clamav-client" From d5460bd2a0cb673a20e77c78a55887a2dc095b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 15:11:36 +0000 Subject: [PATCH 13/64] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4a1788a..aa87721 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.py[cod] *.egg-info/ +dist/ From 4459b50b943e554e622fd4dd6397782bc959f423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 16:07:28 +0000 Subject: [PATCH 14/64] Add release workflow --- .github/workflows/release.yml | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..276bd34 --- /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@v1 + with: + enable-cache: true + version: "0.4.5" + - 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" From 54aafb58bbbaec50c81667ec43c1c5303de55bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 5 Sep 2024 16:14:56 +0000 Subject: [PATCH 15/64] Bump version to 0.2.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b8bb8a3..44979be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "clamav-client" -version = "0.1.0" +version = "0.2.0" description = "Python client library for the ClamAV antivirus." readme = "README.md" license = {file = "LICENSE"} From 67f54ee8b69c16f58c1d97988d5e1abba2a95104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 9 Sep 2024 07:15:39 +0000 Subject: [PATCH 16/64] Move EICAR to tests --- clamav_client/__init__.py | 5 ----- tests/test_client.py | 8 +++++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/clamav_client/__init__.py b/clamav_client/__init__.py index 75ffab1..ae74d8a 100644 --- a/clamav_client/__init__.py +++ b/clamav_client/__init__.py @@ -4,17 +4,12 @@ import struct import contextlib import re -import base64 __version__ = importlib.metadata.version("clamav_client") scan_response = re.compile( r"^(?P.*): ((?P.+) )?(?P(FOUND|OK|ERROR))$" ) -EICAR = base64.b64decode( - b"WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E" - b"QVJELUFOVElWSVJVUy1URVNU\nLUZJTEUhJEgrSCo=\n" -) class ClamdError(Exception): diff --git a/tests/test_client.py b/tests/test_client.py index 9c0086f..121d9d3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,4 @@ +from base64 import b64decode from io import BytesIO import os import stat @@ -5,10 +6,15 @@ import pytest from clamav_client import ClamdUnixSocket -from clamav_client import EICAR from clamav_client import ConnectionError +EICAR = b64decode( + b"WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E" + b"QVJELUFOVElWSVJVUy1URVNU\nLUZJTEUhJEgrSCo=\n" +) + + EICAR_NAME = "Win.Test.EICAR_HDB-1" if "CI" in os.environ: EICAR_NAME = "Eicar-Signature" From cdeead812530c1cd3e5d1323bc26aa7e64cd6967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 9 Sep 2024 08:40:38 +0000 Subject: [PATCH 17/64] Move clamd client to its own module --- clamav_client/__init__.py | 299 ----------------------- clamav_client/clamd.py | 302 ++++++++++++++++++++++++ tests/{test_client.py => test_clamd.py} | 4 +- 3 files changed, 304 insertions(+), 301 deletions(-) create mode 100644 clamav_client/clamd.py rename tests/{test_client.py => test_clamd.py} (95%) diff --git a/clamav_client/__init__.py b/clamav_client/__init__.py index ae74d8a..9584531 100644 --- a/clamav_client/__init__.py +++ b/clamav_client/__init__.py @@ -1,302 +1,3 @@ import importlib.metadata -import socket -import sys -import struct -import contextlib -import re __version__ = importlib.metadata.version("clamav_client") - -scan_response = re.compile( - r"^(?P.*): ((?P.+) )?(?P(FOUND|OK|ERROR))$" -) - - -class ClamdError(Exception): - pass - - -class ResponseError(ClamdError): - pass - - -class BufferTooLongError(ResponseError): - """Class for errors with clamd using INSTREAM with a buffer lenght > StreamMaxLength in /etc/clamav/clamd.conf""" - - -class ConnectionError(ClamdError): - """Class for errors communication with clamd""" - - -class ClamdNetworkSocket(object): - """ - Class for using clamd with a network socket - """ - - def __init__(self, host="127.0.0.1", port=3310, timeout=None): - """ - class initialisation - - host (string) : hostname or ip address - port (int) : TCP port - timeout (float or None) : socket timeout - """ - - self.host = host - self.port = port - self.timeout = timeout - - def _init_socket(self): - """ - 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) - - except socket.error: - e = sys.exc_info()[1] - raise ConnectionError(self._error_message(e)) - - def _error_message(self, exception): - # 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] - ) - 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 self._basic_command("PING") - - def version(self): - return self._basic_command("VERSION") - - def reload(self): - return self._basic_command("RELOAD") - - def shutdown(self): - """ - Force Clamd to shutdown and exit - - return: nothing - - May raise: - - ConnectionError: in case of communication problem - """ - try: - self._init_socket() - self._send_command("SHUTDOWN") - # result = self._recv_response() - finally: - self._close_socket() - - def scan(self, file): - return self._file_system_scan("SCAN", file) - - def contscan(self, file): - return self._file_system_scan("CONTSCAN", file) - - def multiscan(self, file): - return self._file_system_scan("MULTISCAN", file) - - def _basic_command(self, command): - """ - 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]) - else: - return response[0] - finally: - self._close_socket() - - def _file_system_scan(self, command, file): - """ - Scan a file or directory given by filename using multiple threads (faster on SMP machines). - Do not stop on error or virus found. - Scan with archive support enabled. - - file (string): filename or directory (MUST BE ABSOLUTE PATH !) - - return: - - (dict): {filename1: ('FOUND', 'virusname'), filename2: ('ERROR', 'reason')} - - 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"): - if result: - filename, reason, status = self._parse_response(result) - dr[filename] = (status, reason) - - return dr - - finally: - self._close_socket() - - def instream(self, buff): - """ - Scan a buffer - - buff filelikeobj: buffer to scan - - return: - - (dict): {filename1: ("virusname", "status")} - - May raise : - - BufferTooLongError: if the buffer size exceeds clamd limits - - ConnectionError: in case of communication problem - """ - - try: - self._init_socket() - 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) - chunk = buff.read(max_chunk_size) - - self.clamd_socket.send(struct.pack(b"!L", 0)) - - result = self._recv_response() - - if len(result) > 0: - if result == "INSTREAM size limit exceeded. ERROR": - raise BufferTooLongError(result) - - filename, reason, status = self._parse_response(result) - return {filename: (status, reason)} - finally: - self._close_socket() - - def stats(self): - """ - Get Clamscan stats - - return: (string) clamscan stats - - May raise: - - ConnectionError: in case of communication problem - """ - self._init_socket() - try: - self._send_command("STATS") - return self._recv_response_multiline() - finally: - self._close_socket() - - def _send_command(self, cmd, *args): - """ - `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 = "" - if args: - concat_args = " " + " ".join(args) - - cmd = "n{cmd}{args}\n".format(cmd=cmd, args=concat_args).encode("utf-8") - self.clamd_socket.send(cmd) - - def _recv_response(self): - """ - 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) as err: - raise ConnectionError( - "Error while reading from socket: {0}".format(err.args) - ) - - def _recv_response_multiline(self): - """ - 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) as err: - raise ConnectionError( - "Error while reading from socket: {0}".format(err.args) - ) - - def _close_socket(self): - """ - close clamd socket - """ - self.clamd_socket.close() - return - - def _parse_response(self, msg): - """ - parses responses for SCAN, CONTSCAN, MULTISCAN and STREAM commands. - """ - if match := scan_response.match(msg): - return match.group("path", "virus", "status") - else: - raise ResponseError(msg.rsplit("ERROR", 1)[0]) - - -class ClamdUnixSocket(ClamdNetworkSocket): - """ - Class for using clamd with an unix socket - """ - - def __init__(self, path="/var/run/clamav/clamd.ctl", timeout=None): - """ - class initialisation - - path (string) : unix socket path - timeout (float or None) : socket timeout - """ - - self.unix_socket = path - self.timeout = timeout - - def _init_socket(self): - """ - internal use only - """ - try: - 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)) - - def _error_message(self, exception): - # 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] - ) - else: - return "Error {erno} connecting {path}. {msg}.".format( - erno=exception.args[0], path=self.unix_socket, msg=exception.args[1] - ) diff --git a/clamav_client/clamd.py b/clamav_client/clamd.py new file mode 100644 index 0000000..1900a49 --- /dev/null +++ b/clamav_client/clamd.py @@ -0,0 +1,302 @@ +"""A client for the ClamAV daemon (clamd), supporting both TCP and Unix socket +connections.""" + +import socket +import sys +import struct +import contextlib +import re + +scan_response = re.compile( + r"^(?P.*): ((?P.+) )?(?P(FOUND|OK|ERROR))$" +) + + +class ClamdError(Exception): + pass + + +class ResponseError(ClamdError): + pass + + +class BufferTooLongError(ResponseError): + """Class for errors with clamd using INSTREAM with a buffer lenght > StreamMaxLength in /etc/clamav/clamd.conf""" + + +class ConnectionError(ClamdError): + """Class for errors communication with clamd""" + + +class ClamdNetworkSocket(object): + """ + Class for using clamd with a network socket + """ + + def __init__(self, host="127.0.0.1", port=3310, timeout=None): + """ + class initialisation + + host (string) : hostname or ip address + port (int) : TCP port + timeout (float or None) : socket timeout + """ + + self.host = host + self.port = port + self.timeout = timeout + + def _init_socket(self): + """ + 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) + + except socket.error: + e = sys.exc_info()[1] + raise ConnectionError(self._error_message(e)) + + def _error_message(self, exception): + # 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] + ) + 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 self._basic_command("PING") + + def version(self): + return self._basic_command("VERSION") + + def reload(self): + return self._basic_command("RELOAD") + + def shutdown(self): + """ + Force Clamd to shutdown and exit + + return: nothing + + May raise: + - ConnectionError: in case of communication problem + """ + try: + self._init_socket() + self._send_command("SHUTDOWN") + # result = self._recv_response() + finally: + self._close_socket() + + def scan(self, file): + return self._file_system_scan("SCAN", file) + + def contscan(self, file): + return self._file_system_scan("CONTSCAN", file) + + def multiscan(self, file): + return self._file_system_scan("MULTISCAN", file) + + def _basic_command(self, command): + """ + 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]) + else: + return response[0] + finally: + self._close_socket() + + def _file_system_scan(self, command, file): + """ + Scan a file or directory given by filename using multiple threads (faster on SMP machines). + Do not stop on error or virus found. + Scan with archive support enabled. + + file (string): filename or directory (MUST BE ABSOLUTE PATH !) + + return: + - (dict): {filename1: ('FOUND', 'virusname'), filename2: ('ERROR', 'reason')} + + 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"): + if result: + filename, reason, status = self._parse_response(result) + dr[filename] = (status, reason) + + return dr + + finally: + self._close_socket() + + def instream(self, buff): + """ + Scan a buffer + + buff filelikeobj: buffer to scan + + return: + - (dict): {filename1: ("virusname", "status")} + + May raise : + - BufferTooLongError: if the buffer size exceeds clamd limits + - ConnectionError: in case of communication problem + """ + + try: + self._init_socket() + 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) + chunk = buff.read(max_chunk_size) + + self.clamd_socket.send(struct.pack(b"!L", 0)) + + result = self._recv_response() + + if len(result) > 0: + if result == "INSTREAM size limit exceeded. ERROR": + raise BufferTooLongError(result) + + filename, reason, status = self._parse_response(result) + return {filename: (status, reason)} + finally: + self._close_socket() + + def stats(self): + """ + Get Clamscan stats + + return: (string) clamscan stats + + May raise: + - ConnectionError: in case of communication problem + """ + self._init_socket() + try: + self._send_command("STATS") + return self._recv_response_multiline() + finally: + self._close_socket() + + def _send_command(self, cmd, *args): + """ + `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 = "" + if args: + concat_args = " " + " ".join(args) + + cmd = "n{cmd}{args}\n".format(cmd=cmd, args=concat_args).encode("utf-8") + self.clamd_socket.send(cmd) + + def _recv_response(self): + """ + 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) as err: + raise ConnectionError( + "Error while reading from socket: {0}".format(err.args) + ) + + def _recv_response_multiline(self): + """ + 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) as err: + raise ConnectionError( + "Error while reading from socket: {0}".format(err.args) + ) + + def _close_socket(self): + """ + close clamd socket + """ + self.clamd_socket.close() + return + + def _parse_response(self, msg): + """ + parses responses for SCAN, CONTSCAN, MULTISCAN and STREAM commands. + """ + if match := scan_response.match(msg): + return match.group("path", "virus", "status") + else: + raise ResponseError(msg.rsplit("ERROR", 1)[0]) + + +class ClamdUnixSocket(ClamdNetworkSocket): + """ + Class for using clamd with an unix socket + """ + + def __init__(self, path="/var/run/clamav/clamd.ctl", timeout=None): + """ + class initialisation + + path (string) : unix socket path + timeout (float or None) : socket timeout + """ + + self.unix_socket = path + self.timeout = timeout + + def _init_socket(self): + """ + internal use only + """ + try: + 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)) + + def _error_message(self, exception): + # 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] + ) + else: + return "Error {erno} connecting {path}. {msg}.".format( + erno=exception.args[0], path=self.unix_socket, msg=exception.args[1] + ) diff --git a/tests/test_client.py b/tests/test_clamd.py similarity index 95% rename from tests/test_client.py rename to tests/test_clamd.py index 121d9d3..4e38b25 100644 --- a/tests/test_client.py +++ b/tests/test_clamd.py @@ -5,8 +5,8 @@ import pytest -from clamav_client import ClamdUnixSocket -from clamav_client import ConnectionError +from clamav_client.clamd import ClamdUnixSocket +from clamav_client.clamd import ConnectionError EICAR = b64decode( From bf97d573c939712959581c04baaf6cd80f23fcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 9 Sep 2024 09:56:17 +0000 Subject: [PATCH 18/64] Add type hints --- clamav_client/clamd.py | 103 ++++++++++++++++++++--------------------- clamav_client/py.typed | 0 pyproject.toml | 4 ++ tests/test_clamd.py | 21 +++++---- uv.lock | 61 +++++++++++++++++++++++- 5 files changed, 126 insertions(+), 63 deletions(-) create mode 100644 clamav_client/py.typed diff --git a/clamav_client/clamd.py b/clamav_client/clamd.py index 1900a49..9391e34 100644 --- a/clamav_client/clamd.py +++ b/clamav_client/clamd.py @@ -2,16 +2,21 @@ connections.""" import socket -import sys import struct import contextlib import re +from typing import Any, BinaryIO, Dict, Optional, Tuple scan_response = re.compile( r"^(?P.*): ((?P.+) )?(?P(FOUND|OK|ERROR))$" ) +ScanStatus = str +ScanResult = Tuple[ScanStatus, Optional[str]] +ScanResults = Dict[str, ScanResult] + + class ClamdError(Exception): pass @@ -33,7 +38,9 @@ class ClamdNetworkSocket(object): 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 @@ -41,12 +48,11 @@ 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 """ @@ -54,12 +60,10 @@ def _init_socket(self): 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) + except socket.error as err: + raise ConnectionError(self._error_message(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: @@ -74,16 +78,16 @@ def _error_message(self, exception): msg=exception.args[1], ) - def ping(self): + 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 @@ -95,35 +99,37 @@ def shutdown(self): try: self._init_socket() self._send_command("SHUTDOWN") - # result = self._recv_response() finally: self._close_socket() - def scan(self, file): + def scan(self, file: str) -> ScanResults: return self._file_system_scan("SCAN", file) - def contscan(self, file): + def contscan(self, file: str) -> ScanResults: return self._file_system_scan("CONTSCAN", file) - def multiscan(self, 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. @@ -137,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 @@ -166,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") - 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) chunk = buff.read(max_chunk_size) - self.clamd_socket.send(struct.pack(b"!L", 0)) - result = self._recv_response() - if len(result) > 0: 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 @@ -208,7 +208,7 @@ def stats(self): 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 @@ -216,11 +216,10 @@ def _send_command(self, cmd, *args): concat_args = "" if args: concat_args = " " + " ".join(args) + send = "n{cmd}{args}\n".format(cmd=cmd, args=concat_args).encode("utf-8") + self.clamd_socket.send(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 """ @@ -232,7 +231,7 @@ def _recv_response(self): "Error while reading from socket: {0}".format(err.args) ) - def _recv_response_multiline(self): + def _recv_response_multiline(self) -> str: """ receive multiple line response from clamd and strip all whitespace characters """ @@ -244,14 +243,13 @@ def _recv_response_multiline(self): "Error while reading from socket: {0}".format(err.args) ) - def _close_socket(self): + 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[str | Any, ...]: """ parses responses for SCAN, CONTSCAN, MULTISCAN and STREAM commands. """ @@ -266,7 +264,9 @@ 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 @@ -277,7 +277,7 @@ class initialisation self.unix_socket = path self.timeout = timeout - def _init_socket(self): + def _init_socket(self) -> None: """ internal use only """ @@ -285,11 +285,10 @@ 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 socket.error as err: + raise ConnectionError(self._error_message(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: diff --git a/clamav_client/py.typed b/clamav_client/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 44979be..a45c10b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,10 @@ build-backend = "hatchling.build" [tool.uv] dev-dependencies = [ + "mypy>=1.11.2", "pytest>=8.3.2", "ruff>=0.6.3", ] + +[tool.mypy] +strict = true diff --git a/tests/test_clamd.py b/tests/test_clamd.py index 4e38b25..41e8147 100644 --- a/tests/test_clamd.py +++ b/tests/test_clamd.py @@ -1,6 +1,7 @@ from base64 import b64decode from io import BytesIO import os +import pathlib import stat import pytest @@ -21,29 +22,29 @@ @pytest.fixture -def unix_socket_client(): +def unix_socket_client() -> ClamdUnixSocket: path = os.getenv("CLAMAV_SOCKET", "/var/run/clamav/clamd.ctl") return ClamdUnixSocket(path=path) -def test_cannot_connect(): +def test_cannot_connect() -> None: with pytest.raises(ConnectionError): ClamdUnixSocket(path="/tmp/404").ping() -def test_ping(unix_socket_client): +def test_ping(unix_socket_client: ClamdUnixSocket) -> None: unix_socket_client.ping() -def test_version(unix_socket_client): +def test_version(unix_socket_client: ClamdUnixSocket) -> None: assert unix_socket_client.version().startswith("ClamAV") -def test_reload(unix_socket_client): +def test_reload(unix_socket_client: ClamdUnixSocket) -> None: assert unix_socket_client.reload() == "RELOADING" -def test_scan(unix_socket_client, tmp_path): +def test_scan(unix_socket_client: ClamdUnixSocket, tmp_path: pathlib.Path) -> None: update_tmp_path_perms(tmp_path) file = tmp_path / "file" file.write_bytes(EICAR) @@ -52,7 +53,7 @@ def test_scan(unix_socket_client, tmp_path): assert unix_socket_client.scan(str(file)) == expected -def test_multiscan(unix_socket_client, tmp_path): +def test_multiscan(unix_socket_client: ClamdUnixSocket, tmp_path: pathlib.Path) -> None: update_tmp_path_perms(tmp_path) file1 = tmp_path / "file1" file1.write_bytes(EICAR) @@ -67,16 +68,16 @@ def test_multiscan(unix_socket_client, tmp_path): assert unix_socket_client.multiscan(str(file1.parent)) == expected -def test_instream(unix_socket_client): +def test_instream(unix_socket_client: ClamdUnixSocket) -> None: expected = {"stream": ("FOUND", EICAR_NAME)} assert unix_socket_client.instream(BytesIO(EICAR)) == expected -def test_insteam_success(unix_socket_client): +def test_insteam_success(unix_socket_client: ClamdUnixSocket) -> None: assert unix_socket_client.instream(BytesIO(b"foo")) == {"stream": ("OK", None)} -def update_tmp_path_perms(temp_file): +def update_tmp_path_perms(temp_file: pathlib.Path) -> None: """Update perms so ClamAV can traverse and read.""" stop_at = temp_file.parent.parent.parent for parent in [temp_file] + list(temp_file.parents): diff --git a/uv.lock b/uv.lock index 8188d85..e8c61be 100644 --- a/uv.lock +++ b/uv.lock @@ -3,11 +3,12 @@ requires-python = ">=3.8" [[package]] name = "clamav-client" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, ] @@ -16,6 +17,7 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "mypy", specifier = ">=1.11.2" }, { name = "pytest", specifier = ">=8.3.2" }, { name = "ruff", specifier = ">=0.6.3" }, ] @@ -47,6 +49,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] +[[package]] +name = "mypy" +version = "1.11.2" +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/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401 }, + { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697 }, + { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508 }, + { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712 }, + { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319 }, + { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630 }, + { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973 }, + { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659 }, + { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010 }, + { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873 }, + { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335 }, + { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119 }, + { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856 }, + { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066 }, + { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000 }, + { url = "https://files.pythonhosted.org/packages/42/ad/5a8567700410f8aa7c755b0ebd4cacff22468cbc5517588773d65075c0cb/mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b", size = 10876550 }, + { url = "https://files.pythonhosted.org/packages/1b/bc/9fc16ea7a27ceb93e123d300f1cfe27a6dd1eac9a8beea4f4d401e737e9d/mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86", size = 10068086 }, + { url = "https://files.pythonhosted.org/packages/cd/8f/a1e460f1288405a13352dad16b24aba6dce4f850fc76510c540faa96eda3/mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce", size = 12459214 }, + { url = "https://files.pythonhosted.org/packages/c7/74/746b31aef7cc7512dab8bdc2311ef88d63fadc1c453a09c8cab7e57e59bf/mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1", size = 12962942 }, + { url = "https://files.pythonhosted.org/packages/28/a4/7fae712240b640d75bb859294ad4776b9960b3216ccb7fa747f578e6c632/mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b", size = 9545616 }, + { url = "https://files.pythonhosted.org/packages/16/64/bb5ed751487e2bea0dfaa6f640a7e3bb88083648f522e766d5ef4a76f578/mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6", size = 10937294 }, + { url = "https://files.pythonhosted.org/packages/a9/a3/67a0069abed93c3bf3b0bebb8857e2979a02828a4a3fd82f107f8f1143e8/mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70", size = 10107707 }, + { url = "https://files.pythonhosted.org/packages/2f/4d/0379daf4258b454b1f9ed589a9dabd072c17f97496daea7b72fdacf7c248/mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d", size = 12498367 }, + { url = "https://files.pythonhosted.org/packages/3b/dc/3976a988c280b3571b8eb6928882dc4b723a403b21735a6d8ae6ed20e82b/mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d", size = 13018014 }, + { url = "https://files.pythonhosted.org/packages/83/84/adffc7138fb970e7e2a167bd20b33bb78958370179853a4ebe9008139342/mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24", size = 9568056 }, + { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + [[package]] name = "packaging" version = "24.1" @@ -115,3 +165,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb0856 wheels = [ { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, ] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] From 2e961e5848a93eb5e09b6ec33ed5e96e9c707df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 9 Sep 2024 10:14:35 +0000 Subject: [PATCH 19/64] Enable additional linters --- clamav_client/clamd.py | 24 ++++++++++++++---------- pyproject.toml | 6 ++++++ tests/test_clamd.py | 9 ++++----- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/clamav_client/clamd.py b/clamav_client/clamd.py index 9391e34..f8d36d7 100644 --- a/clamav_client/clamd.py +++ b/clamav_client/clamd.py @@ -1,11 +1,15 @@ """A client for the ClamAV daemon (clamd), supporting both TCP and Unix socket connections.""" -import socket -import struct import contextlib import re -from typing import Any, BinaryIO, Dict, Optional, Tuple +import socket +import struct +from typing import Any +from typing import BinaryIO +from typing import Dict +from typing import Optional +from typing import Tuple scan_response = re.compile( r"^(?P.*): ((?P.+) )?(?P(FOUND|OK|ERROR))$" @@ -29,7 +33,7 @@ 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""" @@ -61,7 +65,7 @@ def _init_socket(self) -> None: self.clamd_socket.connect((self.host, self.port)) self.clamd_socket.settimeout(self.timeout) except socket.error as err: - raise ConnectionError(self._error_message(err)) + raise CommunicationError(self._error_message(err)) from err def _error_message(self, exception: BaseException) -> str: # args for socket.error can either be (errno, "message") @@ -227,9 +231,9 @@ def _recv_response(self) -> str: with contextlib.closing(self.clamd_socket.makefile("rb")) as f: return f.readline().decode("utf-8").strip() except (socket.error, socket.timeout) as err: - raise ConnectionError( + raise CommunicationError( "Error while reading from socket: {0}".format(err.args) - ) + ) from err def _recv_response_multiline(self) -> str: """ @@ -239,9 +243,9 @@ def _recv_response_multiline(self) -> str: with contextlib.closing(self.clamd_socket.makefile("rb")) as f: return f.read().decode("utf-8") except (socket.error, socket.timeout) as err: - raise ConnectionError( + raise CommunicationError( "Error while reading from socket: {0}".format(err.args) - ) + ) from err def _close_socket(self) -> None: """ @@ -286,7 +290,7 @@ def _init_socket(self) -> None: self.clamd_socket.connect(self.unix_socket) self.clamd_socket.settimeout(self.timeout) except socket.error as err: - raise ConnectionError(self._error_message(err)) + raise CommunicationError(self._error_message(err)) from err def _error_message(self, exception: BaseException) -> str: # args for socket.error can either be (errno, "message") diff --git a/pyproject.toml b/pyproject.toml index a45c10b..d5e1e1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,3 +20,9 @@ dev-dependencies = [ [tool.mypy] strict = true + +[tool.ruff.lint] +extend-select = ["A", "B", "I", "Q"] + +[tool.ruff.lint.isort] +force-single-line = true diff --git a/tests/test_clamd.py b/tests/test_clamd.py index 41e8147..d7cbab4 100644 --- a/tests/test_clamd.py +++ b/tests/test_clamd.py @@ -1,14 +1,13 @@ -from base64 import b64decode -from io import BytesIO import os import pathlib import stat +from base64 import b64decode +from io import BytesIO import pytest from clamav_client.clamd import ClamdUnixSocket -from clamav_client.clamd import ConnectionError - +from clamav_client.clamd import CommunicationError EICAR = b64decode( b"WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E" @@ -28,7 +27,7 @@ def unix_socket_client() -> ClamdUnixSocket: def test_cannot_connect() -> None: - with pytest.raises(ConnectionError): + with pytest.raises(CommunicationError): ClamdUnixSocket(path="/tmp/404").ping() From a3c78a9e45c0a6e5234f8d0fddc8dbc9446f85ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 9 Sep 2024 10:24:04 +0000 Subject: [PATCH 20/64] Add test script --- .github/workflows/test.yml | 8 +------- clamav_client/clamd.py | 3 ++- pyproject.toml | 3 ++- test.sh | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 9 deletions(-) create mode 100755 test.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0e34ec..f8037f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,10 +25,4 @@ jobs: with: enable-cache: true - name: Run tests - run: | - versions=("3.8" "3.9" "3.10" "3.11" "3.12") - for version in "${versions[@]}"; do - uv run --frozen --python "$version" -- pytest - uv run --frozen --python "$version" -- ruff check - uv run --frozen --python "$version" -- ruff format --check - done + run: ./test.sh diff --git a/clamav_client/clamd.py b/clamav_client/clamd.py index f8d36d7..25160cf 100644 --- a/clamav_client/clamd.py +++ b/clamav_client/clamd.py @@ -10,6 +10,7 @@ from typing import Dict from typing import Optional from typing import Tuple +from typing import Union scan_response = re.compile( r"^(?P.*): ((?P.+) )?(?P(FOUND|OK|ERROR))$" @@ -253,7 +254,7 @@ def _close_socket(self) -> None: """ self.clamd_socket.close() - def _parse_response(self, msg: str) -> Tuple[str | Any, ...]: + def _parse_response(self, msg: str) -> Tuple[Union[str, Any], ...]: """ parses responses for SCAN, CONTSCAN, MULTISCAN and STREAM commands. """ diff --git a/pyproject.toml b/pyproject.toml index d5e1e1f..c7e37a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "clamav-client" -version = "0.2.0" +version = "0.3.0" description = "Python client library for the ClamAV antivirus." readme = "README.md" license = {file = "LICENSE"} @@ -19,6 +19,7 @@ dev-dependencies = [ ] [tool.mypy] +files = ["clamav_client", "tests"] strict = true [tool.ruff.lint] diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..ce80dd8 --- /dev/null +++ b/test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! command -v uv > /dev/null; then + echo "Error: 'uv' is not installed or not in the PATH." + exit 1 +fi + +versions=( + "3.8" + "3.9" + "3.10" + "3.11" + "3.12" +) + +print_status() { + echo -en "\n➡️ $1\n\n" +} + +for version in "${versions[@]}"; do + print_status "Running \`pytest\` using Python $version..." + uv run --frozen --python "$version" -- pytest +done + +latest="${versions[${#versions[@]}-1]}" + +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 From 8d3efb400adbe973e5964959a622e96d70801220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 9 Sep 2024 11:11:46 +0000 Subject: [PATCH 21/64] Clarify the purpose of the fork --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ae3f949..b3add9e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ 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. +This is a fork of [clamd] ([5c5e33b2]) created by Thomas Grainger. It introduces +type hints and tests exclusively against supported Python versions. [clamd]: https://github.com/graingert/python-clamd From a29340e7340f2415b4c3ccd505b2a1b213e6ca73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 9 Sep 2024 11:38:28 +0000 Subject: [PATCH 22/64] Use dynamic version with hatch-vcs --- pyproject.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c7e37a2..d715bb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "clamav-client" -version = "0.3.0" +dynamic = ["version"] description = "Python client library for the ClamAV antivirus." readme = "README.md" license = {file = "LICENSE"} @@ -8,9 +8,12 @@ requires-python = ">=3.8" dependencies = [] [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +[tool.hatch.version] +source = "vcs" + [tool.uv] dev-dependencies = [ "mypy>=1.11.2", From d93bf6c0b982657f77f36f4791dd063084dac3ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 9 Sep 2024 11:41:23 +0000 Subject: [PATCH 23/64] Update license attribute --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d715bb4..ddc26bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "clamav-client" dynamic = ["version"] description = "Python client library for the ClamAV antivirus." readme = "README.md" -license = {file = "LICENSE"} +license = { text = "GPL-2.0" } requires-python = ">=3.8" dependencies = [] From 537b746f52dd6f8734d0bd0ceb08d9348e6cfc6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 9 Sep 2024 11:49:51 +0000 Subject: [PATCH 24/64] Use astra-sh/setup-uv@v2 --- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 276bd34..a1f0458 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,10 @@ jobs: - name: Check out repository uses: "actions/checkout@v4" - name: Install the latest version of uv - uses: astral-sh/setup-uv@v1 + uses: astral-sh/setup-uv@v2 with: enable-cache: true - version: "0.4.5" + version: latest - name: Build package run: uv build - name: "Save distribution directory" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f8037f0..de0f928 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,8 +21,9 @@ jobs: tcp_port: 3310 stream_max_length: 1M - name: Install the latest version of uv - uses: astral-sh/setup-uv@v1 + uses: astral-sh/setup-uv@v2 with: enable-cache: true + version: latest - name: Run tests run: ./test.sh From 3910715897f3108ba7b6a670a2e68797394ec7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Tue, 10 Sep 2024 09:28:09 +0000 Subject: [PATCH 25/64] Add scanner interface --- .github/workflows/test.yml | 7 +- clamav_client/__init__.py | 6 + clamav_client/clamd.py | 6 +- clamav_client/scanner.py | 197 +++++++++++++++++++++++++++ tests/conftest.py | 39 ++++++ tests/integration/test_clamd_net.py | 36 +++++ tests/integration/test_clamd_unix.py | 85 ++++++++++++ tests/integration/test_scanner.py | 156 +++++++++++++++++++++ tests/test_clamd.py | 88 ------------ 9 files changed, 528 insertions(+), 92 deletions(-) create mode 100644 clamav_client/scanner.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/test_clamd_net.py create mode 100644 tests/integration/test_clamd_unix.py create mode 100644 tests/integration/test_scanner.py delete mode 100644 tests/test_clamd.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de0f928..cfa11e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,8 @@ on: branches: - "main" env: - CLAMAV_SOCKET: /tmp/clamd.socket + CLAMD_UNIX_SOCKET: "/tmp/clamd.socket" + CLAMD_TCP_PORT: "3310" jobs: test: name: "Test Python ${{ matrix.python-version }}" @@ -17,8 +18,8 @@ jobs: - name: Start ClamAV daemon clamd uses: toblux/start-clamd-github-action@bae519cc165de29b89cbb9c4528f61c34b1c848b # v0.2.1 with: - unix_socket: ${{ env.CLAMAV_SOCKET }} - tcp_port: 3310 + unix_socket: ${{ env.CLAMD_UNIX_SOCKET }} + tcp_port: ${{ env.CLAMD_TCP_PORT }} stream_max_length: 1M - name: Install the latest version of uv uses: astral-sh/setup-uv@v2 diff --git a/clamav_client/__init__.py b/clamav_client/__init__.py index 9584531..3ef29c2 100644 --- a/clamav_client/__init__.py +++ b/clamav_client/__init__.py @@ -1,3 +1,9 @@ import importlib.metadata +from clamav_client.scanner import get_scanner + __version__ = importlib.metadata.version("clamav_client") + +__all__ = [ + "get_scanner", +] diff --git a/clamav_client/clamd.py b/clamav_client/clamd.py index 25160cf..5d50951 100644 --- a/clamav_client/clamd.py +++ b/clamav_client/clamd.py @@ -1,5 +1,9 @@ """A client for the ClamAV daemon (clamd), supporting both TCP and Unix socket -connections.""" +connections. + +This module stays as close as possible to its original counterpart, the clamd +project on which this code is based, to maintain backward compatibility. +""" import contextlib import re diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py new file mode 100644 index 0000000..4f5964d --- /dev/null +++ b/clamav_client/scanner.py @@ -0,0 +1,197 @@ +"""A general-purpose scanner compatible with both ``clamd`` and ``clamscan``.""" + +import abc +import re +from dataclasses import dataclass +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 ClamdNetworkSocket +from clamav_client.clamd import ClamdUnixSocket + +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 + + def update(self, state: ScanResultState, details: ScanResultDetails) -> None: + self.state = state + self.details = details + + +class Scanner(abc.ABC): + _info: ScannerInfo + _program: ProgramName + + @abc.abstractmethod + def scan(self, filename: str) -> ScanResult: + pass + + @abc.abstractmethod + def _get_version(self) -> str: + pass + + def info(self) -> ScannerInfo: + try: + return self._info + except AttributeError: + self._info = self._parse_version(self._get_version()) + return self._info + + 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) + try: + report = self.client.scan(filename) + except Exception as err: + result.update(state="ERROR", details=str(err)) + file_report = report.get(filename) + if file_report is None: + return result + state, details = file_report + result.update(state, details) # type: ignore[arg-type] + return result + + def _get_version(self) -> str: + return self.client.version() + + +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) + + def scan(self, filename: str) -> ScanResult: + result = ScanResult(filename=filename, state=None, details=None) + max_file_size = "--max-filesize=%dM" % self.max_file_size + max_scan_size = "--max-scansize=%dM" % self.max_scan_size + try: + self._call(max_file_size, max_scan_size, filename) + except CalledProcessError as err: + if err.returncode == 1: + result.update("FOUND", self._parse_found(err.output)) + else: + stderr = err.stderr.decode("utf-8", errors="replace") + result.update("ERROR", stderr) + else: + result.update("OK", None) + return result + + def _get_version(self) -> str: + return self._call("-V").decode("utf-8") + + 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", errors="replace") + 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/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..60eaab5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +from base64 import b64decode +from os import environ +from os import getenv + +import pytest + +from clamav_client.clamd import ClamdNetworkSocket +from clamav_client.clamd import ClamdUnixSocket + +# TODO: figure out this discrepancy - likely because we're missing recent sigs +# in the CI job. +EICAR_NAME = "Win.Test.EICAR_HDB-1" +if "CI" in environ: + EICAR_NAME = "Eicar-Signature" + + +@pytest.fixture +def eicar_name() -> str: + return EICAR_NAME + + +@pytest.fixture +def eicar() -> bytes: + return b64decode( + b"WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E" + b"QVJELUFOVElWSVJVUy1URVNU\nLUZJTEUhJEgrSCo=\n" + ) + + +@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)) diff --git a/tests/integration/test_clamd_net.py b/tests/integration/test_clamd_net.py new file mode 100644 index 0000000..b15be6a --- /dev/null +++ b/tests/integration/test_clamd_net.py @@ -0,0 +1,36 @@ +from io import BytesIO + +import pytest + +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_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_insteam_ok(clamd_net_client: ClamdNetworkSocket) -> None: + assert clamd_net_client.instream(BytesIO(b"foo")) == {"stream": ("OK", None)} diff --git a/tests/integration/test_clamd_unix.py b/tests/integration/test_clamd_unix.py new file mode 100644 index 0000000..2254212 --- /dev/null +++ b/tests/integration/test_clamd_unix.py @@ -0,0 +1,85 @@ +import os +import pathlib +import stat +from io import BytesIO + +import pytest + +from clamav_client.clamd import ClamdUnixSocket +from clamav_client.clamd import CommunicationError + + +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_scan( + clamd_unix_client: ClamdUnixSocket, + tmp_path: pathlib.Path, + eicar: bytes, + eicar_name: str, +) -> None: + update_tmp_path_perms(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( + clamd_unix_client: ClamdUnixSocket, + tmp_path: pathlib.Path, + eicar: bytes, + eicar_name: str, +) -> None: + update_tmp_path_perms(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_insteam_ok(clamd_unix_client: ClamdUnixSocket) -> None: + assert clamd_unix_client.instream(BytesIO(b"foo")) == {"stream": ("OK", None)} + + +def update_tmp_path_perms(temp_file: pathlib.Path) -> None: + """Update perms so ClamAV can traverse and read.""" + 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 + os.chmod( + parent, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH | stat.S_IROTH + ) diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py new file mode 100644 index 0000000..4a24002 --- /dev/null +++ b/tests/integration/test_scanner.py @@ -0,0 +1,156 @@ +from subprocess import CalledProcessError +from unittest import mock + +import pytest + +from clamav_client import get_scanner +from clamav_client.scanner import ClamdScanner +from clamav_client.scanner import ClamdScannerConfig +from clamav_client.scanner import ClamscanScanner +from clamav_client.scanner import ClamscanScannerConfig +from clamav_client.scanner import Scanner +from clamav_client.scanner import ScannerInfo +from clamav_client.scanner import ScanResult + +CLAMAV_VERSION = "ClamAV 0.103.11/27393/Mon Sep 9 10:29:16 2024" + + +@pytest.fixture +def clamd_scanner() -> Scanner: + config: ClamdScannerConfig = { + "backend": "clamd", + } + return get_scanner(config) + + +@pytest.fixture +def clamscan_scanner() -> Scanner: + config: ClamscanScannerConfig = { + "backend": "clamscan", + } + return get_scanner(config) + + +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] + + +def test_get_scanner_with_clamd_backend() -> None: + scanner = get_scanner({"backend": "clamd"}) + assert isinstance(scanner, ClamdScanner) + + +def test_get_scanner_with_clamscan_backend() -> None: + scanner = get_scanner({"backend": "clamscan"}) + assert isinstance(scanner, ClamscanScanner) + + +@mock.patch( + "clamav_client.scanner.check_output", + return_value=CLAMAV_VERSION.encode("utf-8"), +) +def test_clamscan_scanner_info(mock: mock.Mock, clamscan_scanner: Scanner) -> None: + assert clamscan_scanner.info() == ScannerInfo( + name="ClamAV (clamscan)", + version="ClamAV 0.103.11", + virus_definitions="27393/Mon Sep 9 10:29:16 2024", + ) + + +@mock.patch( + "clamav_client.scanner.check_output", + side_effect=CalledProcessError( + cmd="clamscan", + returncode=1, + output=b"/tmp/eicar: Win.Test.EICAR_HDB-1 FOUND", + ), +) +def test_clamscan_scanner_scan_found( + mock: mock.Mock, clamscan_scanner: Scanner +) -> None: + assert clamscan_scanner.scan("/tmp/eicar") == ScanResult( + filename="/tmp/eicar", + state="FOUND", + details="Win.Test.EICAR_HDB-1", + ) + + +@mock.patch( + "clamav_client.scanner.check_output", + side_effect=CalledProcessError( + cmd="clamscan", + returncode=2, + stderr=b"/tmp/eicar: No such file or directory\n", + ), +) +def test_clamscan_scanner_scan_error( + mock: mock.Mock, clamscan_scanner: Scanner +) -> None: + assert clamscan_scanner.scan("/tmp/eicar") == ScanResult( + filename="/tmp/eicar", + state="ERROR", + details="/tmp/eicar: No such file or directory\n", + ) + + +@mock.patch( + "clamav_client.scanner.check_output", + return_value=b"/tmp/eicar: OK\n", +) +def test_clamscan_scanner_scan_ok(mock: mock.Mock, clamscan_scanner: Scanner) -> None: + assert clamscan_scanner.scan("/tmp/eicar") == ScanResult( + filename="/tmp/eicar", + state="OK", + details=None, + ) + + +@mock.patch( + "clamav_client.scanner.ClamdUnixSocket", + return_value=mock.Mock(version=mock.Mock(return_value=CLAMAV_VERSION)), +) +def test_clamd_scanner_info(mock: mock.Mock) -> None: + scanner = get_scanner( + { + "backend": "clamd", + "address": "/var/run/clamav/clamd.ctl", + } + ) + assert scanner.info() == ScannerInfo( + name="ClamAV (clamd)", + version="ClamAV 0.103.11", + virus_definitions="27393/Mon Sep 9 10:29:16 2024", + ) + + +@mock.patch( + "clamav_client.scanner.ClamdUnixSocket", + return_value=mock.Mock( + scan=mock.Mock( + return_value={ + "/tmp/eicar": ( + "FOUND", + "Win.Test.EICAR_HDB-1", + ), + } + ) + ), +) +def test_clamd_scanner_scan_found(mock: mock.Mock) -> None: + scanner = get_scanner( + { + "backend": "clamd", + "address": "/var/run/clamav/clamd.ctl", + } + ) + assert scanner.scan("/tmp/eicar") == ScanResult( + filename="/tmp/eicar", + state="FOUND", + details="Win.Test.EICAR_HDB-1", + ) diff --git a/tests/test_clamd.py b/tests/test_clamd.py deleted file mode 100644 index d7cbab4..0000000 --- a/tests/test_clamd.py +++ /dev/null @@ -1,88 +0,0 @@ -import os -import pathlib -import stat -from base64 import b64decode -from io import BytesIO - -import pytest - -from clamav_client.clamd import ClamdUnixSocket -from clamav_client.clamd import CommunicationError - -EICAR = b64decode( - b"WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5E" - b"QVJELUFOVElWSVJVUy1URVNU\nLUZJTEUhJEgrSCo=\n" -) - - -EICAR_NAME = "Win.Test.EICAR_HDB-1" -if "CI" in os.environ: - EICAR_NAME = "Eicar-Signature" - - -@pytest.fixture -def unix_socket_client() -> ClamdUnixSocket: - path = os.getenv("CLAMAV_SOCKET", "/var/run/clamav/clamd.ctl") - return ClamdUnixSocket(path=path) - - -def test_cannot_connect() -> None: - with pytest.raises(CommunicationError): - ClamdUnixSocket(path="/tmp/404").ping() - - -def test_ping(unix_socket_client: ClamdUnixSocket) -> None: - unix_socket_client.ping() - - -def test_version(unix_socket_client: ClamdUnixSocket) -> None: - assert unix_socket_client.version().startswith("ClamAV") - - -def test_reload(unix_socket_client: ClamdUnixSocket) -> None: - assert unix_socket_client.reload() == "RELOADING" - - -def test_scan(unix_socket_client: ClamdUnixSocket, tmp_path: pathlib.Path) -> None: - update_tmp_path_perms(tmp_path) - file = tmp_path / "file" - file.write_bytes(EICAR) - file.chmod(0o644) - expected = {str(file): ("FOUND", EICAR_NAME)} - assert unix_socket_client.scan(str(file)) == expected - - -def test_multiscan(unix_socket_client: ClamdUnixSocket, tmp_path: pathlib.Path) -> None: - update_tmp_path_perms(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 unix_socket_client.multiscan(str(file1.parent)) == expected - - -def test_instream(unix_socket_client: ClamdUnixSocket) -> None: - expected = {"stream": ("FOUND", EICAR_NAME)} - assert unix_socket_client.instream(BytesIO(EICAR)) == expected - - -def test_insteam_success(unix_socket_client: ClamdUnixSocket) -> None: - assert unix_socket_client.instream(BytesIO(b"foo")) == {"stream": ("OK", None)} - - -def update_tmp_path_perms(temp_file: pathlib.Path) -> None: - """Update perms so ClamAV can traverse and read.""" - 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 - os.chmod( - parent, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH | stat.S_IROTH - ) From d69d68b150d16ee2cf1dff85f19269b16984b30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 11 Sep 2024 18:45:31 +0000 Subject: [PATCH 26/64] Add coverage report --- .github/workflows/test.yml | 9 ++++ .gitignore | 2 + README.md | 9 ++-- pyproject.toml | 1 + test.sh | 2 +- uv.lock | 101 ++++++++++++++++++++++++++++++++++++- 6 files changed, 119 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cfa11e6..bd680a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,3 +28,12 @@ jobs: 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 + fail_ci_if_error: false + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index aa87721..cfdfc95 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__/ *.py[cod] *.egg-info/ dist/ +.coverage +coverage.xml diff --git a/README.md b/README.md index b3add9e..524e25c 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,15 @@ # 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/graph/badge.svg?token=ldznzhNTG0)](https://codecov.io/gh/artefactual-labs/clamav-client) + `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. +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. - [clamd]: https://github.com/graingert/python-clamd [5c5e33b2]: https://github.com/graingert/python-clamd/commit/5c5e33b2dfd0499470e15abeb83efb6531ef9ab7 diff --git a/pyproject.toml b/pyproject.toml index ddc26bb..02929c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ source = "vcs" [tool.uv] dev-dependencies = [ "mypy>=1.11.2", + "pytest-cov>=5.0.0", "pytest>=8.3.2", "ruff>=0.6.3", ] diff --git a/test.sh b/test.sh index ce80dd8..8d5aac5 100755 --- a/test.sh +++ b/test.sh @@ -21,7 +21,7 @@ print_status() { for version in "${versions[@]}"; do print_status "Running \`pytest\` using Python $version..." - uv run --frozen --python "$version" -- pytest + uv run --frozen --python "$version" -- pytest --cov clamav_client --cov-report xml:coverage.xml --cov-append done latest="${versions[${#versions[@]}-1]}" diff --git a/uv.lock b/uv.lock index e8c61be..52327e0 100644 --- a/uv.lock +++ b/uv.lock @@ -3,13 +3,14 @@ requires-python = ">=3.8" [[package]] name = "clamav-client" -version = "0.2.0" +version = "0.4.1.dev2+g3910715.d20240911" source = { editable = "." } [package.dev-dependencies] dev = [ { name = "mypy" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -19,6 +20,7 @@ dev = [ dev = [ { name = "mypy", specifier = ">=1.11.2" }, { name = "pytest", specifier = ">=8.3.2" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "ruff", specifier = ">=0.6.3" }, ] @@ -31,6 +33,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "coverage" +version = "7.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 }, + { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 }, + { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 }, + { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 }, + { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 }, + { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 }, + { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 }, + { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 }, + { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 }, + { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 }, + { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 }, + { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 }, + { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 }, + { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 }, + { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 }, + { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 }, + { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 }, + { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 }, + { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 }, + { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, + { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, + { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, + { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, + { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, + { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, + { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, + { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, + { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, + { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, + { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, + { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, + { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, + { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, + { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, + { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, + { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, + { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, + { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, + { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, + { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, + { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, + { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, + { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, + { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, + { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, + { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, + { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, + { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, + { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, + { url = "https://files.pythonhosted.org/packages/81/d0/d9e3d554e38beea5a2e22178ddb16587dbcbe9a1ef3211f55733924bf7fa/coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", size = 206674 }, + { url = "https://files.pythonhosted.org/packages/38/ea/cab2dc248d9f45b2b7f9f1f596a4d75a435cb364437c61b51d2eb33ceb0e/coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", size = 207101 }, + { url = "https://files.pythonhosted.org/packages/ca/6f/f82f9a500c7c5722368978a5390c418d2a4d083ef955309a8748ecaa8920/coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", size = 236554 }, + { url = "https://files.pythonhosted.org/packages/a6/94/d3055aa33d4e7e733d8fa309d9adf147b4b06a82c1346366fc15a2b1d5fa/coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", size = 234440 }, + { url = "https://files.pythonhosted.org/packages/e4/6e/885bcd787d9dd674de4a7d8ec83faf729534c63d05d51d45d4fa168f7102/coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", size = 235889 }, + { url = "https://files.pythonhosted.org/packages/f4/63/df50120a7744492710854860783d6819ff23e482dee15462c9a833cc428a/coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", size = 235142 }, + { url = "https://files.pythonhosted.org/packages/3a/5d/9d0acfcded2b3e9ce1c7923ca52ccc00c78a74e112fc2aee661125b7843b/coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", size = 233805 }, + { url = "https://files.pythonhosted.org/packages/c4/56/50abf070cb3cd9b1dd32f2c88f083aab561ecbffbcd783275cb51c17f11d/coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", size = 234655 }, + { url = "https://files.pythonhosted.org/packages/25/ee/b4c246048b8485f85a2426ef4abab88e48c6e80c74e964bea5cd4cd4b115/coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", size = 209296 }, + { url = "https://files.pythonhosted.org/packages/5c/1c/96cf86b70b69ea2b12924cdf7cabb8ad10e6130eab8d767a1099fbd2a44f/coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", size = 210137 }, + { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 }, + { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 }, + { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 }, + { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 }, + { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 }, + { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 }, + { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 }, + { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 }, + { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 }, + { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 }, + { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -132,6 +218,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802 }, ] +[[package]] +name = "pytest-cov" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, +] + [[package]] name = "ruff" version = "0.6.3" From e61c6e52bef3ab2de8c106f973a977b76d8a4f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 11 Sep 2024 19:00:45 +0000 Subject: [PATCH 27/64] Use codecov badge for main branch --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 524e25c..ac3f285 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![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/graph/badge.svg?token=ldznzhNTG0)](https://codecov.io/gh/artefactual-labs/clamav-client) +[![codecov](https://codecov.io/gh/artefactual-labs/clamav-client/branch/main/graph/badge.svg?token=ldznzhNTG0)](https://codecov.io/gh/artefactual-labs/clamav-client) `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 From 686372906e0a8962cb9f1478efa1a2e91b4718e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Fri, 13 Sep 2024 08:12:59 +0000 Subject: [PATCH 28/64] Pass --no-summary to clamscan --- clamav_client/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py index 4f5964d..d9e5ddf 100644 --- a/clamav_client/scanner.py +++ b/clamav_client/scanner.py @@ -158,7 +158,7 @@ def scan(self, filename: str) -> ScanResult: max_file_size = "--max-filesize=%dM" % self.max_file_size max_scan_size = "--max-scansize=%dM" % self.max_scan_size try: - self._call(max_file_size, max_scan_size, filename) + 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)) From 39c42d24de40065a6d939d2b146902405d33abf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 06:10:10 +0000 Subject: [PATCH 29/64] Adjust coverage config --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 02929c3..6dc4c7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,10 @@ dev-dependencies = [ "ruff>=0.6.3", ] +[tool.coverage.run] +branch = true +source = ["clamav_client/**"] + [tool.mypy] files = ["clamav_client", "tests"] strict = true From b7630e651b636c8c92f6662e05fdfda567a2e407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 06:12:01 +0000 Subject: [PATCH 30/64] Handle CalledProcessError attrs when None --- clamav_client/scanner.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py index d9e5ddf..37e97b8 100644 --- a/clamav_client/scanner.py +++ b/clamav_client/scanner.py @@ -163,8 +163,7 @@ def scan(self, filename: str) -> ScanResult: if err.returncode == 1: result.update("FOUND", self._parse_found(err.output)) else: - stderr = err.stderr.decode("utf-8", errors="replace") - result.update("ERROR", stderr) + result.update("ERROR", self._parse_stderr(err.stderr)) else: result.update("OK", None) return result @@ -172,6 +171,11 @@ def scan(self, filename: str) -> ScanResult: def _get_version(self) -> str: return self._call("-V").decode("utf-8") + def _parse_stderr(self, stderr: bytes) -> Optional[str]: + if stderr is None: + return None + return stderr.decode("utf-8", errors="replace") + def _parse_found(self, output: Any) -> Optional[str]: if output is None or not isinstance(output, bytes): return None From bc1b6526bee1f03f4b0419e4b36472cb31eb7e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 06:59:11 +0000 Subject: [PATCH 31/64] Implement streaming in scanner --- clamav_client/clamd.py | 5 +- clamav_client/scanner.py | 41 ++++-- tests/conftest.py | 91 ++++++++++++++ tests/integration/test_clamd_net.py | 16 ++- tests/integration/test_clamd_unix.py | 40 +++--- tests/integration/test_scanner.py | 181 +++++++++++---------------- 6 files changed, 238 insertions(+), 136 deletions(-) diff --git a/clamav_client/clamd.py b/clamav_client/clamd.py index 5d50951..7336f04 100644 --- a/clamav_client/clamd.py +++ b/clamav_client/clamd.py @@ -17,7 +17,7 @@ from typing import Union scan_response = re.compile( - r"^(?P.*): ((?P.+) )?(?P(FOUND|OK|ERROR))$" + r"^(?P[^:]+): ((?P.+?) )?(?P(FOUND|OK|ERROR))$" ) @@ -283,6 +283,9 @@ 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 diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py index 37e97b8..fcf9b92 100644 --- a/clamav_client/scanner.py +++ b/clamav_client/scanner.py @@ -3,6 +3,7 @@ import abc import re from dataclasses import dataclass +from subprocess import STDOUT from subprocess import CalledProcessError from subprocess import check_output from typing import Any @@ -15,6 +16,7 @@ from clamav_client.clamd import ClamdNetworkSocket from clamav_client.clamd import ClamdUnixSocket +from clamav_client.clamd import ScanResults ProgramName = Literal[ "ClamAV (clamd)", @@ -53,9 +55,12 @@ class ScanResult: state: ScanResultState details: ScanResultDetails - def update(self, state: ScanResultState, details: ScanResultDetails) -> None: + def update( + self, state: ScanResultState, details: ScanResultDetails + ) -> "ScanResult": self.state = state self.details = details + return self class Scanner(abc.ABC): @@ -119,20 +124,28 @@ def get_client(self) -> Union["ClamdNetworkSocket", "ClamdUnixSocket"]: def scan(self, filename: str) -> ScanResult: result = ScanResult(filename=filename, state=None, details=None) + method_name = "_pass_by_stream" if self.stream else "_pass_by_reference" + report_key = "stream" if self.stream else filename try: - report = self.client.scan(filename) + method = getattr(self, method_name) + report = method(filename) except Exception as err: - result.update(state="ERROR", details=str(err)) - file_report = report.get(filename) + return result.update(state="ERROR", details=str(err)) + file_report = report.get(report_key) if file_report is None: return result state, details = file_report - result.update(state, details) # type: ignore[arg-type] - return result + 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"] @@ -151,7 +164,7 @@ def __init__(self, config: ClamscanScannerConfig) -> None: self.max_scan_size = config.get("max_scan_size", float(2000)) def _call(self, *args: str) -> bytes: - return check_output((self._command,) + args) + return check_output((self._command,) + args, stderr=STDOUT) def scan(self, filename: str) -> ScanResult: result = ScanResult(filename=filename, state=None, details=None) @@ -163,7 +176,7 @@ def scan(self, filename: str) -> ScanResult: if err.returncode == 1: result.update("FOUND", self._parse_found(err.output)) else: - result.update("ERROR", self._parse_stderr(err.stderr)) + result.update("ERROR", self._parse_error(err.output)) else: result.update("OK", None) return result @@ -171,16 +184,20 @@ def scan(self, filename: str) -> ScanResult: def _get_version(self) -> str: return self._call("-V").decode("utf-8") - def _parse_stderr(self, stderr: bytes) -> Optional[str]: - if stderr is None: + 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 - return stderr.decode("utf-8", errors="replace") 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", errors="replace") + stdout = output.decode("utf-8") match = self.found_pattern.search(stdout) return match.group(1) if match else None except Exception: diff --git a/tests/conftest.py b/tests/conftest.py index 60eaab5..8b14719 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,21 @@ +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 # TODO: figure out this discrepancy - likely because we're missing recent sigs # in the CI job. @@ -27,6 +37,40 @@ def eicar() -> bytes: ) +@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 file_without_perms_adjusted(tmp_path: Path) -> Path: + f = tmp_path / "file" + f.write_bytes(b"") + 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") @@ -37,3 +81,50 @@ def clamd_unix_client() -> ClamdUnixSocket: 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) diff --git a/tests/integration/test_clamd_net.py b/tests/integration/test_clamd_net.py index b15be6a..316a7d2 100644 --- a/tests/integration/test_clamd_net.py +++ b/tests/integration/test_clamd_net.py @@ -2,6 +2,7 @@ import pytest +from clamav_client.clamd import BufferTooLongError from clamav_client.clamd import ClamdNetworkSocket from clamav_client.clamd import CommunicationError @@ -23,6 +24,10 @@ 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, @@ -32,5 +37,14 @@ def test_instream_found( assert clamd_net_client.instream(BytesIO(eicar)) == expected -def test_insteam_ok(clamd_net_client: ClamdNetworkSocket) -> None: +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 index 2254212..05120ac 100644 --- a/tests/integration/test_clamd_unix.py +++ b/tests/integration/test_clamd_unix.py @@ -1,14 +1,23 @@ import os import pathlib -import stat 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() @@ -26,13 +35,18 @@ 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: - update_tmp_path_perms(tmp_path) + perms_updater(tmp_path) file = tmp_path / "file" file.write_bytes(eicar) file.chmod(0o644) @@ -41,12 +55,13 @@ def test_scan( def test_multiscan( + perms_updater: Callable[[pathlib.Path], None], clamd_unix_client: ClamdUnixSocket, tmp_path: pathlib.Path, eicar: bytes, eicar_name: str, ) -> None: - update_tmp_path_perms(tmp_path) + perms_updater(tmp_path) file1 = tmp_path / "file1" file1.write_bytes(eicar) file1.chmod(0o644) @@ -69,17 +84,14 @@ def test_instream_found( assert clamd_unix_client.instream(BytesIO(eicar)) == expected -def test_insteam_ok(clamd_unix_client: ClamdUnixSocket) -> None: +def test_instream_ok(clamd_unix_client: ClamdUnixSocket) -> None: assert clamd_unix_client.instream(BytesIO(b"foo")) == {"stream": ("OK", None)} -def update_tmp_path_perms(temp_file: pathlib.Path) -> None: - """Update perms so ClamAV can traverse and read.""" - 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 - os.chmod( - parent, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH | stat.S_IROTH - ) +@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 index 4a24002..820753e 100644 --- a/tests/integration/test_scanner.py +++ b/tests/integration/test_scanner.py @@ -1,35 +1,13 @@ -from subprocess import CalledProcessError -from unittest import mock +from pathlib import Path import pytest from clamav_client import get_scanner from clamav_client.scanner import ClamdScanner -from clamav_client.scanner import ClamdScannerConfig from clamav_client.scanner import ClamscanScanner -from clamav_client.scanner import ClamscanScannerConfig from clamav_client.scanner import Scanner -from clamav_client.scanner import ScannerInfo from clamav_client.scanner import ScanResult -CLAMAV_VERSION = "ClamAV 0.103.11/27393/Mon Sep 9 10:29:16 2024" - - -@pytest.fixture -def clamd_scanner() -> Scanner: - config: ClamdScannerConfig = { - "backend": "clamd", - } - return get_scanner(config) - - -@pytest.fixture -def clamscan_scanner() -> Scanner: - config: ClamscanScannerConfig = { - "backend": "clamscan", - } - return get_scanner(config) - def test_get_scanner_provides_default() -> None: scanner = get_scanner() @@ -41,116 +19,103 @@ def test_get_scanner_raises_value_error() -> None: get_scanner({"backend": "unknown"}) # type: ignore[misc,arg-type] -def test_get_scanner_with_clamd_backend() -> None: - scanner = get_scanner({"backend": "clamd"}) - assert isinstance(scanner, ClamdScanner) +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.") + assert info.virus_definitions is not None and int( + info.virus_definitions.split("/")[0] + ) -def test_get_scanner_with_clamscan_backend() -> None: - scanner = get_scanner({"backend": "clamscan"}) - assert isinstance(scanner, ClamscanScanner) +def test_clamscan_scanner_scan_ok(clamscan_scanner: Scanner, clean_file: Path) -> None: + result = clamscan_scanner.scan(str(clean_file)) -@mock.patch( - "clamav_client.scanner.check_output", - return_value=CLAMAV_VERSION.encode("utf-8"), -) -def test_clamscan_scanner_info(mock: mock.Mock, clamscan_scanner: Scanner) -> None: - assert clamscan_scanner.info() == ScannerInfo( - name="ClamAV (clamscan)", - version="ClamAV 0.103.11", - virus_definitions="27393/Mon Sep 9 10:29:16 2024", + assert result == ScanResult( + filename=str(clean_file), + state="OK", + details=None, ) -@mock.patch( - "clamav_client.scanner.check_output", - side_effect=CalledProcessError( - cmd="clamscan", - returncode=1, - output=b"/tmp/eicar: Win.Test.EICAR_HDB-1 FOUND", - ), -) def test_clamscan_scanner_scan_found( - mock: mock.Mock, clamscan_scanner: Scanner + clamscan_scanner: Scanner, + eicar_file: Path, + eicar_name: str, ) -> None: - assert clamscan_scanner.scan("/tmp/eicar") == ScanResult( - filename="/tmp/eicar", + result = clamscan_scanner.scan(str(eicar_file)) + + assert result == ScanResult( + filename=str(eicar_file), state="FOUND", - details="Win.Test.EICAR_HDB-1", + details=eicar_name, ) -@mock.patch( - "clamav_client.scanner.check_output", - side_effect=CalledProcessError( - cmd="clamscan", - returncode=2, - stderr=b"/tmp/eicar: No such file or directory\n", - ), -) -def test_clamscan_scanner_scan_error( - mock: mock.Mock, clamscan_scanner: Scanner -) -> None: - assert clamscan_scanner.scan("/tmp/eicar") == ScanResult( - filename="/tmp/eicar", +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/eicar: No such file or directory\n", + details="/tmp/notfound: No such file or directory", + ) + + +def test_clamd_scanner_info(clamd_scanner: Scanner) -> None: + info = clamd_scanner.info() + + assert isinstance(clamd_scanner, ClamdScanner) + assert info.name == "ClamAV (clamd)" + assert info.version.startswith("ClamAV 0.") + assert info.virus_definitions is not None and int( + info.virus_definitions.split("/")[0] ) -@mock.patch( - "clamav_client.scanner.check_output", - return_value=b"/tmp/eicar: OK\n", -) -def test_clamscan_scanner_scan_ok(mock: mock.Mock, clamscan_scanner: Scanner) -> None: - assert clamscan_scanner.scan("/tmp/eicar") == ScanResult( - filename="/tmp/eicar", +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, ) -@mock.patch( - "clamav_client.scanner.ClamdUnixSocket", - return_value=mock.Mock(version=mock.Mock(return_value=CLAMAV_VERSION)), -) -def test_clamd_scanner_info(mock: mock.Mock) -> None: - scanner = get_scanner( - { - "backend": "clamd", - "address": "/var/run/clamav/clamd.ctl", - } - ) - assert scanner.info() == ScannerInfo( - name="ClamAV (clamd)", - version="ClamAV 0.103.11", - virus_definitions="27393/Mon Sep 9 10:29:16 2024", +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, ) -@mock.patch( - "clamav_client.scanner.ClamdUnixSocket", - return_value=mock.Mock( - scan=mock.Mock( - return_value={ - "/tmp/eicar": ( - "FOUND", - "Win.Test.EICAR_HDB-1", - ), - } - ) - ), -) -def test_clamd_scanner_scan_found(mock: mock.Mock) -> None: - scanner = get_scanner( - { - "backend": "clamd", - "address": "/var/run/clamav/clamd.ctl", - } +def test_clamd_scanner_scan_error( + clamd_scanner: Scanner, file_without_perms_adjusted: Path +) -> None: + result = clamd_scanner.scan(str(file_without_perms_adjusted)) + + assert result == ScanResult( + filename=str(file_without_perms_adjusted), + state="ERROR", + details="File path check failure: Permission denied.", ) - assert scanner.scan("/tmp/eicar") == ScanResult( - filename="/tmp/eicar", + + +def test_clamd_scanner_instream( + 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="Win.Test.EICAR_HDB-1", + details=eicar_name, ) From abb700359dc4891c8cd9dd6a74d74d47e745b9bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 09:58:30 +0000 Subject: [PATCH 32/64] Add test.sh --latest flag --- test.sh | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test.sh b/test.sh index 8d5aac5..aaf8c32 100755 --- a/test.sh +++ b/test.sh @@ -2,6 +2,10 @@ set -euo pipefail +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." exit 1 @@ -15,17 +19,16 @@ versions=( "3.12" ) -print_status() { - echo -en "\n➡️ $1\n\n" -} +latest="${versions[${#versions[@]}-1]}" +if [[ "${1:-}" == "--latest" ]]; then + versions=("3.12") +fi for version in "${versions[@]}"; do print_status "Running \`pytest\` using Python $version..." - uv run --frozen --python "$version" -- pytest --cov clamav_client --cov-report xml:coverage.xml --cov-append + uv run --frozen --python "$version" -- pytest --cov clamav_client --cov-report xml:coverage.xml --cov-report html:/tmp/coverage --cov-append done -latest="${versions[${#versions[@]}-1]}" - print_status "Running \`ruff check\` using Python $latest..." uv run --frozen --python "$latest" -- ruff check From 50e3d5929ce7bab97761c9cc168b957167a42c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 10:05:35 +0000 Subject: [PATCH 33/64] Generate html covreport in default location --- .gitignore | 1 + test.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cfdfc95..8e64049 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ dist/ .coverage coverage.xml +htmlcov/ diff --git a/test.sh b/test.sh index aaf8c32..860bc11 100755 --- a/test.sh +++ b/test.sh @@ -26,7 +26,7 @@ if [[ "${1:-}" == "--latest" ]]; then fi for version in "${versions[@]}"; do print_status "Running \`pytest\` using Python $version..." - uv run --frozen --python "$version" -- pytest --cov clamav_client --cov-report xml:coverage.xml --cov-report html:/tmp/coverage --cov-append + uv run --frozen --python "$version" -- pytest --cov clamav_client --cov-report xml:coverage.xml --cov-report html --cov-append done print_status "Running \`ruff check\` using Python $latest..." From fec5ee271ab8bfd6d1e13ada74ba95da88643a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 10:05:46 +0000 Subject: [PATCH 34/64] Document scanner methods --- clamav_client/scanner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py index fcf9b92..421fb8a 100644 --- a/clamav_client/scanner.py +++ b/clamav_client/scanner.py @@ -69,13 +69,14 @@ class Scanner(abc.ABC): @abc.abstractmethod def scan(self, filename: str) -> ScanResult: - pass + """Scan a file.""" @abc.abstractmethod def _get_version(self) -> str: - pass + """Return the program version details.""" def info(self) -> ScannerInfo: + """Fetch information of the current backend.""" try: return self._info except AttributeError: From 2781649bc08119cd96bc5fccadba866aa856055f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 10:43:09 +0000 Subject: [PATCH 35/64] Use slow marker in pytest --- pyproject.toml | 5 +++++ test.sh | 10 ++++++++-- tests/integration/test_scanner.py | 4 ++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6dc4c7d..4f463a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,11 @@ 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.11.2", diff --git a/test.sh b/test.sh index 860bc11..e33edc3 100755 --- a/test.sh +++ b/test.sh @@ -21,12 +21,18 @@ versions=( latest="${versions[${#versions[@]}-1]}" -if [[ "${1:-}" == "--latest" ]]; then +markers="" +if [[ " $@ " =~ " --fast " ]]; then + markers="not slow" +fi + +if [[ " $@ " =~ " --latest " ]]; then versions=("3.12") fi + for version in "${versions[@]}"; do print_status "Running \`pytest\` using Python $version..." - uv run --frozen --python "$version" -- pytest --cov clamav_client --cov-report xml:coverage.xml --cov-report html --cov-append + uv run --frozen --python "$version" -- pytest -m "$markers" --cov clamav_client --cov-report xml:coverage.xml --cov-report html --cov-append done print_status "Running \`ruff check\` using Python $latest..." diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py index 820753e..6f2c35e 100644 --- a/tests/integration/test_scanner.py +++ b/tests/integration/test_scanner.py @@ -19,6 +19,7 @@ def test_get_scanner_raises_value_error() -> None: get_scanner({"backend": "unknown"}) # type: ignore[misc,arg-type] +@pytest.mark.slow def test_clamscan_scanner_info(clamscan_scanner: Scanner) -> None: info = clamscan_scanner.info() @@ -30,6 +31,7 @@ def test_clamscan_scanner_info(clamscan_scanner: Scanner) -> None: ) +@pytest.mark.slow def test_clamscan_scanner_scan_ok(clamscan_scanner: Scanner, clean_file: Path) -> None: result = clamscan_scanner.scan(str(clean_file)) @@ -40,6 +42,7 @@ def test_clamscan_scanner_scan_ok(clamscan_scanner: Scanner, clean_file: Path) - ) +@pytest.mark.slow def test_clamscan_scanner_scan_found( clamscan_scanner: Scanner, eicar_file: Path, @@ -54,6 +57,7 @@ def test_clamscan_scanner_scan_found( ) +@pytest.mark.slow def test_clamscan_scanner_scan_error(clamscan_scanner: Scanner) -> None: result = clamscan_scanner.scan("/tmp/notfound") From 7a3e3b5ed8ddaa4ebe7e63959b1afb6641a49351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 10:44:09 +0000 Subject: [PATCH 36/64] Update cache to index by program name --- clamav_client/scanner.py | 21 ++++++++++++--------- tests/integration/test_scanner.py | 3 +++ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py index 421fb8a..5779b0e 100644 --- a/clamav_client/scanner.py +++ b/clamav_client/scanner.py @@ -7,6 +7,7 @@ from subprocess import CalledProcessError from subprocess import check_output from typing import Any +from typing import Dict from typing import Literal from typing import Optional from typing import TypedDict @@ -64,24 +65,26 @@ def update( class Scanner(abc.ABC): - _info: ScannerInfo + _info: Dict[ProgramName, ScannerInfo] _program: ProgramName @abc.abstractmethod def scan(self, filename: str) -> ScanResult: """Scan a file.""" - @abc.abstractmethod - def _get_version(self) -> str: - """Return the program version details.""" - def info(self) -> ScannerInfo: """Fetch information of the current backend.""" + if not hasattr(self, "_info"): + self._info = {} try: - return self._info - except AttributeError: - self._info = self._parse_version(self._get_version()) - return self._info + 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("/") diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py index 6f2c35e..8f0cc33 100644 --- a/tests/integration/test_scanner.py +++ b/tests/integration/test_scanner.py @@ -70,6 +70,7 @@ def test_clamscan_scanner_scan_error(clamscan_scanner: Scanner) -> 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)" @@ -78,6 +79,8 @@ def test_clamd_scanner_info(clamd_scanner: Scanner) -> None: info.virus_definitions.split("/")[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)) From f48f7cbc183e2436ec33a32c45fa18f7d029b582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 10:44:22 +0000 Subject: [PATCH 37/64] Test instream over TCP --- tests/conftest.py | 10 ++++++++++ tests/integration/test_scanner.py | 14 +++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8b14719..2b0df27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -128,3 +128,13 @@ def clamd_scanner_with_streaming() -> Scanner: "stream": True, } return get_scanner(config) + + +@pytest.fixture +def clamd_scanner_over_tcp() -> Scanner: + config: ClamdScannerConfig = { + "backend": "clamd", + "address": f"127.0.0.1:{getenv("CLAMD_TCP_PORT", "3310")}", + "stream": True, + } + return get_scanner(config) diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py index 8f0cc33..2f3866d 100644 --- a/tests/integration/test_scanner.py +++ b/tests/integration/test_scanner.py @@ -116,7 +116,7 @@ def test_clamd_scanner_scan_error( ) -def test_clamd_scanner_instream( +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)) @@ -126,3 +126,15 @@ def test_clamd_scanner_instream( state="FOUND", details=eicar_name, ) + + +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, + ) From 442fd1931e7d026169a34e063d4bdb1c2e8a1344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 11:01:12 +0000 Subject: [PATCH 38/64] Add test to confirm exception handling --- tests/integration/test_scanner.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py index 2f3866d..9604bc7 100644 --- a/tests/integration/test_scanner.py +++ b/tests/integration/test_scanner.py @@ -116,6 +116,23 @@ def test_clamd_scanner_scan_error( ) +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.", + ) + + def test_clamd_scanner_instream_over_unix( clamd_scanner_with_streaming: Scanner, eicar_file: Path, eicar_name: str ) -> None: From bc4eb979dfbbb09e4a08a7f675bad317adcdadbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 11:03:25 +0000 Subject: [PATCH 39/64] Add test to confirm invalid address exception --- tests/integration/test_scanner.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py index 9604bc7..0a258a6 100644 --- a/tests/integration/test_scanner.py +++ b/tests/integration/test_scanner.py @@ -17,6 +17,8 @@ def test_get_scanner_provides_default() -> None: 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 From 9dbd5e3cdf56cf2f5265316e9a8b17abce5c9353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 11:04:39 +0000 Subject: [PATCH 40/64] Fix syntax issue --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2b0df27..a7a38ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -132,9 +132,10 @@ def clamd_scanner_with_streaming() -> Scanner: @pytest.fixture def clamd_scanner_over_tcp() -> Scanner: + port = getenv("CLAMD_TCP_PORT", "3310") config: ClamdScannerConfig = { "backend": "clamd", - "address": f"127.0.0.1:{getenv("CLAMD_TCP_PORT", "3310")}", + "address": f"127.0.0.1:{port}", "stream": True, } return get_scanner(config) From 801a4ee166b092896523c7c0d29a325a2a79f55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 11:27:16 +0000 Subject: [PATCH 41/64] Add passed prop to ScanResult --- clamav_client/scanner.py | 44 ++++++++++++++++++++++++++++--- tests/integration/test_scanner.py | 10 +++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py index 5779b0e..913d810 100644 --- a/clamav_client/scanner.py +++ b/clamav_client/scanner.py @@ -3,6 +3,7 @@ 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 @@ -15,6 +16,7 @@ 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 ScanResults @@ -55,14 +57,48 @@ class ScanResult: 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 + 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, ConnectionError)): + return None + elif isinstance(self.err, IOError) and self.err.errno == EPIPE: + return None + else: + return False + class Scanner(abc.ABC): _info: Dict[ProgramName, ScannerInfo] @@ -127,14 +163,14 @@ def get_client(self) -> Union["ClamdNetworkSocket", "ClamdUnixSocket"]: raise ValueError(f"Invalid address format: {self.address}") def scan(self, filename: str) -> ScanResult: - result = ScanResult(filename=filename, state=None, details=None) + 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)) + return result.update(state="ERROR", details=str(err), err=err) file_report = report.get(report_key) if file_report is None: return result @@ -171,7 +207,7 @@ 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) + result = ScanResult(filename=filename, state=None, details=None, err=None) max_file_size = "--max-filesize=%dM" % self.max_file_size max_scan_size = "--max-scansize=%dM" % self.max_scan_size try: diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py index 0a258a6..6c63003 100644 --- a/tests/integration/test_scanner.py +++ b/tests/integration/test_scanner.py @@ -3,6 +3,7 @@ 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 @@ -41,6 +42,7 @@ def test_clamscan_scanner_scan_ok(clamscan_scanner: Scanner, clean_file: Path) - filename=str(clean_file), state="OK", details=None, + err=None, ) @@ -56,6 +58,7 @@ def test_clamscan_scanner_scan_found( filename=str(eicar_file), state="FOUND", details=eicar_name, + err=None, ) @@ -67,6 +70,7 @@ def test_clamscan_scanner_scan_error(clamscan_scanner: Scanner) -> None: filename="/tmp/notfound", state="ERROR", details="/tmp/notfound: No such file or directory", + err=None, ) @@ -91,6 +95,7 @@ def test_clamd_scanner_scan_ok(clamd_scanner: Scanner, clean_file: Path) -> None filename=str(clean_file), state="OK", details=None, + err=None, ) @@ -103,6 +108,7 @@ def test_clamd_scanner_scan_found( filename=str(eicar_file), state="FOUND", details=eicar_name, + err=None, ) @@ -115,6 +121,7 @@ def test_clamd_scanner_scan_error( filename=str(file_without_perms_adjusted), state="ERROR", details="File path check failure: Permission denied.", + err=None, ) @@ -132,6 +139,7 @@ def test_clamd_scanner_scan_exception(eicar_file: Path) -> None: 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."), ) @@ -144,6 +152,7 @@ def test_clamd_scanner_instream_over_unix( filename=str(eicar_file), state="FOUND", details=eicar_name, + err=None, ) @@ -156,4 +165,5 @@ def test_clamd_scanner_instream_over_tcp( filename=str(eicar_file), state="FOUND", details=eicar_name, + err=None, ) From a9edcbe8f828952c29fcd96f95537bdee7f6d709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 11:54:18 +0000 Subject: [PATCH 42/64] Test passed prop --- clamav_client/scanner.py | 8 ++++---- tests/integration/test_scanner.py | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py index 913d810..14f1dcf 100644 --- a/clamav_client/scanner.py +++ b/clamav_client/scanner.py @@ -63,10 +63,10 @@ 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) + self.filename == other.filename + and self.state == other.state + and self.details == other.details + and str(self.err) == str(other.err) ) def update( diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py index 6c63003..ff18c64 100644 --- a/tests/integration/test_scanner.py +++ b/tests/integration/test_scanner.py @@ -139,8 +139,11 @@ def test_clamd_scanner_scan_exception(eicar_file: Path) -> None: 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."), + err=CommunicationError( + "Error 111 connecting 127.0.0.1:65000. Connection refused." + ), ) + assert result.passed is False def test_clamd_scanner_instream_over_unix( From ef4eed786de84d595974785bf9983e28772407a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 12:04:51 +0000 Subject: [PATCH 43/64] Update coverage link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ac3f285..5892877 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![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://codecov.io/gh/artefactual-labs/clamav-client) +[![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 From eba48c66c09127ab654848990a41deecda658747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Mon, 16 Sep 2024 12:02:05 +0000 Subject: [PATCH 44/64] Fix tests in CI --- tests/conftest.py | 16 ++++++++-------- tests/integration/test_scanner.py | 18 +++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a7a38ce..b8bbe0b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,13 +17,20 @@ 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 + # TODO: figure out this discrepancy - likely because we're missing recent sigs # in the CI job. EICAR_NAME = "Win.Test.EICAR_HDB-1" -if "CI" in environ: +if CI: EICAR_NAME = "Eicar-Signature" +@pytest.fixture +def ci() -> bool: + return CI + + @pytest.fixture def eicar_name() -> str: return EICAR_NAME @@ -58,13 +65,6 @@ def clean_file( return f -@pytest.fixture -def file_without_perms_adjusted(tmp_path: Path) -> Path: - f = tmp_path / "file" - f.write_bytes(b"") - return f - - @pytest.fixture def really_big_file() -> BytesIO: # Generate a stream of 4M to exceed StreamMaxLength (set to 2M in CI). diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py index ff18c64..1ccced8 100644 --- a/tests/integration/test_scanner.py +++ b/tests/integration/test_scanner.py @@ -29,9 +29,6 @@ def test_clamscan_scanner_info(clamscan_scanner: Scanner) -> None: assert isinstance(clamscan_scanner, ClamscanScanner) assert info.name == "ClamAV (clamscan)" assert info.version.startswith("ClamAV 0.") - assert info.virus_definitions is not None and int( - info.virus_definitions.split("/")[0] - ) @pytest.mark.slow @@ -81,9 +78,6 @@ def test_clamd_scanner_info(clamd_scanner: Scanner) -> None: assert isinstance(clamd_scanner, ClamdScanner) assert info.name == "ClamAV (clamd)" assert info.version.startswith("ClamAV 0.") - assert info.virus_definitions is not None and int( - info.virus_definitions.split("/")[0] - ) assert info == info_2 @@ -113,12 +107,18 @@ def test_clamd_scanner_scan_found( def test_clamd_scanner_scan_error( - clamd_scanner: Scanner, file_without_perms_adjusted: Path + ci: bool, clamd_scanner: Scanner, tmp_path: Path ) -> None: - result = clamd_scanner.scan(str(file_without_perms_adjusted)) + 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(file_without_perms_adjusted), + filename=str(f), state="ERROR", details="File path check failure: Permission denied.", err=None, From 98f52a9dea992b563d3535ae0b768ebb9e3670b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Tue, 17 Sep 2024 05:26:26 +0000 Subject: [PATCH 45/64] Update README --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 5892877..4116efb 100644 --- a/README.md +++ b/README.md @@ -11,5 +11,15 @@ 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 From b16a079c144a08447140c8206194f77a01579baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Tue, 17 Sep 2024 09:01:19 +0000 Subject: [PATCH 46/64] Add unit tests --- clamav_client/scanner.py | 5 ++-- tests/integration/test_scanner.py | 2 +- tests/unit/test_unit_scanner.py | 43 +++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_unit_scanner.py diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py index 14f1dcf..cd0341f 100644 --- a/clamav_client/scanner.py +++ b/clamav_client/scanner.py @@ -19,6 +19,7 @@ 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[ @@ -92,9 +93,9 @@ def passed(self) -> Optional[bool]: """ if self.err is None: return self.state == "OK" - elif isinstance(self.err, (BufferTooLongError, ConnectionError)): + elif isinstance(self.err, (BufferTooLongError, CommunicationError)): return None - elif isinstance(self.err, IOError) and self.err.errno == EPIPE: + elif isinstance(self.err, OSError) and self.err.errno == EPIPE: return None else: return False diff --git a/tests/integration/test_scanner.py b/tests/integration/test_scanner.py index 1ccced8..37edfb6 100644 --- a/tests/integration/test_scanner.py +++ b/tests/integration/test_scanner.py @@ -143,7 +143,7 @@ def test_clamd_scanner_scan_exception(eicar_file: Path) -> None: "Error 111 connecting 127.0.0.1:65000. Connection refused." ), ) - assert result.passed is False + assert result.passed is None def test_clamd_scanner_instream_over_unix( diff --git a/tests/unit/test_unit_scanner.py b/tests/unit/test_unit_scanner.py new file mode 100644 index 0000000..38b6a24 --- /dev/null +++ b/tests/unit/test_unit_scanner.py @@ -0,0 +1,43 @@ +from clamav_client.clamd import BufferTooLongError +from clamav_client.clamd import CommunicationError +from clamav_client.scanner import ScanResult + + +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=ValueError()).passed + is False + ) From 1f09f2bd6ec5d985c8731ccd0061a3839cf00015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Tue, 17 Sep 2024 09:03:58 +0000 Subject: [PATCH 47/64] Enable pyupgrade rules --- clamav_client/clamd.py | 35 ++++++++++++----------------------- pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/clamav_client/clamd.py b/clamav_client/clamd.py index 7336f04..f2b45f4 100644 --- a/clamav_client/clamd.py +++ b/clamav_client/clamd.py @@ -42,7 +42,7 @@ class CommunicationError(ClamdError): """Class for errors communication with clamd""" -class ClamdNetworkSocket(object): +class ClamdNetworkSocket: """ Class for using clamd with a network socket """ @@ -69,23 +69,16 @@ def _init_socket(self) -> None: 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) - except socket.error as err: + except OSError as err: raise CommunicationError(self._error_message(err)) from err 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], - ) + return f"Error {exception.args[0]} connecting {self.host}:{self.port}. {exception.args[1]}." def ping(self) -> str: return self._basic_command("PING") @@ -225,7 +218,7 @@ def _send_command(self, cmd: str, *args: str) -> None: concat_args = "" if args: concat_args = " " + " ".join(args) - send = "n{cmd}{args}\n".format(cmd=cmd, args=concat_args).encode("utf-8") + send = f"n{cmd}{concat_args}\n".encode() self.clamd_socket.send(send) def _recv_response(self) -> str: @@ -235,9 +228,9 @@ def _recv_response(self) -> str: try: with contextlib.closing(self.clamd_socket.makefile("rb")) as f: return f.readline().decode("utf-8").strip() - except (socket.error, socket.timeout) as err: + except (OSError, socket.timeout) as err: raise CommunicationError( - "Error while reading from socket: {0}".format(err.args) + f"Error while reading from socket: {err.args}" ) from err def _recv_response_multiline(self) -> str: @@ -247,9 +240,9 @@ def _recv_response_multiline(self) -> str: try: with contextlib.closing(self.clamd_socket.makefile("rb")) as f: return f.read().decode("utf-8") - except (socket.error, socket.timeout) as err: + except (OSError, socket.timeout) as err: raise CommunicationError( - "Error while reading from socket: {0}".format(err.args) + f"Error while reading from socket: {err.args}" ) from err def _close_socket(self) -> None: @@ -297,17 +290,13 @@ def _init_socket(self) -> None: 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 as err: + except OSError as err: raise CommunicationError(self._error_message(err)) from err 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/pyproject.toml b/pyproject.toml index 4f463a7..3e75e25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ files = ["clamav_client", "tests"] strict = true [tool.ruff.lint] -extend-select = ["A", "B", "I", "Q"] +extend-select = ["A", "B", "I", "Q", "UP"] [tool.ruff.lint.isort] force-single-line = true From 2020cd2dcad866f2506c424dd2bf8e02c969df71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Tue, 17 Sep 2024 09:08:01 +0000 Subject: [PATCH 48/64] Include tip to install uv from test.sh --- test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test.sh b/test.sh index e33edc3..d628bbf 100755 --- a/test.sh +++ b/test.sh @@ -8,6 +8,8 @@ print_status() { 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 From ef6581f13c5027e95d77d1948d5e922e455f15f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Tue, 17 Sep 2024 09:18:59 +0000 Subject: [PATCH 49/64] Test passed prop with OSError --- tests/unit/test_unit_scanner.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/test_unit_scanner.py b/tests/unit/test_unit_scanner.py index 38b6a24..1751dc3 100644 --- a/tests/unit/test_unit_scanner.py +++ b/tests/unit/test_unit_scanner.py @@ -1,3 +1,5 @@ +from errno import EPIPE + from clamav_client.clamd import BufferTooLongError from clamav_client.clamd import CommunicationError from clamav_client.scanner import ScanResult @@ -37,6 +39,12 @@ def test_scan_result_passed() -> None: ).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 From 610941dd0987dc997210bc9138bd19fc3914a2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 18 Sep 2024 05:28:32 +0000 Subject: [PATCH 50/64] Fix use of source attr in coverage config --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3e75e25..76e55f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dev-dependencies = [ [tool.coverage.run] branch = true -source = ["clamav_client/**"] +source_pkgs = ["clamav_client"] [tool.mypy] files = ["clamav_client", "tests"] From f49d280321ce62961d5c0a2f99edf64a11df3e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 18 Sep 2024 05:36:00 +0000 Subject: [PATCH 51/64] Upload test results to Codecov --- .github/workflows/test.yml | 11 +++++++---- .gitignore | 1 + test.sh | 9 ++++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd680a9..8eb6683 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,10 @@ jobs: uses: "codecov/codecov-action@v4" with: files: ./coverage.xml - fail_ci_if_error: false - verbose: true - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + 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 8e64049..86217bb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ .coverage coverage.xml htmlcov/ +junit.xml diff --git a/test.sh b/test.sh index d628bbf..969ef04 100755 --- a/test.sh +++ b/test.sh @@ -34,7 +34,14 @@ fi for version in "${versions[@]}"; do print_status "Running \`pytest\` using Python $version..." - uv run --frozen --python "$version" -- pytest -m "$markers" --cov clamav_client --cov-report xml:coverage.xml --cov-report html --cov-append + 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 print_status "Running \`ruff check\` using Python $latest..." From 9ea770c1badd76cc82b01b055cfac6ea171255a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 18 Sep 2024 05:44:35 +0000 Subject: [PATCH 52/64] Include prereleases in test script --- test.sh | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/test.sh b/test.sh index 969ef04..c498388 100755 --- a/test.sh +++ b/test.sh @@ -2,6 +2,18 @@ set -euo pipefail +versions=( + "3.8" + "3.9" + "3.10" + "3.11" + "3.12" +) + +prereleases=( + "3.13" +) + print_status() { echo -en "\n➡️ $1\n\n" } @@ -13,26 +25,24 @@ if ! command -v uv > /dev/null; then exit 1 fi -versions=( - "3.8" - "3.9" - "3.10" - "3.11" - "3.12" -) - -latest="${versions[${#versions[@]}-1]}" - markers="" if [[ " $@ " =~ " --fast " ]]; then markers="not slow" fi +latest="${versions[${#versions[@]}-1]}" + if [[ " $@ " =~ " --latest " ]]; then versions=("3.12") + prereleases=() +elif [[ " $@ " =~ " --pre " ]]; then + versions=() + prereleases=("3.13") fi -for version in "${versions[@]}"; do +combined=("${versions[@]}" "${prereleases[@]}") + +for version in "${combined[@]}"; do print_status "Running \`pytest\` using Python $version..." uv run --frozen --python "$version" -- \ pytest -m "$markers" \ From 6c9068747ac984bb0db72841edc028b20109729b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 18 Sep 2024 06:10:06 +0000 Subject: [PATCH 53/64] Don't overwrite .venv in test runner --- test.sh | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/test.sh b/test.sh index c498388..31d9beb 100755 --- a/test.sh +++ b/test.sh @@ -25,6 +25,8 @@ if ! command -v uv > /dev/null; then exit 1 fi +curdir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + markers="" if [[ " $@ " =~ " --fast " ]]; then markers="not slow" @@ -44,14 +46,17 @@ combined=("${versions[@]}" "${prereleases[@]}") for version in "${combined[@]}"; do print_status "Running \`pytest\` using Python $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 + 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 print_status "Running \`ruff check\` using Python $latest..." From aabcf12beaedf5977f6eb3112236cb76ec802ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 18 Sep 2024 07:25:55 +0000 Subject: [PATCH 54/64] Test clamscan output parse functions --- tests/unit/test_unit_scanner.py | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/unit/test_unit_scanner.py b/tests/unit/test_unit_scanner.py index 1751dc3..8633467 100644 --- a/tests/unit/test_unit_scanner.py +++ b/tests/unit/test_unit_scanner.py @@ -1,7 +1,12 @@ from errno import EPIPE +import pytest + from clamav_client.clamd import BufferTooLongError from clamav_client.clamd import CommunicationError +from clamav_client.scanner import ClamscanScanner +from clamav_client.scanner import Scanner +from clamav_client.scanner import ScannerInfo from clamav_client.scanner import ScanResult @@ -49,3 +54,45 @@ def test_scan_result_passed() -> None: 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" From f25f29505edcbfb73fc19963d69c8354a9682ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 18 Sep 2024 07:50:19 +0000 Subject: [PATCH 55/64] Test scenario with missing file report entry --- tests/unit/test_unit_scanner.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/test_unit_scanner.py b/tests/unit/test_unit_scanner.py index 8633467..1992f9f 100644 --- a/tests/unit/test_unit_scanner.py +++ b/tests/unit/test_unit_scanner.py @@ -1,15 +1,34 @@ 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) From eaa324afab718cf06f4f44f836b6434b6eb1adac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 25 Sep 2024 09:02:49 +0000 Subject: [PATCH 56/64] Update test job name --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8eb6683..ed7af4e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ env: CLAMD_TCP_PORT: "3310" jobs: test: - name: "Test Python ${{ matrix.python-version }}" + name: "Test" runs-on: "ubuntu-22.04" steps: - name: "Check out repository" From ce490273a973740419757f676becc72dd2296776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 25 Sep 2024 10:28:07 +0000 Subject: [PATCH 57/64] Always reuse project env --- test.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test.sh b/test.sh index 31d9beb..18811ac 100755 --- a/test.sh +++ b/test.sh @@ -59,6 +59,9 @@ for version in "${combined[@]}"; do --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 From 6c58b582cf78568ab88e08f4590bee244311a3ec Mon Sep 17 00:00:00 2001 From: Thibault Dethier Date: Fri, 24 Jan 2025 10:34:29 +0100 Subject: [PATCH 58/64] fix/network_socket_timeout --- clamav_client/clamd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clamav_client/clamd.py b/clamav_client/clamd.py index f2b45f4..7a75a2d 100644 --- a/clamav_client/clamd.py +++ b/clamav_client/clamd.py @@ -67,8 +67,8 @@ def _init_socket(self) -> None: """ 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 From 1297f43fdcdd6be16e7c5e72e55e6221a0fc91a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 14 May 2025 19:42:11 +0200 Subject: [PATCH 59/64] Update dependencies --- .github/workflows/test.yml | 2 +- clamav_client/clamd.py | 8 +- clamav_client/scanner.py | 7 +- pyproject.toml | 10 +- test.sh | 8 +- uv.lock | 337 ++++++++++++++++++++----------------- 6 files changed, 199 insertions(+), 173 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed7af4e..89199a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: tcp_port: ${{ env.CLAMD_TCP_PORT }} stream_max_length: 1M - name: Install the latest version of uv - uses: astral-sh/setup-uv@v2 + uses: astral-sh/setup-uv@v6 with: enable-cache: true version: latest diff --git a/clamav_client/clamd.py b/clamav_client/clamd.py index 7a75a2d..03e2969 100644 --- a/clamav_client/clamd.py +++ b/clamav_client/clamd.py @@ -11,9 +11,7 @@ import struct from typing import Any from typing import BinaryIO -from typing import Dict from typing import Optional -from typing import Tuple from typing import Union scan_response = re.compile( @@ -22,8 +20,8 @@ ScanStatus = str -ScanResult = Tuple[ScanStatus, Optional[str]] -ScanResults = Dict[str, ScanResult] +ScanResult = tuple[ScanStatus, Optional[str]] +ScanResults = dict[str, ScanResult] class ClamdError(Exception): @@ -251,7 +249,7 @@ def _close_socket(self) -> None: """ self.clamd_socket.close() - def _parse_response(self, msg: str) -> Tuple[Union[str, Any], ...]: + def _parse_response(self, msg: str) -> tuple[Union[str, Any], ...]: """ parses responses for SCAN, CONTSCAN, MULTISCAN and STREAM commands. """ diff --git a/clamav_client/scanner.py b/clamav_client/scanner.py index cd0341f..b246847 100644 --- a/clamav_client/scanner.py +++ b/clamav_client/scanner.py @@ -8,7 +8,6 @@ from subprocess import CalledProcessError from subprocess import check_output from typing import Any -from typing import Dict from typing import Literal from typing import Optional from typing import TypedDict @@ -102,7 +101,7 @@ def passed(self) -> Optional[bool]: class Scanner(abc.ABC): - _info: Dict[ProgramName, ScannerInfo] + _info: dict[ProgramName, ScannerInfo] _program: ProgramName @abc.abstractmethod @@ -209,8 +208,8 @@ def _call(self, *args: str) -> bytes: def scan(self, filename: str) -> ScanResult: result = ScanResult(filename=filename, state=None, details=None, err=None) - max_file_size = "--max-filesize=%dM" % self.max_file_size - max_scan_size = "--max-scansize=%dM" % self.max_scan_size + 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: diff --git a/pyproject.toml b/pyproject.toml index 76e55f8..2b8dd3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] description = "Python client library for the ClamAV antivirus." readme = "README.md" license = { text = "GPL-2.0" } -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [] [build-system] @@ -21,10 +21,10 @@ markers = [ [tool.uv] dev-dependencies = [ - "mypy>=1.11.2", - "pytest-cov>=5.0.0", - "pytest>=8.3.2", - "ruff>=0.6.3", + "mypy>=1.15.0", + "pytest-cov>=6.1.1", + "pytest>=8.3.5", + "ruff>=0.11.9", ] [tool.coverage.run] diff --git a/test.sh b/test.sh index 18811ac..8305bc9 100755 --- a/test.sh +++ b/test.sh @@ -3,15 +3,15 @@ set -euo pipefail versions=( - "3.8" "3.9" "3.10" "3.11" "3.12" + "3.13" ) prereleases=( - "3.13" + "3.14" ) print_status() { @@ -35,11 +35,11 @@ fi latest="${versions[${#versions[@]}-1]}" if [[ " $@ " =~ " --latest " ]]; then - versions=("3.12") + versions=("3.13") prereleases=() elif [[ " $@ " =~ " --pre " ]]; then versions=() - prereleases=("3.13") + prereleases=("3.14") fi combined=("${versions[@]}" "${prereleases[@]}") diff --git a/uv.lock b/uv.lock index 52327e0..606c017 100644 --- a/uv.lock +++ b/uv.lock @@ -1,9 +1,9 @@ version = 1 -requires-python = ">=3.8" +revision = 2 +requires-python = ">=3.9" [[package]] name = "clamav-client" -version = "0.4.1.dev2+g3910715.d20240911" source = { editable = "." } [package.dev-dependencies] @@ -18,98 +18,89 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=1.11.2" }, - { name = "pytest", specifier = ">=8.3.2" }, - { name = "pytest-cov", specifier = ">=5.0.0" }, - { name = "ruff", specifier = ">=0.6.3" }, + { 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 } +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 }, + { 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.6.1" +version = "7.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } +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/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 }, - { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 }, - { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 }, - { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 }, - { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 }, - { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 }, - { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 }, - { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 }, - { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 }, - { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 }, - { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 }, - { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 }, - { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 }, - { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 }, - { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 }, - { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 }, - { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 }, - { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 }, - { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 }, - { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 }, - { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, - { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, - { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, - { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, - { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, - { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, - { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, - { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, - { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, - { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, - { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, - { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, - { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, - { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, - { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, - { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, - { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, - { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, - { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, - { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, - { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, - { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, - { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, - { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, - { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, - { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, - { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, - { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, - { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, - { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, - { url = "https://files.pythonhosted.org/packages/81/d0/d9e3d554e38beea5a2e22178ddb16587dbcbe9a1ef3211f55733924bf7fa/coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", size = 206674 }, - { url = "https://files.pythonhosted.org/packages/38/ea/cab2dc248d9f45b2b7f9f1f596a4d75a435cb364437c61b51d2eb33ceb0e/coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", size = 207101 }, - { url = "https://files.pythonhosted.org/packages/ca/6f/f82f9a500c7c5722368978a5390c418d2a4d083ef955309a8748ecaa8920/coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", size = 236554 }, - { url = "https://files.pythonhosted.org/packages/a6/94/d3055aa33d4e7e733d8fa309d9adf147b4b06a82c1346366fc15a2b1d5fa/coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", size = 234440 }, - { url = "https://files.pythonhosted.org/packages/e4/6e/885bcd787d9dd674de4a7d8ec83faf729534c63d05d51d45d4fa168f7102/coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", size = 235889 }, - { url = "https://files.pythonhosted.org/packages/f4/63/df50120a7744492710854860783d6819ff23e482dee15462c9a833cc428a/coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", size = 235142 }, - { url = "https://files.pythonhosted.org/packages/3a/5d/9d0acfcded2b3e9ce1c7923ca52ccc00c78a74e112fc2aee661125b7843b/coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", size = 233805 }, - { url = "https://files.pythonhosted.org/packages/c4/56/50abf070cb3cd9b1dd32f2c88f083aab561ecbffbcd783275cb51c17f11d/coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", size = 234655 }, - { url = "https://files.pythonhosted.org/packages/25/ee/b4c246048b8485f85a2426ef4abab88e48c6e80c74e964bea5cd4cd4b115/coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", size = 209296 }, - { url = "https://files.pythonhosted.org/packages/5c/1c/96cf86b70b69ea2b12924cdf7cabb8ad10e6130eab8d767a1099fbd2a44f/coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", size = 210137 }, - { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 }, - { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 }, - { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 }, - { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 }, - { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 }, - { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 }, - { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 }, - { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 }, - { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 }, - { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 }, - { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 }, + { 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] @@ -119,91 +110,99 @@ toml = [ [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +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/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, + { 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.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +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/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + { 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.11.2" +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/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806 } +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/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401 }, - { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697 }, - { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508 }, - { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712 }, - { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319 }, - { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630 }, - { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973 }, - { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659 }, - { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010 }, - { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873 }, - { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335 }, - { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119 }, - { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856 }, - { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066 }, - { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000 }, - { url = "https://files.pythonhosted.org/packages/42/ad/5a8567700410f8aa7c755b0ebd4cacff22468cbc5517588773d65075c0cb/mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b", size = 10876550 }, - { url = "https://files.pythonhosted.org/packages/1b/bc/9fc16ea7a27ceb93e123d300f1cfe27a6dd1eac9a8beea4f4d401e737e9d/mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86", size = 10068086 }, - { url = "https://files.pythonhosted.org/packages/cd/8f/a1e460f1288405a13352dad16b24aba6dce4f850fc76510c540faa96eda3/mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce", size = 12459214 }, - { url = "https://files.pythonhosted.org/packages/c7/74/746b31aef7cc7512dab8bdc2311ef88d63fadc1c453a09c8cab7e57e59bf/mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1", size = 12962942 }, - { url = "https://files.pythonhosted.org/packages/28/a4/7fae712240b640d75bb859294ad4776b9960b3216ccb7fa747f578e6c632/mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b", size = 9545616 }, - { url = "https://files.pythonhosted.org/packages/16/64/bb5ed751487e2bea0dfaa6f640a7e3bb88083648f522e766d5ef4a76f578/mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6", size = 10937294 }, - { url = "https://files.pythonhosted.org/packages/a9/a3/67a0069abed93c3bf3b0bebb8857e2979a02828a4a3fd82f107f8f1143e8/mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70", size = 10107707 }, - { url = "https://files.pythonhosted.org/packages/2f/4d/0379daf4258b454b1f9ed589a9dabd072c17f97496daea7b72fdacf7c248/mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d", size = 12498367 }, - { url = "https://files.pythonhosted.org/packages/3b/dc/3976a988c280b3571b8eb6928882dc4b723a403b21735a6d8ae6ed20e82b/mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d", size = 13018014 }, - { url = "https://files.pythonhosted.org/packages/83/84/adffc7138fb970e7e2a167bd20b33bb78958370179853a4ebe9008139342/mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24", size = 9568056 }, - { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625 }, + { 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.0.0" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +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/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, + { 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 = "24.1" +version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } +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/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, + { 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 } +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 }, + { 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.2" +version = "8.3.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -213,63 +212,93 @@ dependencies = [ { name = "pluggy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/8c/9862305bdcd6020bc7b45b1b5e7397a6caf1a33d3025b9a003b39075ffb2/pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce", size = 1439314 } +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/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802 }, + { 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 = "5.0.0" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } +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/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, + { 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.6.3" +version = "0.11.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5d/f9/0b32e5d1c6f957df49398cd882a011e9488fcbca0d6acfeeea50ccd37a4d/ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983", size = 2463514 } +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/72/68/1da6a1e39a03a229ea57c511691d6225072759cc7764206c3f0989521194/ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3", size = 9696928 }, - { url = "https://files.pythonhosted.org/packages/6e/59/3b8b1d3a4271c6eb6ceecd3cef19a6d881639a0f18ad651563d6f619aaae/ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc", size = 9448462 }, - { url = "https://files.pythonhosted.org/packages/35/4f/b942ecb8bbebe53aa9b33e9b96df88acd50b70adaaed3070f1d92131a1cb/ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1", size = 9176190 }, - { url = "https://files.pythonhosted.org/packages/a0/20/b0bcb29d4ee437f3567b73b6905c034e2e94d29b9b826c66daecc1cf6388/ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1", size = 10108892 }, - { url = "https://files.pythonhosted.org/packages/9c/e3/211bc759f424e8823a9937e0f678695ca02113c621dfde1fa756f9f26f6d/ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672", size = 9476471 }, - { url = "https://files.pythonhosted.org/packages/b2/a3/2ec35a2d7a554364864206f0e46812b92a074ad8a014b923d821ead532aa/ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1", size = 10294802 }, - { url = "https://files.pythonhosted.org/packages/03/8b/56ef687b3489c88886dea48c78fb4969b6b65f18007d0ac450070edd1f58/ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384", size = 11022372 }, - { url = "https://files.pythonhosted.org/packages/a5/21/327d147feb442adb88975e81e2263102789eba9ad2afa102c661912a482f/ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a", size = 10596596 }, - { url = "https://files.pythonhosted.org/packages/6c/86/ff386de63729da3e08c8099c57f577a00ec9f3eea711b23ac07cf3588dc5/ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500", size = 11572830 }, - { url = "https://files.pythonhosted.org/packages/38/5d/b33284c108e3f315ddd09b70296fd76bd28ecf8965a520bc93f3bbd8ac40/ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470", size = 10262577 }, - { url = "https://files.pythonhosted.org/packages/29/99/9cdfad0d7f460e66567236eddc691473791afd9aff93a0dfcdef0462a6c7/ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f", size = 10098751 }, - { url = "https://files.pythonhosted.org/packages/a8/9f/f801a1619f5549e552f1f722f1db57eb39e7e1d83d482133142781d450de/ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5", size = 9563859 }, - { url = "https://files.pythonhosted.org/packages/0b/4d/fb2424faf04ffdb960ae2b3a1d991c5183dd981003de727d2d5cc38abc98/ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351", size = 9914291 }, - { url = "https://files.pythonhosted.org/packages/2e/dd/94fddf002a8f6152e8ebfbb51d3f93febc415c1fe694345623c31ce8b33b/ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8", size = 10331549 }, - { url = "https://files.pythonhosted.org/packages/b4/73/ca9c2f9237a430ca423b6dca83b77e9a428afeb7aec80596e86c369123fe/ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521", size = 7962163 }, - { url = "https://files.pythonhosted.org/packages/55/ce/061c605b1dfb52748d59bc0c7a8507546c178801156415773d18febfd71d/ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb", size = 8800901 }, - { url = "https://files.pythonhosted.org/packages/63/28/ae4ffe7d3b6134ca6d31ebef07447ef70097c4a9e8fbbc519b374c5c1559/ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82", size = 8229171 }, + { 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.0.1" +version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } +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/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, + { 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.12.2" +version = "4.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +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/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { 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" }, ] From 200bda0301ceff5896832542b03db6613bf4c966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 14 May 2025 19:53:04 +0200 Subject: [PATCH 60/64] Update uv action --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1f0458..c28a314 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: - name: Check out repository uses: "actions/checkout@v4" - name: Install the latest version of uv - uses: astral-sh/setup-uv@v2 + uses: astral-sh/setup-uv@v6 with: enable-cache: true version: latest From a805a14cc54099b1646dc4f97b0749e3d722f972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Wed, 14 May 2025 20:02:13 +0200 Subject: [PATCH 61/64] Update EICAR name --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index b8bbe0b..dc22b04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,7 @@ # in the CI job. EICAR_NAME = "Win.Test.EICAR_HDB-1" if CI: - EICAR_NAME = "Eicar-Signature" + EICAR_NAME = "Eicar-Test-Signature" @pytest.fixture From d6ba7a174b3d8b28446d575556f196648b2115af Mon Sep 17 00:00:00 2001 From: Richie B2B Date: Thu, 15 May 2025 09:02:24 +0200 Subject: [PATCH 62/64] Add project.urls to pyproject.toml --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2b8dd3b..cdbdef2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,12 @@ 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" From 541d92579f685bbb07bae4458bdb30df01141136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Garc=C3=ADa=20Crespo?= Date: Thu, 15 May 2025 17:37:34 +0200 Subject: [PATCH 63/64] Download main and daily dbs in CI --- .github/workflows/test.yml | 2 ++ tests/conftest.py | 8 +------- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89199a5..1c6e10f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,8 @@ jobs: 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: diff --git a/tests/conftest.py b/tests/conftest.py index dc22b04..61f9e8e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,12 +19,6 @@ CI = True if "CI" in environ or "GITHUB_REF" in environ else False -# TODO: figure out this discrepancy - likely because we're missing recent sigs -# in the CI job. -EICAR_NAME = "Win.Test.EICAR_HDB-1" -if CI: - EICAR_NAME = "Eicar-Test-Signature" - @pytest.fixture def ci() -> bool: @@ -33,7 +27,7 @@ def ci() -> bool: @pytest.fixture def eicar_name() -> str: - return EICAR_NAME + return "Win.Test.EICAR_HDB-1" @pytest.fixture From dd59841537669f28ac3793ba86d077f8aea1a665 Mon Sep 17 00:00:00 2001 From: nathan wray Date: Wed, 4 Mar 2026 18:20:37 -0500 Subject: [PATCH 64/64] use sendall vs send which does not guarentee all bytes will be sent --- clamav_client/clamd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/clamav_client/clamd.py b/clamav_client/clamd.py index 03e2969..5c58e4b 100644 --- a/clamav_client/clamd.py +++ b/clamav_client/clamd.py @@ -178,9 +178,9 @@ def instream(self, buff: BinaryIO) -> ScanResults: chunk = buff.read(max_chunk_size) while chunk: size = struct.pack(b"!L", len(chunk)) - self.clamd_socket.send(size + 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": @@ -217,7 +217,7 @@ def _send_command(self, cmd: str, *args: str) -> None: if args: concat_args = " " + " ".join(args) send = f"n{cmd}{concat_args}\n".encode() - self.clamd_socket.send(send) + self.clamd_socket.sendall(send) def _recv_response(self) -> str: """