diff --git a/src/apify_client/clients/resource_clients/dataset.py b/src/apify_client/clients/resource_clients/dataset.py index 453833ca..7d2babff 100644 --- a/src/apify_client/clients/resource_clients/dataset.py +++ b/src/apify_client/clients/resource_clients/dataset.py @@ -4,9 +4,16 @@ import warnings from contextlib import asynccontextmanager, contextmanager from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode, urlparse, urlunparse + +from apify_shared.utils import create_storage_content_signature from apify_client._types import ListPage -from apify_client._utils import catch_not_found_or_throw, filter_out_none_values_recursively, pluck_data +from apify_client._utils import ( + catch_not_found_or_throw, + filter_out_none_values_recursively, + pluck_data, +) from apify_client.clients.base import ResourceClient, ResourceClientAsync from apify_client.errors import ApifyApiError @@ -558,6 +565,67 @@ def get_statistics(self) -> dict | None: return None + def create_items_public_url( + self, + *, + offset: int | None = None, + limit: int | None = None, + clean: bool | None = None, + desc: bool | None = None, + fields: list[str] | None = None, + omit: list[str] | None = None, + unwind: list[str] | None = None, + skip_empty: bool | None = None, + skip_hidden: bool | None = None, + flatten: list[str] | None = None, + view: str | None = None, + expires_in_secs: int | None = None, + ) -> str: + """Generate a URL that can be used to access dataset items. + + If the client has permission to access the dataset's URL signing key, + the URL will include a signature to verify its authenticity. + + You can optionally control how long the signed URL should be valid using the `expires_in_secs` option. + This value sets the expiration duration in seconds from the time the URL is generated. + If not provided, the URL will not expire. + + Any other options (like `limit` or `offset`) will be included as query parameters in the URL. + + Returns: + The public dataset items URL. + """ + dataset = self.get() + + request_params = self._params( + offset=offset, + limit=limit, + desc=desc, + clean=clean, + fields=fields, + omit=omit, + unwind=unwind, + skipEmpty=skip_empty, + skipHidden=skip_hidden, + flatten=flatten, + view=view, + ) + + if dataset and 'urlSigningSecretKey' in dataset: + signature = create_storage_content_signature( + resource_id=dataset['id'], + url_signing_secret_key=dataset['urlSigningSecretKey'], + expires_in_millis=expires_in_secs * 1000 if expires_in_secs is not None else None, + ) + request_params['signature'] = signature + + items_public_url = urlparse(self._url('items')) + filtered_params = {k: v for k, v in request_params.items() if v is not None} + if filtered_params: + items_public_url = items_public_url._replace(query=urlencode(filtered_params)) + + return urlunparse(items_public_url) + class DatasetClientAsync(ResourceClientAsync): """Async sub-client for manipulating a single dataset.""" @@ -1003,3 +1071,64 @@ async def get_statistics(self) -> dict | None: catch_not_found_or_throw(exc) return None + + async def create_items_public_url( + self, + *, + offset: int | None = None, + limit: int | None = None, + clean: bool | None = None, + desc: bool | None = None, + fields: list[str] | None = None, + omit: list[str] | None = None, + unwind: list[str] | None = None, + skip_empty: bool | None = None, + skip_hidden: bool | None = None, + flatten: list[str] | None = None, + view: str | None = None, + expires_in_secs: int | None = None, + ) -> str: + """Generate a URL that can be used to access dataset items. + + If the client has permission to access the dataset's URL signing key, + the URL will include a signature to verify its authenticity. + + You can optionally control how long the signed URL should be valid using the `expires_in_secs` option. + This value sets the expiration duration in seconds from the time the URL is generated. + If not provided, the URL will not expire. + + Any other options (like `limit` or `offset`) will be included as query parameters in the URL. + + Returns: + The public dataset items URL. + """ + dataset = await self.get() + + request_params = self._params( + offset=offset, + limit=limit, + desc=desc, + clean=clean, + fields=fields, + omit=omit, + unwind=unwind, + skipEmpty=skip_empty, + skipHidden=skip_hidden, + flatten=flatten, + view=view, + ) + + if dataset and 'urlSigningSecretKey' in dataset: + signature = create_storage_content_signature( + resource_id=dataset['id'], + url_signing_secret_key=dataset['urlSigningSecretKey'], + expires_in_millis=expires_in_secs * 1000 if expires_in_secs is not None else None, + ) + request_params['signature'] = signature + + items_public_url = urlparse(self._url('items')) + filtered_params = {k: v for k, v in request_params.items() if v is not None} + if filtered_params: + items_public_url = items_public_url._replace(query=urlencode(filtered_params)) + + return urlunparse(items_public_url) diff --git a/src/apify_client/clients/resource_clients/key_value_store.py b/src/apify_client/clients/resource_clients/key_value_store.py index 731e9349..2447cf87 100644 --- a/src/apify_client/clients/resource_clients/key_value_store.py +++ b/src/apify_client/clients/resource_clients/key_value_store.py @@ -4,6 +4,9 @@ from contextlib import asynccontextmanager, contextmanager from http import HTTPStatus from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode, urlparse, urlunparse + +from apify_shared.utils import create_storage_content_signature from apify_client._utils import ( catch_not_found_or_throw, @@ -264,6 +267,54 @@ def delete_record(self, key: str) -> None: timeout_secs=_SMALL_TIMEOUT, ) + def create_keys_public_url( + self, + *, + limit: int | None = None, + exclusive_start_key: str | None = None, + collection: str | None = None, + prefix: str | None = None, + expires_in_secs: int | None = None, + ) -> str: + """Generate a URL that can be used to access key-value store keys. + + If the client has permission to access the key-value store's URL signing key, + the URL will include a signature to verify its authenticity. + + You can optionally control how long the signed URL should be valid using the `expires_in_secs` option. + This value sets the expiration duration in seconds from the time the URL is generated. + If not provided, the URL will not expire. + + Any other options (like `limit` or `prefix`) will be included as query parameters in the URL. + + Returns: + The public key-value store keys URL. + """ + store = self.get() + + request_params = self._params( + limit=limit, + exclusive_start_key=exclusive_start_key, + collection=collection, + prefix=prefix, + ) + + if store and 'urlSigningSecretKey' in store: + signature = create_storage_content_signature( + resource_id=store['id'], + url_signing_secret_key=store['urlSigningSecretKey'], + expires_in_millis=expires_in_secs * 1000 if expires_in_secs is not None else None, + ) + request_params['signature'] = signature + + keys_public_url = urlparse(self._url('keys')) + + filtered_params = {k: v for k, v in request_params.items() if v is not None} + if filtered_params: + keys_public_url = keys_public_url._replace(query=urlencode(filtered_params)) + + return urlunparse(keys_public_url) + class KeyValueStoreClientAsync(ResourceClientAsync): """Async sub-client for manipulating a single key-value store.""" @@ -503,3 +554,52 @@ async def delete_record(self, key: str) -> None: params=self._params(), timeout_secs=_SMALL_TIMEOUT, ) + + async def create_keys_public_url( + self, + *, + limit: int | None = None, + exclusive_start_key: str | None = None, + collection: str | None = None, + prefix: str | None = None, + expires_in_secs: int | None = None, + ) -> str: + """Generate a URL that can be used to access key-value store keys. + + If the client has permission to access the key-value store's URL signing key, + the URL will include a signature to verify its authenticity. + + You can optionally control how long the signed URL should be valid using the `expires_in_secs` option. + This value sets the expiration duration in seconds from the time the URL is generated. + If not provided, the URL will not expire. + + Any other options (like `limit` or `prefix`) will be included as query parameters in the URL. + + Returns: + The public key-value store keys URL. + """ + store = await self.get() + + keys_public_url = urlparse(self._url('keys')) + + request_params = self._params( + limit=limit, + exclusive_start_key=exclusive_start_key, + collection=collection, + prefix=prefix, + ) + + if store and 'urlSigningSecretKey' in store: + signature = create_storage_content_signature( + resource_id=store['id'], + url_signing_secret_key=store['urlSigningSecretKey'], + expires_in_millis=expires_in_secs * 1000 if expires_in_secs is not None else None, + ) + request_params['signature'] = signature + + keys_public_url = urlparse(self._url('keys')) + filtered_params = {k: v for k, v in request_params.items() if v is not None} + if filtered_params: + keys_public_url = keys_public_url._replace(query=urlencode(filtered_params)) + + return urlunparse(keys_public_url) diff --git a/tests/integration/integration_test_utils.py b/tests/integration/integration_test_utils.py new file mode 100644 index 00000000..8e8494e8 --- /dev/null +++ b/tests/integration/integration_test_utils.py @@ -0,0 +1,10 @@ +import secrets +import string + + +def random_string(length: int = 10) -> str: + return ''.join(secrets.choice(string.ascii_letters) for _ in range(length)) + + +def random_resource_name(resource: str) -> str: + return f'python-client-test-{resource}-{random_string(5)}' diff --git a/tests/integration/test_dataset.py b/tests/integration/test_dataset.py new file mode 100644 index 00000000..103f3d00 --- /dev/null +++ b/tests/integration/test_dataset.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import impit + +from integration.integration_test_utils import random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClient, ApifyClientAsync + + +class TestDatasetSync: + def test_dataset_should_create_public_items_expiring_url_with_params(self, apify_client: ApifyClient) -> None: + created_dataset = apify_client.datasets().get_or_create(name=random_resource_name('dataset')) + + dataset = apify_client.dataset(created_dataset['id']) + items_public_url = dataset.create_items_public_url( + expires_in_secs=2000, + limit=10, + offset=0, + ) + + assert 'signature=' in items_public_url + assert 'limit=10' in items_public_url + assert 'offset=0' in items_public_url + + impit_client = impit.Client() + response = impit_client.get(items_public_url, timeout=5) + assert response.status_code == 200 + + dataset.delete() + assert apify_client.dataset(created_dataset['id']).get() is None + + def test_dataset_should_create_public_items_non_expiring_url(self, apify_client: ApifyClient) -> None: + created_dataset = apify_client.datasets().get_or_create(name=random_resource_name('dataset')) + + dataset = apify_client.dataset(created_dataset['id']) + items_public_url = dataset.create_items_public_url() + + assert 'signature=' in items_public_url + + impit_client = impit.Client() + response = impit_client.get(items_public_url, timeout=5) + assert response.status_code == 200 + + dataset.delete() + assert apify_client.dataset(created_dataset['id']).get() is None + + +class TestDatasetAsync: + async def test_dataset_should_create_public_items_expiring_url_with_params( + self, apify_client_async: ApifyClientAsync + ) -> None: + created_dataset = await apify_client_async.datasets().get_or_create(name=random_resource_name('dataset')) + + dataset = apify_client_async.dataset(created_dataset['id']) + items_public_url = await dataset.create_items_public_url( + expires_in_secs=2000, + limit=10, + offset=0, + ) + + assert 'signature=' in items_public_url + assert 'limit=10' in items_public_url + assert 'offset=0' in items_public_url + + impit_async_client = impit.AsyncClient() + response = await impit_async_client.get(items_public_url, timeout=5) + assert response.status_code == 200 + + await dataset.delete() + assert await apify_client_async.dataset(created_dataset['id']).get() is None + + async def test_dataset_should_create_public_items_non_expiring_url( + self, apify_client_async: ApifyClientAsync + ) -> None: + created_dataset = await apify_client_async.datasets().get_or_create(name=random_resource_name('dataset')) + + dataset = apify_client_async.dataset(created_dataset['id']) + items_public_url = await dataset.create_items_public_url() + + assert 'signature=' in items_public_url + + impit_async_client = impit.AsyncClient() + response = await impit_async_client.get(items_public_url, timeout=5) + assert response.status_code == 200 + + await dataset.delete() + assert await apify_client_async.dataset(created_dataset['id']).get() is None diff --git a/tests/integration/test_key_value_store.py b/tests/integration/test_key_value_store.py new file mode 100644 index 00000000..38983835 --- /dev/null +++ b/tests/integration/test_key_value_store.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import impit + +from integration.integration_test_utils import random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClient, ApifyClientAsync + + +class TestKeyValueStoreSync: + def test_key_value_store_should_create_expiring_keys_public_url_with_params( + self, apify_client: ApifyClient + ) -> None: + created_store = apify_client.key_value_stores().get_or_create(name=random_resource_name('key-value-store')) + + store = apify_client.key_value_store(created_store['id']) + keys_public_url = store.create_keys_public_url( + expires_in_secs=2000, + limit=10, + ) + + assert 'signature=' in keys_public_url + assert 'limit=10' in keys_public_url + + impit_client = impit.Client() + response = impit_client.get(keys_public_url, timeout=5) + assert response.status_code == 200 + + store.delete() + assert apify_client.key_value_store(created_store['id']).get() is None + + def test_key_value_store_should_create_public_keys_non_expiring_url(self, apify_client: ApifyClient) -> None: + created_store = apify_client.key_value_stores().get_or_create(name=random_resource_name('key-value-store')) + + store = apify_client.key_value_store(created_store['id']) + keys_public_url = store.create_keys_public_url() + + assert 'signature=' in keys_public_url + + impit_client = impit.Client() + response = impit_client.get(keys_public_url, timeout=5) + assert response.status_code == 200 + + store.delete() + assert apify_client.key_value_store(created_store['id']).get() is None + + +class TestKeyValueStoreAsync: + async def test_key_value_store_should_create_expiring_keys_public_url_with_params( + self, apify_client_async: ApifyClientAsync + ) -> None: + created_store = await apify_client_async.key_value_stores().get_or_create( + name=random_resource_name('key-value-store') + ) + + store = apify_client_async.key_value_store(created_store['id']) + keys_public_url = await store.create_keys_public_url( + expires_in_secs=2000, + limit=10, + ) + + assert 'signature=' in keys_public_url + assert 'limit=10' in keys_public_url + + impit_async_client = impit.AsyncClient() + response = await impit_async_client.get(keys_public_url, timeout=5) + assert response.status_code == 200 + + await store.delete() + assert await apify_client_async.key_value_store(created_store['id']).get() is None + + async def test_key_value_store_should_create_public_keys_non_expiring_url( + self, apify_client_async: ApifyClientAsync + ) -> None: + created_store = await apify_client_async.key_value_stores().get_or_create( + name=random_resource_name('key-value-store') + ) + + store = apify_client_async.key_value_store(created_store['id']) + keys_public_url = await store.create_keys_public_url() + + assert 'signature=' in keys_public_url + + impit_async_client = impit.AsyncClient() + response = await impit_async_client.get(keys_public_url, timeout=5) + assert response.status_code == 200 + + await store.delete() + assert await apify_client_async.key_value_store(created_store['id']).get() is None diff --git a/tests/integration/test_request_queue.py b/tests/integration/test_request_queue.py index a004d4ea..64759e47 100644 --- a/tests/integration/test_request_queue.py +++ b/tests/integration/test_request_queue.py @@ -1,24 +1,16 @@ from __future__ import annotations -import secrets -import string from typing import TYPE_CHECKING +from integration.integration_test_utils import random_resource_name, random_string + if TYPE_CHECKING: from apify_client import ApifyClient, ApifyClientAsync -def random_string(length: int = 10) -> str: - return ''.join(secrets.choice(string.ascii_letters) for _ in range(length)) - - -def random_queue_name() -> str: - return f'python-client-test-queue-{random_string(5)}' - - class TestRequestQueueSync: def test_request_queue_lock(self, apify_client: ApifyClient) -> None: - created_queue = apify_client.request_queues().get_or_create(name=random_queue_name()) + created_queue = apify_client.request_queues().get_or_create(name=random_resource_name('queue')) queue = apify_client.request_queue(created_queue['id'], client_key=random_string(10)) # Add requests and check if correct number of requests was locked @@ -46,7 +38,7 @@ def test_request_queue_lock(self, apify_client: ApifyClient) -> None: assert apify_client.request_queue(created_queue['id']).get() is None def test_request_batch_operations(self, apify_client: ApifyClient) -> None: - created_queue = apify_client.request_queues().get_or_create(name=random_queue_name()) + created_queue = apify_client.request_queues().get_or_create(name=random_resource_name('queue')) queue = apify_client.request_queue(created_queue['id']) # Add requests to queue and check if they were added @@ -71,7 +63,7 @@ def test_request_batch_operations(self, apify_client: ApifyClient) -> None: class TestRequestQueueAsync: async def test_request_queue_lock(self, apify_client_async: ApifyClientAsync) -> None: - created_queue = await apify_client_async.request_queues().get_or_create(name=random_queue_name()) + created_queue = await apify_client_async.request_queues().get_or_create(name=random_resource_name('queue')) queue = apify_client_async.request_queue(created_queue['id'], client_key=random_string(10)) # Add requests and check if correct number of requests was locked @@ -100,7 +92,7 @@ async def test_request_queue_lock(self, apify_client_async: ApifyClientAsync) -> assert await apify_client_async.request_queue(created_queue['id']).get() is None async def test_request_batch_operations(self, apify_client_async: ApifyClientAsync) -> None: - created_queue = await apify_client_async.request_queues().get_or_create(name=random_queue_name()) + created_queue = await apify_client_async.request_queues().get_or_create(name=random_resource_name('queue')) queue = apify_client_async.request_queue(created_queue['id']) # Add requests to queue and check if they were added diff --git a/uv.lock b/uv.lock index a38b2b67..7bc65d74 100644 --- a/uv.lock +++ b/uv.lock @@ -453,11 +453,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.18.0" +version = "3.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] [[package]] @@ -966,27 +966,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.12.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/da/5bd7565be729e86e1442dad2c9a364ceeff82227c2dece7c29697a9795eb/ruff-0.12.8.tar.gz", hash = "sha256:4cb3a45525176e1009b2b64126acf5f9444ea59066262791febf55e40493a033", size = 5242373, upload-time = "2025-08-07T19:05:47.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/1e/c843bfa8ad1114fab3eb2b78235dda76acd66384c663a4e0415ecc13aa1e/ruff-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:63cb5a5e933fc913e5823a0dfdc3c99add73f52d139d6cd5cc8639d0e0465513", size = 11675315, upload-time = "2025-08-07T19:05:06.15Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/af6e5c2a8ca3a81676d5480a1025494fd104b8896266502bb4de2a0e8388/ruff-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a9bbe28f9f551accf84a24c366c1aa8774d6748438b47174f8e8565ab9dedbc", size = 12456653, upload-time = "2025-08-07T19:05:09.759Z" }, - { url = "https://files.pythonhosted.org/packages/99/9d/e91f84dfe3866fa648c10512904991ecc326fd0b66578b324ee6ecb8f725/ruff-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2fae54e752a3150f7ee0e09bce2e133caf10ce9d971510a9b925392dc98d2fec", size = 11659690, upload-time = "2025-08-07T19:05:12.551Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ac/a363d25ec53040408ebdd4efcee929d48547665858ede0505d1d8041b2e5/ruff-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0acbcf01206df963d9331b5838fb31f3b44fa979ee7fa368b9b9057d89f4a53", size = 11896923, upload-time = "2025-08-07T19:05:14.821Z" }, - { url = "https://files.pythonhosted.org/packages/58/9f/ea356cd87c395f6ade9bb81365bd909ff60860975ca1bc39f0e59de3da37/ruff-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae3e7504666ad4c62f9ac8eedb52a93f9ebdeb34742b8b71cd3cccd24912719f", size = 11477612, upload-time = "2025-08-07T19:05:16.712Z" }, - { url = "https://files.pythonhosted.org/packages/1a/46/92e8fa3c9dcfd49175225c09053916cb97bb7204f9f899c2f2baca69e450/ruff-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb82efb5d35d07497813a1c5647867390a7d83304562607f3579602fa3d7d46f", size = 13182745, upload-time = "2025-08-07T19:05:18.709Z" }, - { url = "https://files.pythonhosted.org/packages/5e/c4/f2176a310f26e6160deaf661ef60db6c3bb62b7a35e57ae28f27a09a7d63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dbea798fc0065ad0b84a2947b0aff4233f0cb30f226f00a2c5850ca4393de609", size = 14206885, upload-time = "2025-08-07T19:05:21.025Z" }, - { url = "https://files.pythonhosted.org/packages/87/9d/98e162f3eeeb6689acbedbae5050b4b3220754554526c50c292b611d3a63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49ebcaccc2bdad86fd51b7864e3d808aad404aab8df33d469b6e65584656263a", size = 13639381, upload-time = "2025-08-07T19:05:23.423Z" }, - { url = "https://files.pythonhosted.org/packages/81/4e/1b7478b072fcde5161b48f64774d6edd59d6d198e4ba8918d9f4702b8043/ruff-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ac9c570634b98c71c88cb17badd90f13fc076a472ba6ef1d113d8ed3df109fb", size = 12613271, upload-time = "2025-08-07T19:05:25.507Z" }, - { url = "https://files.pythonhosted.org/packages/e8/67/0c3c9179a3ad19791ef1b8f7138aa27d4578c78700551c60d9260b2c660d/ruff-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560e0cd641e45591a3e42cb50ef61ce07162b9c233786663fdce2d8557d99818", size = 12847783, upload-time = "2025-08-07T19:05:28.14Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2a/0b6ac3dd045acf8aa229b12c9c17bb35508191b71a14904baf99573a21bd/ruff-0.12.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:71c83121512e7743fba5a8848c261dcc454cafb3ef2934a43f1b7a4eb5a447ea", size = 11702672, upload-time = "2025-08-07T19:05:30.413Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ee/f9fdc9f341b0430110de8b39a6ee5fa68c5706dc7c0aa940817947d6937e/ruff-0.12.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:de4429ef2ba091ecddedd300f4c3f24bca875d3d8b23340728c3cb0da81072c3", size = 11440626, upload-time = "2025-08-07T19:05:32.492Z" }, - { url = "https://files.pythonhosted.org/packages/89/fb/b3aa2d482d05f44e4d197d1de5e3863feb13067b22c571b9561085c999dc/ruff-0.12.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a2cab5f60d5b65b50fba39a8950c8746df1627d54ba1197f970763917184b161", size = 12462162, upload-time = "2025-08-07T19:05:34.449Z" }, - { url = "https://files.pythonhosted.org/packages/18/9f/5c5d93e1d00d854d5013c96e1a92c33b703a0332707a7cdbd0a4880a84fb/ruff-0.12.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:45c32487e14f60b88aad6be9fd5da5093dbefb0e3e1224131cb1d441d7cb7d46", size = 12913212, upload-time = "2025-08-07T19:05:36.541Z" }, - { url = "https://files.pythonhosted.org/packages/71/13/ab9120add1c0e4604c71bfc2e4ef7d63bebece0cfe617013da289539cef8/ruff-0.12.8-py3-none-win32.whl", hash = "sha256:daf3475060a617fd5bc80638aeaf2f5937f10af3ec44464e280a9d2218e720d3", size = 11694382, upload-time = "2025-08-07T19:05:38.468Z" }, - { url = "https://files.pythonhosted.org/packages/f6/dc/a2873b7c5001c62f46266685863bee2888caf469d1edac84bf3242074be2/ruff-0.12.8-py3-none-win_amd64.whl", hash = "sha256:7209531f1a1fcfbe8e46bcd7ab30e2f43604d8ba1c49029bb420b103d0b5f76e", size = 12740482, upload-time = "2025-08-07T19:05:40.391Z" }, - { url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718, upload-time = "2025-08-07T19:05:42.866Z" }, +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/45/2e403fa7007816b5fbb324cb4f8ed3c7402a927a0a0cb2b6279879a8bfdc/ruff-0.12.9.tar.gz", hash = "sha256:fbd94b2e3c623f659962934e52c2bea6fc6da11f667a427a368adaf3af2c866a", size = 5254702, upload-time = "2025-08-14T16:08:55.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/20/53bf098537adb7b6a97d98fcdebf6e916fcd11b2e21d15f8c171507909cc/ruff-0.12.9-py3-none-linux_armv6l.whl", hash = "sha256:fcebc6c79fcae3f220d05585229463621f5dbf24d79fdc4936d9302e177cfa3e", size = 11759705, upload-time = "2025-08-14T16:08:12.968Z" }, + { url = "https://files.pythonhosted.org/packages/20/4d/c764ee423002aac1ec66b9d541285dd29d2c0640a8086c87de59ebbe80d5/ruff-0.12.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aed9d15f8c5755c0e74467731a007fcad41f19bcce41cd75f768bbd687f8535f", size = 12527042, upload-time = "2025-08-14T16:08:16.54Z" }, + { url = "https://files.pythonhosted.org/packages/8b/45/cfcdf6d3eb5fc78a5b419e7e616d6ccba0013dc5b180522920af2897e1be/ruff-0.12.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5b15ea354c6ff0d7423814ba6d44be2807644d0c05e9ed60caca87e963e93f70", size = 11724457, upload-time = "2025-08-14T16:08:18.686Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/44615c754b55662200c48bebb02196dbb14111b6e266ab071b7e7297b4ec/ruff-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d596c2d0393c2502eaabfef723bd74ca35348a8dac4267d18a94910087807c53", size = 11949446, upload-time = "2025-08-14T16:08:21.059Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d1/9b7d46625d617c7df520d40d5ac6cdcdf20cbccb88fad4b5ecd476a6bb8d/ruff-0.12.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b15599931a1a7a03c388b9c5df1bfa62be7ede6eb7ef753b272381f39c3d0ff", size = 11566350, upload-time = "2025-08-14T16:08:23.433Z" }, + { url = "https://files.pythonhosted.org/packages/59/20/b73132f66f2856bc29d2d263c6ca457f8476b0bbbe064dac3ac3337a270f/ruff-0.12.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d02faa2977fb6f3f32ddb7828e212b7dd499c59eb896ae6c03ea5c303575756", size = 13270430, upload-time = "2025-08-14T16:08:25.837Z" }, + { url = "https://files.pythonhosted.org/packages/a2/21/eaf3806f0a3d4c6be0a69d435646fba775b65f3f2097d54898b0fd4bb12e/ruff-0.12.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:17d5b6b0b3a25259b69ebcba87908496e6830e03acfb929ef9fd4c58675fa2ea", size = 14264717, upload-time = "2025-08-14T16:08:27.907Z" }, + { url = "https://files.pythonhosted.org/packages/d2/82/1d0c53bd37dcb582b2c521d352fbf4876b1e28bc0d8894344198f6c9950d/ruff-0.12.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72db7521860e246adbb43f6ef464dd2a532ef2ef1f5dd0d470455b8d9f1773e0", size = 13684331, upload-time = "2025-08-14T16:08:30.352Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2f/1c5cf6d8f656306d42a686f1e207f71d7cebdcbe7b2aa18e4e8a0cb74da3/ruff-0.12.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a03242c1522b4e0885af63320ad754d53983c9599157ee33e77d748363c561ce", size = 12739151, upload-time = "2025-08-14T16:08:32.55Z" }, + { url = "https://files.pythonhosted.org/packages/47/09/25033198bff89b24d734e6479e39b1968e4c992e82262d61cdccaf11afb9/ruff-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fc83e4e9751e6c13b5046d7162f205d0a7bac5840183c5beebf824b08a27340", size = 12954992, upload-time = "2025-08-14T16:08:34.816Z" }, + { url = "https://files.pythonhosted.org/packages/52/8e/d0dbf2f9dca66c2d7131feefc386523404014968cd6d22f057763935ab32/ruff-0.12.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:881465ed56ba4dd26a691954650de6ad389a2d1fdb130fe51ff18a25639fe4bb", size = 12899569, upload-time = "2025-08-14T16:08:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b614d7c08515b1428ed4d3f1d4e3d687deffb2479703b90237682586fa66/ruff-0.12.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:43f07a3ccfc62cdb4d3a3348bf0588358a66da756aa113e071b8ca8c3b9826af", size = 11751983, upload-time = "2025-08-14T16:08:39.314Z" }, + { url = "https://files.pythonhosted.org/packages/58/d6/383e9f818a2441b1a0ed898d7875f11273f10882f997388b2b51cb2ae8b5/ruff-0.12.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:07adb221c54b6bba24387911e5734357f042e5669fa5718920ee728aba3cbadc", size = 11538635, upload-time = "2025-08-14T16:08:41.297Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/56f869d314edaa9fc1f491706d1d8a47747b9d714130368fbd69ce9024e9/ruff-0.12.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f5cd34fabfdea3933ab85d72359f118035882a01bff15bd1d2b15261d85d5f66", size = 12534346, upload-time = "2025-08-14T16:08:43.39Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4b/d8b95c6795a6c93b439bc913ee7a94fda42bb30a79285d47b80074003ee7/ruff-0.12.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f6be1d2ca0686c54564da8e7ee9e25f93bdd6868263805f8c0b8fc6a449db6d7", size = 13017021, upload-time = "2025-08-14T16:08:45.889Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c1/5f9a839a697ce1acd7af44836f7c2181cdae5accd17a5cb85fcbd694075e/ruff-0.12.9-py3-none-win32.whl", hash = "sha256:cc7a37bd2509974379d0115cc5608a1a4a6c4bff1b452ea69db83c8855d53f93", size = 11734785, upload-time = "2025-08-14T16:08:48.062Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/cdddc2d1d9a9f677520b7cfc490d234336f523d4b429c1298de359a3be08/ruff-0.12.9-py3-none-win_amd64.whl", hash = "sha256:6fb15b1977309741d7d098c8a3cb7a30bc112760a00fb6efb7abc85f00ba5908", size = 12840654, upload-time = "2025-08-14T16:08:50.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fd/669816bc6b5b93b9586f3c1d87cd6bc05028470b3ecfebb5938252c47a35/ruff-0.12.9-py3-none-win_arm64.whl", hash = "sha256:63c8c819739d86b96d500cce885956a1a48ab056bbcbc61b747ad494b2485089", size = 11949623, upload-time = "2025-08-14T16:08:52.233Z" }, ] [[package]] @@ -1087,16 +1088,17 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.33.1" +version = "20.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/60/4f20960df6c7b363a18a55ab034c8f2bcd5d9770d1f94f9370ec104c1855/virtualenv-20.33.1.tar.gz", hash = "sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8", size = 6082160, upload-time = "2025-08-05T16:10:55.605Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/ff/ded57ac5ff40a09e6e198550bab075d780941e0b0f83cbeabd087c59383a/virtualenv-20.33.1-py3-none-any.whl", hash = "sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67", size = 6060362, upload-time = "2025-08-05T16:10:52.81Z" }, + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, ] [[package]]