From bf46c2fff5c7dbcf1dca58715753818d16cb274b Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 1 Jul 2025 11:58:25 +0100 Subject: [PATCH 1/6] Add ability to scan file bodies. --- src/matrix_content_scanner/httpserver.py | 1 + src/matrix_content_scanner/scanner/scanner.py | 123 ++++++++++++++---- .../servlets/__init__.py | 25 ++++ src/matrix_content_scanner/servlets/scan.py | 64 ++++++++- src/matrix_content_scanner/utils/constants.py | 2 + 5 files changed, 189 insertions(+), 26 deletions(-) diff --git a/src/matrix_content_scanner/httpserver.py b/src/matrix_content_scanner/httpserver.py index d1189d2..311dde8 100644 --- a/src/matrix_content_scanner/httpserver.py +++ b/src/matrix_content_scanner/httpserver.py @@ -109,6 +109,7 @@ def _build_app(self) -> web.Application: [ web.get("/scan" + _MEDIA_PATH_REGEXP, scan_handler.handle_plain), web.post("/scan_encrypted", scan_handler.handle_encrypted), + web.post("/scan_file", scan_handler.handle_file), web.get( "/download" + _MEDIA_PATH_REGEXP, download_handler.handle_plain ), diff --git a/src/matrix_content_scanner/scanner/scanner.py b/src/matrix_content_scanner/scanner/scanner.py index d8474b1..c6bd8ec 100644 --- a/src/matrix_content_scanner/scanner/scanner.py +++ b/src/matrix_content_scanner/scanner/scanner.py @@ -7,12 +7,14 @@ import logging import os import subprocess +import uuid from asyncio import Future from pathlib import Path from typing import TYPE_CHECKING, Dict, List, Optional, Tuple import attr import magic +from aiohttp import BodyPartReader from cachetools import TTLCache from canonicaljson import encode_canonical_json from humanfriendly import format_size @@ -309,6 +311,55 @@ async def _scan_file( return media + async def scan_file_on_disk( + self, file_path: str, metadata: Optional[JsonDict] = None + ) -> None: + """Scan a file that already exists on disk. The file will be deleted after scanning. + + This does not cache the result. + + Args: + file_path: The full file path to the source file. + metadata: The metadata attached to the file (e.g. decryption key), or None if + the file isn't encrypted. + + Raises: + FileDirtyError if the result of the scan said that the file is dirty. + """ + scan_filename = file_path + if metadata is not None: + with open(file_path, "rb") as f: + content = f.read() + # If the file is encrypted, we need to decrypt it before we can scan it. + media_content = self._decrypt_file(content, metadata) + scan_filename = self._write_file_to_disk( + str(uuid.uuid4()), media_content + ) + + # Remove source file now we've decrypted it. + removal_command_parts = self._removal_command.split() + removal_command_parts.append(file_path) + subprocess.run(removal_command_parts) + + try: + # Check the file's MIME type to see if it's allowed. + self._check_mimetype(scan_filename) + + # Scan the file and see if the result is positive or negative. + exit_code = await self._run_scan(scan_filename) + result = exit_code == 0 + + # Delete the file now that we've scanned it. + logger.info("Scan has finished, removing file") + finally: + removal_command_parts = self._removal_command.split() + removal_command_parts.append(scan_filename) + subprocess.run(removal_command_parts) + + # Raise an error if the result isn't clean. + if result is False: + raise FileDirtyError(cacheable=False) + async def _scan_media( self, media: MediaDescription, @@ -340,29 +391,32 @@ async def _scan_media( # If the file is encrypted, we need to decrypt it before we can scan it. media_content = self._decrypt_file(media_content, metadata) - # Check the file's MIME type to see if it's allowed. - self._check_mimetype(media_content) - # Write the file to disk. file_path = self._write_file_to_disk(media_path, media_content) - # Scan the file and see if the result is positive or negative. - exit_code = await self._run_scan(file_path) - result = exit_code == 0 + try: + # Check the file's MIME type to see if it's allowed. + self._check_mimetype(file_path) - # If the exit code isn't part of the ones we should ignore, cache the result. - cacheable = True - if exit_code in self._exit_codes_to_ignore: - logger.info( - "Scan returned exit code %d which must not be cached", exit_code - ) - cacheable = False + # Scan the file and see if the result is positive or negative. + exit_code = await self._run_scan(file_path) + result = exit_code == 0 + + # If the exit code isn't part of the ones we should ignore, cache the result. + cacheable = True + if exit_code in self._exit_codes_to_ignore: + logger.info( + "Scan returned exit code %d which must not be cached", exit_code + ) + cacheable = False - # Delete the file now that we've scanned it. - logger.info("Scan has finished, removing file") - removal_command_parts = self._removal_command.split() - removal_command_parts.append(file_path) - subprocess.run(removal_command_parts) + logger.info("Scan has finished") + finally: + # Delete the file now that we've scanned it. + logger.info("Removing file") + removal_command_parts = self._removal_command.split() + removal_command_parts.append(file_path) + subprocess.run(removal_command_parts) # Raise an error if the result isn't clean. if result is False: @@ -434,6 +488,30 @@ def _decrypt_file(self, body: bytes, metadata: JsonDict) -> bytes: info=str(e), ) + async def write_multipart_to_disk(self, multipart: BodyPartReader) -> str: + """ + Writes a multipart file body to the store directory. + + Returns: + The full file path to the file. + """ + filename = str(uuid.uuid4()) + # Figure out the full absolute path for this file. + full_path = self._store_directory.joinpath(filename).resolve() + logger.info("Writing multipart file to %s", full_path) + + # Create any directory we need. + os.makedirs(full_path.parent, exist_ok=True) + + with open(full_path, "wb") as fp: + while True: + chunk = await multipart.read_chunk() + if not chunk: + break + fp.write(chunk) + + return str(full_path) + def _write_file_to_disk(self, media_path: str, body: bytes) -> str: """Writes the given content to disk. The final file name will be a concatenation of `temp_directory` and the media's `server_name/media_id` path. @@ -495,16 +573,15 @@ async def _run_scan(self, file_name: str) -> int: return retcode - def _check_mimetype(self, media_content: bytes) -> None: - """Detects the MIME type of the provided bytes, and checks that this type is allowed + def _check_mimetype(self, filepath: str) -> None: + """Detects the MIME type of the provided file, and checks that this type is allowed (if an allow list is provided in the configuration) Args: - media_content: The file's content. If the file is encrypted, this is its - decrypted content. + filepath: The full file path. Raises: FileMimeTypeForbiddenError if one of the checks fail. """ - detected_mimetype = magic.from_buffer(media_content, mime=True) + detected_mimetype = magic.from_file(filepath, mime=True) logger.debug("Detected MIME type for file is %s", detected_mimetype) # If there's an allow list for MIME types, check that the MIME type that's been diff --git a/src/matrix_content_scanner/servlets/__init__.py b/src/matrix_content_scanner/servlets/__init__.py index 53e4ff7..37e070e 100644 --- a/src/matrix_content_scanner/servlets/__init__.py +++ b/src/matrix_content_scanner/servlets/__init__.py @@ -180,6 +180,31 @@ async def get_media_metadata_from_request( return media_path, metadata +async def get_media_metadata_from_filebody( + file_body: JsonDict, + crypto_handler: crypto.CryptoHandler, +) -> JsonDict: + """Extracts, optionally decrypts, and validates encrypted file metadata from a + request body. + + Args: + request: The request to extract the data from. + crypto_handler: The crypto handler to use if we need to decrypt an Olm-encrypted + body. + + Raises: + ContentScannerRestError(400) if the request's body is None or if the metadata + didn't pass schema validation. + """ + metadata = _metadata_from_body(file_body, crypto_handler) + + validate_encrypted_file_metadata(metadata) + + # URL parameter is ignored. + + return metadata + + def _metadata_from_body( body: JsonDict, crypto_handler: crypto.CryptoHandler ) -> JsonDict: diff --git a/src/matrix_content_scanner/servlets/scan.py b/src/matrix_content_scanner/servlets/scan.py index 461581e..833a1c0 100644 --- a/src/matrix_content_scanner/servlets/scan.py +++ b/src/matrix_content_scanner/servlets/scan.py @@ -2,12 +2,18 @@ # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial # Please see LICENSE files in the repository root for full details. +import json from typing import TYPE_CHECKING, Optional, Tuple -from aiohttp import web +from aiohttp import BodyPartReader, web -from matrix_content_scanner.servlets import get_media_metadata_from_request, web_handler -from matrix_content_scanner.utils.errors import FileDirtyError +from matrix_content_scanner.servlets import ( + get_media_metadata_from_filebody, + get_media_metadata_from_request, + web_handler, +) +from matrix_content_scanner.utils.constants import ErrCode +from matrix_content_scanner.utils.errors import ContentScannerRestError, FileDirtyError from matrix_content_scanner.utils.types import JsonDict if TYPE_CHECKING: @@ -51,3 +57,55 @@ async def handle_encrypted(self, request: web.Request) -> Tuple[int, JsonDict]: return await self._scan_and_format( media_path, metadata, auth_header=request.headers.get("Authorization") ) + + @web_handler + async def handle_file(self, request: web.Request) -> Tuple[int, JsonDict]: + """Handles GET requests to ../scan_file""" + try: + reader = await request.multipart() + except Exception: + raise ContentScannerRestError( + 400, + ErrCode.MALFORMED_MULTIPART, + "Request body was not a multipart body.", + ) + + body = None + metadata: Optional[JsonDict] = None + + # Iterate to find the fields. + while True: + field = await reader.next() + if (metadata and body) or field is None: + break + if not isinstance(field, BodyPartReader): + continue + if field.name == "file": + try: + file_json = await field.json() + if file_json is None: + raise Exception("'file' field is empty") + except json.decoder.JSONDecodeError as e: + raise ContentScannerRestError(400, ErrCode.MALFORMED_JSON, str(e)) + + metadata = await get_media_metadata_from_filebody( + file_json, self._crypto_handler + ) + elif field.name == "body": + body = await self._scanner.write_multipart_to_disk(field) + + if body is None: + raise ContentScannerRestError( + 400, ErrCode.MALFORMED_MULTIPART, "Missing 'body' field" + ) + + # 'metadata' is optional + + try: + await self._scanner.scan_file_on_disk(body, metadata) + except FileDirtyError as e: + res = {"clean": False, "info": e.info} + else: + res = {"clean": True, "info": "File is clean"} + + return 200, res diff --git a/src/matrix_content_scanner/utils/constants.py b/src/matrix_content_scanner/utils/constants.py index aac0b06..ab8931c 100644 --- a/src/matrix_content_scanner/utils/constants.py +++ b/src/matrix_content_scanner/utils/constants.py @@ -32,3 +32,5 @@ class ErrCode(str, Enum): MALFORMED_JSON = "MCS_MALFORMED_JSON" # The Mime type is not in the allowed list of Mime types. MIME_TYPE_FORBIDDEN = "MCS_MIME_TYPE_FORBIDDEN" + # The body was not a multipart. + MALFORMED_MULTIPART = "MCS_MALFORMED_MULTIPART" From e2cb3e5ec3bdb6440f4c861085397b113cd675ce Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 1 Jul 2025 11:58:29 +0100 Subject: [PATCH 2/6] Add documentation --- docs/api.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/api.md b/docs/api.md index d37fed9..ba783ec 100644 --- a/docs/api.md +++ b/docs/api.md @@ -128,6 +128,26 @@ Downloads a specified encrypted file, decrypts it and then behaves identically t The request body for this route is the same as for `POST /_matrix/media_proxy/unstable/download_encrypted`. +### `POST /_matrix/media_proxy/unstable/scan_file` + +Performs a scan on a file body without uploading to Matrix. This request takes a multi-part / form data +body. + +Response format: + +| Parameter | Type | Description | +|-----------|------|--------------------------------------------------------------------| +| `body` | [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) | The file body. | +| `file` | EncryptedFile as JSON string | The metadata (decryption key) of an encrypted file. Follows the format of the `EncryptedFile` structure from the [Matrix specification](https://spec.matrix.org/v1.2/client-server-api/#extensions-to-mroommessage-msgtypes). Only required if the file is encrypted. | + +Example: + +```json +{ + "clean": false, + "info": "***VIRUS DETECTED***" +} +``` ### `GET /_matrix/media_proxy/unstable/public_key` From 0567a1fbf5b555d8a6abe9ce776790d2aa1ca85c Mon Sep 17 00:00:00 2001 From: Joshua Fenster Date: Mon, 9 Mar 2026 11:31:02 +0100 Subject: [PATCH 3/6] Adds restructured scan_content and removes write_multipart_to_disk --- src/matrix_content_scanner/scanner/scanner.py | 121 +++++++++--------- src/matrix_content_scanner/servlets/scan.py | 4 +- 2 files changed, 60 insertions(+), 65 deletions(-) diff --git a/src/matrix_content_scanner/scanner/scanner.py b/src/matrix_content_scanner/scanner/scanner.py index c6bd8ec..f7fc27a 100644 --- a/src/matrix_content_scanner/scanner/scanner.py +++ b/src/matrix_content_scanner/scanner/scanner.py @@ -311,54 +311,73 @@ async def _scan_file( return media - async def scan_file_on_disk( - self, file_path: str, metadata: Optional[JsonDict] = None + async def scan_content( + self, content: bytes, metadata: Optional[JsonDict] = None ) -> None: - """Scan a file that already exists on disk. The file will be deleted after scanning. + """Scan raw file bytes. The content is written to disk once (decrypted if + needed), scanned, and cleaned up. - This does not cache the result. + This does not use the result cache or concurrent-request deduplication. Args: - file_path: The full file path to the source file. - metadata: The metadata attached to the file (e.g. decryption key), or None if - the file isn't encrypted. + content: The raw file bytes (possibly still encrypted). + metadata: The metadata attached to the file (e.g. decryption key), or None + if the file isn't encrypted. Raises: FileDirtyError if the result of the scan said that the file is dirty. """ - scan_filename = file_path + exit_code = await self._do_scan(content, metadata) + result = exit_code == 0 + + cacheable = exit_code not in self._exit_codes_to_ignore + + if result is False: + raise FileDirtyError(cacheable=cacheable) + + async def _do_scan( + self, + content: bytes, + metadata: Optional[JsonDict] = None, + file_id: Optional[str] = None, + ) -> int: + """Core scan pipeline shared by all request paths. + + Handles: decrypt (if needed) → write to disk → mimetype check → scan → cleanup. + + Args: + content: The raw file bytes (encrypted or plaintext). + metadata: Decryption metadata, or None if the file is unencrypted. + file_id: Identifier used as the temp filename on disk. If None, a random + UUID is generated. Passing the media_path (server_name/media_id) + preserves the original directory structure for traceability. + + Returns: + The exit code from the scan script (0 = clean). + """ + # Decrypt the content if necessary. if metadata is not None: - with open(file_path, "rb") as f: - content = f.read() - # If the file is encrypted, we need to decrypt it before we can scan it. - media_content = self._decrypt_file(content, metadata) - scan_filename = self._write_file_to_disk( - str(uuid.uuid4()), media_content - ) + # If the file is encrypted, we need to decrypt it before we can scan it. + content = self._decrypt_file(content, metadata) - # Remove source file now we've decrypted it. - removal_command_parts = self._removal_command.split() - removal_command_parts.append(file_path) - subprocess.run(removal_command_parts) + # Write the file to disk. + file_path = self._write_file_to_disk(file_id or str(uuid.uuid4()), content) try: # Check the file's MIME type to see if it's allowed. - self._check_mimetype(scan_filename) - + self._check_mimetype(file_path) # Scan the file and see if the result is positive or negative. - exit_code = await self._run_scan(scan_filename) - result = exit_code == 0 - - # Delete the file now that we've scanned it. - logger.info("Scan has finished, removing file") + exit_code = await self._run_scan(file_path) + # Log the result of the scan. + logger.info("Scan has finished") finally: + # This could be own function. + logger.info("Removing file") removal_command_parts = self._removal_command.split() - removal_command_parts.append(scan_filename) + removal_command_parts.append(file_path) subprocess.run(removal_command_parts) - # Raise an error if the result isn't clean. - if result is False: - raise FileDirtyError(cacheable=False) + return exit_code async def _scan_media( self, @@ -384,41 +403,17 @@ async def _scan_media( FileDirtyError if the result of the scan said that the file is dirty, or if the media path is malformed. """ + exit_code = await self._do_scan(media.content, metadata, file_id=media_path) + result = exit_code == 0 - # Decrypt the content if necessary. - media_content = media.content - if metadata is not None: - # If the file is encrypted, we need to decrypt it before we can scan it. - media_content = self._decrypt_file(media_content, metadata) - - # Write the file to disk. - file_path = self._write_file_to_disk(media_path, media_content) - - try: - # Check the file's MIME type to see if it's allowed. - self._check_mimetype(file_path) - - # Scan the file and see if the result is positive or negative. - exit_code = await self._run_scan(file_path) - result = exit_code == 0 - - # If the exit code isn't part of the ones we should ignore, cache the result. - cacheable = True - if exit_code in self._exit_codes_to_ignore: - logger.info( - "Scan returned exit code %d which must not be cached", exit_code - ) - cacheable = False - - logger.info("Scan has finished") - finally: - # Delete the file now that we've scanned it. - logger.info("Removing file") - removal_command_parts = self._removal_command.split() - removal_command_parts.append(file_path) - subprocess.run(removal_command_parts) + # If the exit code isn't part of the ones we should ignore, cache the result. + cacheable = True + if exit_code in self._exit_codes_to_ignore: + logger.info( + "Scan returned exit code %d which must not be cached", exit_code + ) + cacheable = False - # Raise an error if the result isn't clean. if result is False: raise FileDirtyError(cacheable=cacheable) diff --git a/src/matrix_content_scanner/servlets/scan.py b/src/matrix_content_scanner/servlets/scan.py index 833a1c0..f4650c9 100644 --- a/src/matrix_content_scanner/servlets/scan.py +++ b/src/matrix_content_scanner/servlets/scan.py @@ -92,7 +92,7 @@ async def handle_file(self, request: web.Request) -> Tuple[int, JsonDict]: file_json, self._crypto_handler ) elif field.name == "body": - body = await self._scanner.write_multipart_to_disk(field) + body = await field.read() if body is None: raise ContentScannerRestError( @@ -102,7 +102,7 @@ async def handle_file(self, request: web.Request) -> Tuple[int, JsonDict]: # 'metadata' is optional try: - await self._scanner.scan_file_on_disk(body, metadata) + await self._scanner.scan_content(body, metadata) except FileDirtyError as e: res = {"clean": False, "info": e.info} else: From fb95f07172402a4f204793a8bfe9bb408abc5db2 Mon Sep 17 00:00:00 2001 From: Joshua Fenster Date: Mon, 9 Mar 2026 12:57:01 +0100 Subject: [PATCH 4/6] Adds Async file writing via aiofile removes write_multipart_to_disk --- poetry.lock | 114 +++++++++++++++--- pyproject.toml | 2 + src/matrix_content_scanner/scanner/scanner.py | 44 +++---- 3 files changed, 116 insertions(+), 44 deletions(-) diff --git a/poetry.lock b/poetry.lock index cab0bb6..bb50b4a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,19 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "aiofile" +version = "3.9.0" +description = "Asynchronous file operations." +optional = false +python-versions = "<4,>=3.8" +groups = ["main"] +files = [ + {file = "aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa"}, + {file = "aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b"}, +] + +[package.dependencies] +caio = ">=0.9.0,<0.10.0" [[package]] name = "aiohappyeyeballs" @@ -6,6 +21,7 @@ version = "2.4.0" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, @@ -17,6 +33,7 @@ version = "3.10.5" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"}, {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"}, @@ -121,7 +138,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" @@ -129,6 +146,7 @@ version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, @@ -143,6 +161,8 @@ version = "4.0.3" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.11\"" files = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, @@ -154,18 +174,19 @@ version = "24.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] [[package]] name = "cachetools" @@ -173,17 +194,54 @@ version = "5.5.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, ] +[[package]] +name = "caio" +version = "0.9.25" +description = "Asynchronous file IO for Linux MacOS or Windows." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619"}, + {file = "caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241"}, + {file = "caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b"}, + {file = "caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae"}, + {file = "caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965"}, + {file = "caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478"}, + {file = "caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c"}, + {file = "caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc"}, + {file = "caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044"}, + {file = "caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64"}, + {file = "caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb"}, + {file = "caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69"}, + {file = "caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451"}, + {file = "caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6"}, + {file = "caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f"}, + {file = "caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7"}, + {file = "caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db"}, + {file = "caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77"}, + {file = "caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79"}, + {file = "caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7"}, + {file = "caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40"}, + {file = "caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10"}, +] + +[package.extras] +develop = ["aiomisc-pytest", "coveralls", "pylama[toml]", "pytest", "pytest-cov", "setuptools"] + [[package]] name = "canonicaljson" version = "2.0.0" description = "Canonical JSON" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "canonicaljson-2.0.0-py3-none-any.whl", hash = "sha256:c38a315de3b5a0532f1ec1f9153cd3d716abfc565a558d00a4835428a34fca5b"}, {file = "canonicaljson-2.0.0.tar.gz", hash = "sha256:e2fdaef1d7fadc5d9cb59bd3d0d41b064ddda697809ac4325dced721d12f113f"}, @@ -195,6 +253,7 @@ version = "1.4.1" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, @@ -281,6 +340,7 @@ version = "10.0" description = "Human friendly output for text interfaces using Python" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] files = [ {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, @@ -295,6 +355,7 @@ version = "3.8" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, @@ -306,6 +367,7 @@ version = "4.23.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, @@ -313,7 +375,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -327,6 +389,7 @@ version = "2023.12.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, @@ -341,6 +404,7 @@ version = "6.0.5" description = "multidict implementation" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, @@ -440,6 +504,7 @@ version = "1.11.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, @@ -487,6 +552,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -498,6 +564,8 @@ version = "3.4.1" description = "A python implementation of GNU readline." optional = false python-versions = "*" +groups = ["main"] +markers = "sys_platform == \"win32\"" files = [ {file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"}, {file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"}, @@ -509,6 +577,7 @@ version = "0.4.27" description = "File type identification using libmagic" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] files = [ {file = "python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b"}, {file = "python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3"}, @@ -520,6 +589,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -582,6 +652,7 @@ version = "0.35.1" description = "JSON Referencing + Python" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, @@ -597,6 +668,7 @@ version = "0.20.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, @@ -709,6 +781,7 @@ version = "0.7.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "ruff-0.7.2-py3-none-linux_armv6l.whl", hash = "sha256:b73f873b5f52092e63ed540adefc3c36f1f803790ecf2590e1df8bf0a9f72cb8"}, {file = "ruff-0.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5b813ef26db1015953daf476202585512afd6a6862a02cde63f3bafb53d0b2d4"}, @@ -736,13 +809,14 @@ version = "2.10.0" description = "A library implementing the 'SemVer' scheme." optional = false python-versions = ">=2.7" +groups = ["main"] files = [ {file = "semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177"}, {file = "semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c"}, ] [package.extras] -dev = ["Django (>=1.11)", "check-manifest", "colorama (<=0.4.1)", "coverage", "flake8", "nose2", "readme-renderer (<25.0)", "tox", "wheel", "zest.releaser[recommended]"] +dev = ["Django (>=1.11)", "check-manifest", "colorama (<=0.4.1) ; python_version == \"3.4\"", "coverage", "flake8", "nose2", "readme-renderer (<25.0) ; python_version == \"3.4\"", "tox", "wheel", "zest.releaser[recommended]"] doc = ["Sphinx", "sphinx-rtd-theme"] [[package]] @@ -751,19 +825,20 @@ version = "74.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "setuptools-74.0.0-py3-none-any.whl", hash = "sha256:0274581a0037b638b9fc1c6883cc71c0210865aaa76073f7882376b641b84e8f"}, {file = "setuptools-74.0.0.tar.gz", hash = "sha256:a85e96b8be2b906f3e3e789adec6a9323abf79758ecfa3065bd740d81158b11e"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.5.2) ; sys_platform != \"cygwin\""] +core = ["importlib-metadata (>=6) ; python_version < \"3.10\"", "importlib-resources (>=5.10.2) ; python_version < \"3.9\"", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.11.*)", "pytest-mypy"] [[package]] name = "setuptools-rust" @@ -771,6 +846,7 @@ version = "1.10.1" description = "Setuptools Rust extension plugin" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "setuptools_rust-1.10.1-py3-none-any.whl", hash = "sha256:3837616cc0a7705b2c44058f626c97f774eeb980f28427c16ece562661bc20c5"}, {file = "setuptools_rust-1.10.1.tar.gz", hash = "sha256:d79035fc54cdf9342e9edf4b009491ecab06c3a652b37c3c137c7ba85547d3e6"}, @@ -786,6 +862,8 @@ version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -797,6 +875,7 @@ version = "5.5.0.20240820" description = "Typing stubs for cachetools" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types-cachetools-5.5.0.20240820.tar.gz", hash = "sha256:b888ab5c1a48116f7799cd5004b18474cd82b5463acb5ffb2db2fc9c7b053bc0"}, {file = "types_cachetools-5.5.0.20240820-py3-none-any.whl", hash = "sha256:efb2ed8bf27a4b9d3ed70d33849f536362603a90b8090a328acf0cd42fda82e2"}, @@ -808,6 +887,7 @@ version = "10.0.1.11" description = "Typing stubs for humanfriendly" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "types-humanfriendly-10.0.1.11.tar.gz", hash = "sha256:c87b2eb8ec2ab1ae51cb3cc2c0efee31622a6711b2a7627d8109e22b4cfd4366"}, {file = "types_humanfriendly-10.0.1.11-py3-none-any.whl", hash = "sha256:c0ca654b16ff36ed4c059152085a76c9909272f5ea6ac03cdb7e59b5dcd392c7"}, @@ -819,6 +899,7 @@ version = "4.23.0.20240813" description = "Typing stubs for jsonschema" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types-jsonschema-4.23.0.20240813.tar.gz", hash = "sha256:c93f48206f209a5bc4608d295ac39f172fb98b9e24159ce577dbd25ddb79a1c0"}, {file = "types_jsonschema-4.23.0.20240813-py3-none-any.whl", hash = "sha256:be283e23f0b87547316c2ee6b0fd36d95ea30e921db06478029e10b5b6aa6ac3"}, @@ -833,6 +914,7 @@ version = "6.0.12.20240808" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types-PyYAML-6.0.12.20240808.tar.gz", hash = "sha256:b8f76ddbd7f65440a8bda5526a9607e4c7a322dc2f8e1a8c405644f9a6f4b9af"}, {file = "types_PyYAML-6.0.12.20240808-py3-none-any.whl", hash = "sha256:deda34c5c655265fc517b546c902aa6eed2ef8d3e921e4765fe606fe2afe8d35"}, @@ -844,6 +926,7 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -855,6 +938,7 @@ version = "1.9.4" description = "Yet another URL library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, @@ -953,6 +1037,6 @@ idna = ">=2.0" multidict = ">=4.0" [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.10.0" -content-hash = "9c601c1dba23ffa589203c39162eb727ca4a65851837d7d3e12318eae0d324f8" +content-hash = "2ed0bcdad855c181359b9c94295b6e18fe7e078247e74edc444e292f6633df43" diff --git a/pyproject.toml b/pyproject.toml index 0fcdc2b..4175aff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,8 @@ humanfriendly = ">=10.0" # Required for calculating cache keys deterministically. Type annotations aren't # discoverable in versions older than 1.6.3. canonicaljson = ">=1.6.3" +# Required for non-blocking file I/O. +aiofile = ">=3.8.0" setuptools_rust = ">=1.3" [tool.poetry.dev-dependencies] diff --git a/src/matrix_content_scanner/scanner/scanner.py b/src/matrix_content_scanner/scanner/scanner.py index f7fc27a..94fdaa7 100644 --- a/src/matrix_content_scanner/scanner/scanner.py +++ b/src/matrix_content_scanner/scanner/scanner.py @@ -14,7 +14,7 @@ import attr import magic -from aiohttp import BodyPartReader +from aiofile import async_open from cachetools import TTLCache from canonicaljson import encode_canonical_json from humanfriendly import format_size @@ -361,7 +361,9 @@ async def _do_scan( content = self._decrypt_file(content, metadata) # Write the file to disk. - file_path = self._write_file_to_disk(file_id or str(uuid.uuid4()), content) + file_path = await self._write_file_to_disk( + file_id or str(uuid.uuid4()), content + ) try: # Check the file's MIME type to see if it's allowed. @@ -483,31 +485,7 @@ def _decrypt_file(self, body: bytes, metadata: JsonDict) -> bytes: info=str(e), ) - async def write_multipart_to_disk(self, multipart: BodyPartReader) -> str: - """ - Writes a multipart file body to the store directory. - - Returns: - The full file path to the file. - """ - filename = str(uuid.uuid4()) - # Figure out the full absolute path for this file. - full_path = self._store_directory.joinpath(filename).resolve() - logger.info("Writing multipart file to %s", full_path) - - # Create any directory we need. - os.makedirs(full_path.parent, exist_ok=True) - - with open(full_path, "wb") as fp: - while True: - chunk = await multipart.read_chunk() - if not chunk: - break - fp.write(chunk) - - return str(full_path) - - def _write_file_to_disk(self, media_path: str, body: bytes) -> str: + async def _write_file_to_disk(self, media_path: str, body: bytes) -> str: """Writes the given content to disk. The final file name will be a concatenation of `temp_directory` and the media's `server_name/media_id` path. @@ -537,8 +515,16 @@ def _write_file_to_disk(self, media_path: str, body: bytes) -> str: # Create any directory we need. os.makedirs(full_path.parent, exist_ok=True) - with open(full_path, "wb") as fp: - fp.write(body) + try: + async with async_open(full_path, "wb") as fp: + await fp.write(body if isinstance(body, bytes) else bytes(body)) + except Exception: + # Delete the file if the write fails. + try: + os.unlink(full_path) + except OSError: + pass + raise return str(full_path) From 4b7209fdf3992c76c20c236de8ad4fbfd3b29e59 Mon Sep 17 00:00:00 2001 From: Joshua Fenster Date: Mon, 9 Mar 2026 13:41:07 +0100 Subject: [PATCH 5/6] Updates API.docs --- docs/api.md | 87 +++++++++++++++++-- .../servlets/__init__.py | 3 +- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/docs/api.md b/docs/api.md index ba783ec..c8aed6f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -130,17 +130,88 @@ The request body for this route is the same as for ### `POST /_matrix/media_proxy/unstable/scan_file` -Performs a scan on a file body without uploading to Matrix. This request takes a multi-part / form data -body. +Scans a file directly without downloading it from a Matrix homeserver. The file +content is sent in the request body as a `multipart/form-data` upload. -Response format: +#### Request -| Parameter | Type | Description | -|-----------|------|--------------------------------------------------------------------| -| `body` | [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) | The file body. | -| `file` | EncryptedFile as JSON string | The metadata (decryption key) of an encrypted file. Follows the format of the `EncryptedFile` structure from the [Matrix specification](https://spec.matrix.org/v1.2/client-server-api/#extensions-to-mroommessage-msgtypes). Only required if the file is encrypted. | +The request must use `Content-Type: multipart/form-data` with the following parts: -Example: +| Part name | Required | Type | Description | +|-----------|----------|-a-----|-------------| +| `body` | **Yes** | Binary (file content) | The raw file to scan. | +| `file` | No | JSON string | Decryption metadata for an encrypted file. Follows the [`EncryptedFile`](https://spec.matrix.org/v1.2/client-server-api/#extensions-to-mroommessage-msgtypes) structure from the Matrix specification. Only needed when the file in `body` is encrypted. | + +#### Request examples + +Scan an unencrypted file with `curl`: + +```bash +curl -X POST \ + http://localhost:8080/_matrix/media_proxy/unstable/scan_file \ + -F "body=@document.pdf;type=application/pdf" +``` + +Scan an encrypted file (provide decryption metadata via the `file` part): + +```bash +curl -X POST \ + http://localhost:8080/_matrix/media_proxy/unstable/scan_file \ + -F "body=@encrypted_file.bin;type=application/octet-stream" \ + -F "file={\"v\":\"v2\",\"key\":{...},\"iv\":\"...\",\"hashes\":{...}};type=application/json" +``` + +Scan a file with Python (`requests`): + +```python +import requests + +resp = requests.post( + "http://localhost:8080/_matrix/media_proxy/unstable/scan_file", + files={"body": ("image.png", open("image.png", "rb"), "image/png")}, +) +print(resp.json()) # {"clean": true, "info": "File is clean"} +``` + +Scan an encrypted file with Python (`requests`), providing decryption metadata via the `file` part: + +```python +import json +import requests + +encrypted_file_metadata = { + "v": "v2", + "key": { + "alg": "A256CTR", + "ext": True, + "k": "base64-encoded-key", + "key_ops": ["encrypt", "decrypt"], + "kty": "oct", + }, + "iv": "base64-encoded-iv", + "hashes": { + "sha256": "base64-encoded-hash", + }, +} + +resp = requests.post( + "http://localhost:8080/_matrix/media_proxy/unstable/scan_file", + files={ + "body": ("encrypted.bin", open("encrypted.bin", "rb"), "application/octet-stream"), + "file": ("metadata.json", json.dumps(encrypted_file_metadata), "application/json"), + }, +) +print(resp.json()) # {"clean": true, "info": "File is clean"} +``` + +#### Response + +| Parameter | Type | Description | +|-----------|------|-------------| +| `clean` | bool | `true` if the file passed the scan, `false` otherwise. | +| `info` | str | Human-readable result description. | + +Example response: ```json { diff --git a/src/matrix_content_scanner/servlets/__init__.py b/src/matrix_content_scanner/servlets/__init__.py index 37e070e..2ed6168 100644 --- a/src/matrix_content_scanner/servlets/__init__.py +++ b/src/matrix_content_scanner/servlets/__init__.py @@ -200,7 +200,8 @@ async def get_media_metadata_from_filebody( validate_encrypted_file_metadata(metadata) - # URL parameter is ignored. + # Unlike get_media_metadata_from_request, we intentionally skip extracting + # the file URL from the metadata because the caller already has the media content. return metadata From 62def34cf50b590986f6e5fe7fdee77e4e777d70 Mon Sep 17 00:00:00 2001 From: Joshua Fenster Date: Mon, 9 Mar 2026 16:34:21 +0100 Subject: [PATCH 6/6] downgraded lockfile --- poetry.lock | 64 ++++++++++++----------------------------------------- 1 file changed, 14 insertions(+), 50 deletions(-) diff --git a/poetry.lock b/poetry.lock index bb50b4a..0fd3b7c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiofile" @@ -6,7 +6,6 @@ version = "3.9.0" description = "Asynchronous file operations." optional = false python-versions = "<4,>=3.8" -groups = ["main"] files = [ {file = "aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa"}, {file = "aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b"}, @@ -21,7 +20,6 @@ version = "2.4.0" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, @@ -33,7 +31,6 @@ version = "3.10.5" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"}, {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"}, @@ -138,7 +135,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] [[package]] name = "aiosignal" @@ -146,7 +143,6 @@ version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, @@ -161,8 +157,6 @@ version = "4.0.3" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.7" -groups = ["main"] -markers = "python_version < \"3.11\"" files = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, @@ -174,19 +168,18 @@ version = "24.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] files = [ {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "cachetools" @@ -194,7 +187,6 @@ version = "5.5.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, @@ -206,7 +198,6 @@ version = "0.9.25" description = "Asynchronous file IO for Linux MacOS or Windows." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619"}, {file = "caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241"}, @@ -241,7 +232,6 @@ version = "2.0.0" description = "Canonical JSON" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "canonicaljson-2.0.0-py3-none-any.whl", hash = "sha256:c38a315de3b5a0532f1ec1f9153cd3d716abfc565a558d00a4835428a34fca5b"}, {file = "canonicaljson-2.0.0.tar.gz", hash = "sha256:e2fdaef1d7fadc5d9cb59bd3d0d41b064ddda697809ac4325dced721d12f113f"}, @@ -253,7 +243,6 @@ version = "1.4.1" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, @@ -340,7 +329,6 @@ version = "10.0" description = "Human friendly output for text interfaces using Python" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["main"] files = [ {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, @@ -355,7 +343,6 @@ version = "3.8" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, @@ -367,7 +354,6 @@ version = "4.23.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, @@ -375,7 +361,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -389,7 +375,6 @@ version = "2023.12.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, @@ -404,7 +389,6 @@ version = "6.0.5" description = "multidict implementation" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, @@ -504,7 +488,6 @@ version = "1.11.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, @@ -552,7 +535,6 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" -groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -564,8 +546,6 @@ version = "3.4.1" description = "A python implementation of GNU readline." optional = false python-versions = "*" -groups = ["main"] -markers = "sys_platform == \"win32\"" files = [ {file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"}, {file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"}, @@ -577,7 +557,6 @@ version = "0.4.27" description = "File type identification using libmagic" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["main"] files = [ {file = "python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b"}, {file = "python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3"}, @@ -589,7 +568,6 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -652,7 +630,6 @@ version = "0.35.1" description = "JSON Referencing + Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, @@ -668,7 +645,6 @@ version = "0.20.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, @@ -781,7 +757,6 @@ version = "0.7.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "ruff-0.7.2-py3-none-linux_armv6l.whl", hash = "sha256:b73f873b5f52092e63ed540adefc3c36f1f803790ecf2590e1df8bf0a9f72cb8"}, {file = "ruff-0.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5b813ef26db1015953daf476202585512afd6a6862a02cde63f3bafb53d0b2d4"}, @@ -809,14 +784,13 @@ version = "2.10.0" description = "A library implementing the 'SemVer' scheme." optional = false python-versions = ">=2.7" -groups = ["main"] files = [ {file = "semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177"}, {file = "semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c"}, ] [package.extras] -dev = ["Django (>=1.11)", "check-manifest", "colorama (<=0.4.1) ; python_version == \"3.4\"", "coverage", "flake8", "nose2", "readme-renderer (<25.0) ; python_version == \"3.4\"", "tox", "wheel", "zest.releaser[recommended]"] +dev = ["Django (>=1.11)", "check-manifest", "colorama (<=0.4.1)", "coverage", "flake8", "nose2", "readme-renderer (<25.0)", "tox", "wheel", "zest.releaser[recommended]"] doc = ["Sphinx", "sphinx-rtd-theme"] [[package]] @@ -825,20 +799,19 @@ version = "74.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "setuptools-74.0.0-py3-none-any.whl", hash = "sha256:0274581a0037b638b9fc1c6883cc71c0210865aaa76073f7882376b641b84e8f"}, {file = "setuptools-74.0.0.tar.gz", hash = "sha256:a85e96b8be2b906f3e3e789adec6a9323abf79758ecfa3065bd740d81158b11e"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.5.2) ; sys_platform != \"cygwin\""] -core = ["importlib-metadata (>=6) ; python_version < \"3.10\"", "importlib-resources (>=5.10.2) ; python_version < \"3.9\"", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.11.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] [[package]] name = "setuptools-rust" @@ -846,7 +819,6 @@ version = "1.10.1" description = "Setuptools Rust extension plugin" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "setuptools_rust-1.10.1-py3-none-any.whl", hash = "sha256:3837616cc0a7705b2c44058f626c97f774eeb980f28427c16ece562661bc20c5"}, {file = "setuptools_rust-1.10.1.tar.gz", hash = "sha256:d79035fc54cdf9342e9edf4b009491ecab06c3a652b37c3c137c7ba85547d3e6"}, @@ -862,8 +834,6 @@ version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -875,7 +845,6 @@ version = "5.5.0.20240820" description = "Typing stubs for cachetools" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "types-cachetools-5.5.0.20240820.tar.gz", hash = "sha256:b888ab5c1a48116f7799cd5004b18474cd82b5463acb5ffb2db2fc9c7b053bc0"}, {file = "types_cachetools-5.5.0.20240820-py3-none-any.whl", hash = "sha256:efb2ed8bf27a4b9d3ed70d33849f536362603a90b8090a328acf0cd42fda82e2"}, @@ -887,7 +856,6 @@ version = "10.0.1.11" description = "Typing stubs for humanfriendly" optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "types-humanfriendly-10.0.1.11.tar.gz", hash = "sha256:c87b2eb8ec2ab1ae51cb3cc2c0efee31622a6711b2a7627d8109e22b4cfd4366"}, {file = "types_humanfriendly-10.0.1.11-py3-none-any.whl", hash = "sha256:c0ca654b16ff36ed4c059152085a76c9909272f5ea6ac03cdb7e59b5dcd392c7"}, @@ -899,7 +867,6 @@ version = "4.23.0.20240813" description = "Typing stubs for jsonschema" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "types-jsonschema-4.23.0.20240813.tar.gz", hash = "sha256:c93f48206f209a5bc4608d295ac39f172fb98b9e24159ce577dbd25ddb79a1c0"}, {file = "types_jsonschema-4.23.0.20240813-py3-none-any.whl", hash = "sha256:be283e23f0b87547316c2ee6b0fd36d95ea30e921db06478029e10b5b6aa6ac3"}, @@ -914,7 +881,6 @@ version = "6.0.12.20240808" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "types-PyYAML-6.0.12.20240808.tar.gz", hash = "sha256:b8f76ddbd7f65440a8bda5526a9607e4c7a322dc2f8e1a8c405644f9a6f4b9af"}, {file = "types_PyYAML-6.0.12.20240808-py3-none-any.whl", hash = "sha256:deda34c5c655265fc517b546c902aa6eed2ef8d3e921e4765fe606fe2afe8d35"}, @@ -926,7 +892,6 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -938,7 +903,6 @@ version = "1.9.4" description = "Yet another URL library" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, @@ -1037,6 +1001,6 @@ idna = ">=2.0" multidict = ">=4.0" [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.10.0" content-hash = "2ed0bcdad855c181359b9c94295b6e18fe7e078247e74edc444e292f6633df43"