From 53be5f3a3758730138332cc10445dd93486a7777 Mon Sep 17 00:00:00 2001 From: Daniel Perrefort Date: Tue, 10 Sep 2024 11:51:21 -0400 Subject: [PATCH 1/7] Update CI to dynamically determine repository owner (#55) --- .github/workflows/Publish.yml | 2 +- .github/workflows/Release.yml | 3 --- .github/workflows/Test.yml | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/Publish.yml b/.github/workflows/Publish.yml index 230a214..9152261 100644 --- a/.github/workflows/Publish.yml +++ b/.github/workflows/Publish.yml @@ -44,7 +44,7 @@ jobs: steps: - name: Update docs - uses: pitt-crc/keystone-docs/.github/actions/update-action/@main + uses: ${{ github.repository_owner }}/keystone-docs/.github/actions/update-action/@main with: keystone-gitops-id: ${{ secrets.KEYSTONE_GITOPS_ID }} keystone-gitops-pk: ${{ secrets.KEYSTONE_GITOPS_PK }} diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 767906f..68b816e 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -4,9 +4,6 @@ on: release: types: [ "released" ] -env: - IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/keystone-api - jobs: version: name: Get Release Version diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 9dce94a..14056d3 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -10,7 +10,7 @@ jobs: services: api: - image: ghcr.io/pitt-crc/keystone-api:latest + image: ghcr.io/better-hpc/keystone-api ports: - 8000:8000 From 4bcaebc4454f2e5855687e98427d392c1b6a06a9 Mon Sep 17 00:00:00 2001 From: Daniel Perrefort Date: Wed, 11 Sep 2024 13:14:07 -0400 Subject: [PATCH 2/7] Revert #55 (#57) --- .github/workflows/Publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Publish.yml b/.github/workflows/Publish.yml index 9152261..6e15bb6 100644 --- a/.github/workflows/Publish.yml +++ b/.github/workflows/Publish.yml @@ -44,7 +44,7 @@ jobs: steps: - name: Update docs - uses: ${{ github.repository_owner }}/keystone-docs/.github/actions/update-action/@main + uses: better-hpc/keystone-docs/.github/actions/update-action/@main with: keystone-gitops-id: ${{ secrets.KEYSTONE_GITOPS_ID }} keystone-gitops-pk: ${{ secrets.KEYSTONE_GITOPS_PK }} From d44263ebfeac0b989c6115b1e5ec0add7122b4a4 Mon Sep 17 00:00:00 2001 From: Daniel Perrefort Date: Tue, 1 Oct 2024 18:44:25 -0400 Subject: [PATCH 3/7] Migrate to session based auth (#56) --- docs/user_guide.md | 3 +- keystone_client/authentication.py | 166 ---------- keystone_client/client.py | 154 ++++----- keystone_client/schema.py | 24 +- tests/authentication/__init__.py | 0 .../test_authentication_manager.py | 298 ------------------ tests/authentication/test_jwt.py | 45 --- tests/client/test_HttpClient.py | 212 +++++++++++++ ...stone_client.py => test_KeystoneClient.py} | 3 +- tests/client/test_http_client.py | 136 -------- .../{test_endpoint.py => test_Endpoint.py} | 0 11 files changed, 297 insertions(+), 744 deletions(-) delete mode 100644 keystone_client/authentication.py delete mode 100644 tests/authentication/__init__.py delete mode 100644 tests/authentication/test_authentication_manager.py delete mode 100644 tests/authentication/test_jwt.py create mode 100644 tests/client/test_HttpClient.py rename tests/client/{test_keystone_client.py => test_KeystoneClient.py} (99%) delete mode 100644 tests/client/test_http_client.py rename tests/schema/{test_endpoint.py => test_Endpoint.py} (100%) diff --git a/docs/user_guide.md b/docs/user_guide.md index 9a676f7..5aa816b 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -13,8 +13,7 @@ client = KeystoneClient(url="http://localhost:8000") # (1)! 1. Specifying a network protocol is required when instantiating new instances (e.g., `http://` or `https://`). The `login` and `logout` methods are used to handle user authentication. -Once authenticated, the client will automatically manage the resulting user credentials, including refreshing JWT -tokens. +Once authenticated, the client will automatically manage the resulting user credentials. ```python client.login(username="username", password="password") # (1)! diff --git a/keystone_client/authentication.py b/keystone_client/authentication.py deleted file mode 100644 index adb5121..0000000 --- a/keystone_client/authentication.py +++ /dev/null @@ -1,166 +0,0 @@ -"""User authentication and credential management.""" - -from __future__ import annotations - -from datetime import datetime - -import jwt -import requests - -from keystone_client.schema import Schema - - -class JWT: - """JSON Web Token.""" - - def __init__(self, access: str, refresh: str, algorithm: str = 'HS256') -> None: - """Initialize a new pair of JWT tokens. - - Args: - access: The access token. - refresh: The refresh token. - algorithm: The algorithm used for encoding the JWT. - """ - - self.algorithm = algorithm - self.access = access - self.refresh = refresh - - def _date_from_token(self, token: str) -> datetime: - """Return a token's expiration datetime.""" - - token_data = jwt.decode(token, options={"verify_signature": False}, algorithms=self.algorithm) - exp = datetime.fromtimestamp(token_data["exp"]) - return exp - - @property - def access_expiration(self) -> datetime: - """Return the expiration datetime of the JWT access token.""" - - return self._date_from_token(self.access) - - @property - def refresh_expiration(self) -> datetime: - """Return the expiration datetime of the JWT refresh token.""" - - return self._date_from_token(self.refresh) - - -class AuthenticationManager: - """User authentication and JWT token manager.""" - - def __init__(self, url: str, schema: Schema = Schema()) -> None: - """Initialize the class. - - Args: - url: Base URL for the authentication API. - schema: Schema defining API endpoints for fetching/managing JWTs. - """ - - self.jwt: JWT | None = None - self.auth_url: str = schema.auth.new.join_url(url) - self.refresh_url: str = schema.auth.refresh.join_url(url) - self.blacklist_url: str = schema.auth.blacklist.join_url(url) - - def is_authenticated(self) -> bool: - """Return whether the client instance has active credentials.""" - - if self.jwt is None: - return False - - now = datetime.now() - access_token_valid = self.jwt.access_expiration > now - access_token_refreshable = self.jwt.refresh_expiration > now - return access_token_valid or access_token_refreshable - - def get_auth_headers(self, auto_refresh: bool = True, timeout: int = None) -> dict[str, str]: - """Return headers data for authenticating API requests. - - The returned dictionary is empty when not authenticated. - - Args: - auto_refresh: Automatically refresh the JWT credentials if necessary. - timeout: Seconds before the token refresh request times out. - - Returns: - A dictionary with header ata for JWT authentication. - """ - - if auto_refresh: - self.refresh(timeout=timeout) - - if not self.is_authenticated(): - return dict() - - return {"Authorization": f"Bearer {self.jwt.access}"} - - def login(self, username: str, password: str, timeout: int = None) -> None: - """Log in to the Keystone API and cache the returned credentials. - - Args: - username: The authentication username. - password: The authentication password. - timeout: Seconds before the request times out. - - Raises: - requests.HTTPError: If the login request fails. - """ - - response = requests.post( - self.auth_url, - json={"username": username, "password": password}, - timeout=timeout - ) - - response.raise_for_status() - response_data = response.json() - self.jwt = JWT(response_data.get("access"), response_data.get("refresh")) - - def logout(self, timeout: int = None) -> None: - """Log out of the current session and blacklist any current credentials. - - Args: - timeout: Seconds before the request times out. - """ - - # Tell the API to blacklist the current token - if self.jwt is not None: - requests.post( - self.blacklist_url, - data={"refresh": self.jwt.refresh}, - timeout=timeout - ).raise_for_status() - - self.jwt = None - - def refresh(self, force: bool = False, timeout: int = None) -> None: - """Refresh the current session credetials if necessary. - - This method will do nothing and exit silently if the current session - has not been authenticated. - - Args: - timeout: Seconds before the request times out. - force: Refresh the access token even if it has not expired yet. - """ - - if self.jwt is None: - return - - # Don't refresh the token if it's not necessary - now = datetime.now() - if self.jwt.access_expiration > now and not force: - return - - # Alert the user when a refresh is not possible - if self.jwt.refresh_expiration < now: - raise RuntimeError("Refresh token has expired. Login again to continue.") - - response = requests.post( - self.refresh_url, - data={"refresh": self.jwt.refresh}, - timeout=timeout - ) - - response.raise_for_status() - self.jwt.refresh = response.json().get("refresh") diff --git a/keystone_client/client.py b/keystone_client/client.py index 9a9bf4e..951eca5 100644 --- a/keystone_client/client.py +++ b/keystone_client/client.py @@ -12,12 +12,11 @@ from urllib.parse import urljoin import requests +from requests import HTTPError, Session -from keystone_client.authentication import AuthenticationManager from keystone_client.schema import Endpoint, Schema DEFAULT_TIMEOUT = 15 -HTTPMethod = Literal["get", "post", "put", "patch", "delete"] class HTTPClient: @@ -29,11 +28,11 @@ def __init__(self, url: str) -> None: """Initialize the class. Args: - url: The base URL for a running Keystone API server. + url: The base URL for a Keystone API server. """ self._url = url.rstrip('/') + '/' - self._auth = AuthenticationManager(url, self.schema) + self._session = Session() @property def url(self) -> str: @@ -53,42 +52,74 @@ def login(self, username: str, password: str, timeout: int = DEFAULT_TIMEOUT) -> requests.HTTPError: If the login request fails. """ - self._auth.login(username, password, timeout) # pragma: nocover + # Prevent HTTP errors raised when authenticating an existing session + login_url = self.schema.login.join_url(self.url) + response = self._session.post(login_url, json={'username': username, 'password': password}, timeout=timeout) - def logout(self, raise_blacklist: bool = False, timeout: int = DEFAULT_TIMEOUT) -> None: - """Clear current credentials and blacklist any active credentials. + try: + response.raise_for_status() + + except HTTPError: + if not self.is_authenticated(timeout=timeout): + raise + + def logout(self, timeout: int = DEFAULT_TIMEOUT) -> None: + """Logout the current user session. Args: - raise_blacklist: Optionally raise an exception if the blacklist request fails. timeout: Seconds before the blacklist request times out. """ - try: - self._auth.logout(timeout) # pragma: nocover + logout_url = self.schema.logout.join_url(self.url) + response = self.http_post(logout_url, timeout=timeout) + response.raise_for_status() - except requests.HTTPError: - if raise_blacklist: - raise + def is_authenticated(self, timeout: int = DEFAULT_TIMEOUT) -> bool: + """Query the server for the current session's authentication status. + + Args: + timeout: Seconds before the blacklist request times out. + """ + + response = self._session.get(f'{self.url}/authentication/whoami/', timeout=timeout) + if response.status_code == 401: + return False + + response.raise_for_status() + return response.status_code == 200 + + def _csrf_headers(self) -> dict: + """Return the CSRF headers for the current session""" - def is_authenticated(self) -> bool: - """Return whether the client instance has active credentials.""" + headers = dict() + if csrf_token := self._session.cookies.get('csrftoken'): + headers['X-CSRFToken'] = csrf_token - return self._auth.is_authenticated() # pragma: nocover + return headers - def _send_request(self, method: HTTPMethod, endpoint: str, **kwargs) -> requests.Response: + def _send_request( + self, + method: Literal["get", "post", "put", "patch", "delete"], + endpoint: str, + **kwargs + ) -> requests.Response: """Send an HTTP request. Args: method: The HTTP method to use. + data: JSON data to include in the POST request. endpoint: The complete url to send the request to. + params: Query parameters to include in the request. timeout: Seconds before the request times out. Returns: An HTTP response. """ + headers = self._csrf_headers() url = urljoin(self.url, endpoint) - response = requests.request(method, url, **kwargs) + + response = self._session.request(method=method, url=url, headers=headers, **kwargs) response.raise_for_status() return response @@ -112,13 +143,7 @@ def http_get( requests.HTTPError: If the request returns an error code. """ - return self._send_request( - "get", - endpoint, - params=params, - headers=self._auth.get_auth_headers(), - timeout=timeout - ) + return self._send_request("get", endpoint, params=params, timeout=timeout) def http_post( self, @@ -140,13 +165,7 @@ def http_post( requests.HTTPError: If the request returns an error code. """ - return self._send_request( - "post", - endpoint, - data=data, - headers=self._auth.get_auth_headers(), - timeout=timeout - ) + return self._send_request("post", endpoint, data=data, timeout=timeout) def http_patch( self, @@ -168,13 +187,7 @@ def http_patch( requests.HTTPError: If the request returns an error code. """ - return self._send_request( - "patch", - endpoint, - data=data, - headers=self._auth.get_auth_headers(), - timeout=timeout - ) + return self._send_request("patch", endpoint, data=data, timeout=timeout) def http_put( self, @@ -196,13 +209,7 @@ def http_put( requests.HTTPError: If the request returns an error code. """ - return self._send_request( - "put", - endpoint, - data=data, - headers=self._auth.get_auth_headers(), - timeout=timeout - ) + return self._send_request("put", endpoint, data=data, timeout=timeout) def http_delete( self, @@ -222,12 +229,7 @@ def http_delete( requests.HTTPError: If the request returns an error code. """ - return self._send_request( - "delete", - endpoint, - headers=self._auth.get_auth_headers(), - timeout=timeout - ) + return self._send_request("delete", endpoint, timeout=timeout) class KeystoneClient(HTTPClient): @@ -246,30 +248,30 @@ def __new__(cls, *args, **kwargs) -> KeystoneClient: new: KeystoneClient = super().__new__(cls) - new.create_allocation = new._create_factory(cls.schema.data.allocations) - new.retrieve_allocation = new._retrieve_factory(cls.schema.data.allocations) - new.update_allocation = new._update_factory(cls.schema.data.allocations) - new.delete_allocation = new._delete_factory(cls.schema.data.allocations) - - new.create_cluster = new._create_factory(cls.schema.data.clusters) - new.retrieve_cluster = new._retrieve_factory(cls.schema.data.clusters) - new.update_cluster = new._update_factory(cls.schema.data.clusters) - new.delete_cluster = new._delete_factory(cls.schema.data.clusters) - - new.create_request = new._create_factory(cls.schema.data.requests) - new.retrieve_request = new._retrieve_factory(cls.schema.data.requests) - new.update_request = new._update_factory(cls.schema.data.requests) - new.delete_request = new._delete_factory(cls.schema.data.requests) - - new.create_research_group = new._create_factory(cls.schema.data.research_groups) - new.retrieve_research_group = new._retrieve_factory(cls.schema.data.research_groups) - new.update_research_group = new._update_factory(cls.schema.data.research_groups) - new.delete_research_group = new._delete_factory(cls.schema.data.research_groups) - - new.create_user = new._create_factory(cls.schema.data.users) - new.retrieve_user = new._retrieve_factory(cls.schema.data.users) - new.update_user = new._update_factory(cls.schema.data.users) - new.delete_user = new._delete_factory(cls.schema.data.users) + new.create_allocation = new._create_factory(cls.schema.allocations) + new.retrieve_allocation = new._retrieve_factory(cls.schema.allocations) + new.update_allocation = new._update_factory(cls.schema.allocations) + new.delete_allocation = new._delete_factory(cls.schema.allocations) + + new.create_cluster = new._create_factory(cls.schema.clusters) + new.retrieve_cluster = new._retrieve_factory(cls.schema.clusters) + new.update_cluster = new._update_factory(cls.schema.clusters) + new.delete_cluster = new._delete_factory(cls.schema.clusters) + + new.create_request = new._create_factory(cls.schema.requests) + new.retrieve_request = new._retrieve_factory(cls.schema.requests) + new.update_request = new._update_factory(cls.schema.requests) + new.delete_request = new._delete_factory(cls.schema.requests) + + new.create_research_group = new._create_factory(cls.schema.research_groups) + new.retrieve_research_group = new._retrieve_factory(cls.schema.research_groups) + new.update_research_group = new._update_factory(cls.schema.research_groups) + new.delete_research_group = new._delete_factory(cls.schema.research_groups) + + new.create_user = new._create_factory(cls.schema.users) + new.retrieve_user = new._retrieve_factory(cls.schema.users) + new.update_user = new._update_factory(cls.schema.users) + new.delete_user = new._delete_factory(cls.schema.users) return new diff --git a/keystone_client/schema.py b/keystone_client/schema.py index cb75bef..a316f2c 100644 --- a/keystone_client/schema.py +++ b/keystone_client/schema.py @@ -1,6 +1,6 @@ """Schema objects used to define available API endpoints.""" -from dataclasses import dataclass, field +from dataclasses import dataclass from os import path @@ -29,28 +29,14 @@ def join_url(self, base: str, *append) -> str: @dataclass -class AuthSchema: - """Schema defining API endpoints used for JWT authentication.""" - - new: Endpoint = Endpoint("authentication/new") - refresh: Endpoint = Endpoint("authentication/refresh") - blacklist: Endpoint = Endpoint("authentication/blacklist") - +class Schema: + """Schema defining the complete set of API endpoints.""" -@dataclass -class DataSchema: - """Schema defining API endpoints for data access.""" + login = Endpoint('authentication/login') + logout = Endpoint('authentication/logout') allocations: Endpoint = Endpoint("allocations/allocations") clusters: Endpoint = Endpoint("allocations/clusters") requests: Endpoint = Endpoint("allocations/requests") research_groups: Endpoint = Endpoint("users/researchgroups") users: Endpoint = Endpoint("users/users") - - -@dataclass -class Schema: - """Schema defining the complete set of API endpoints.""" - - auth: AuthSchema = field(default_factory=AuthSchema) - data: DataSchema = field(default_factory=DataSchema) diff --git a/tests/authentication/__init__.py b/tests/authentication/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/authentication/test_authentication_manager.py b/tests/authentication/test_authentication_manager.py deleted file mode 100644 index 524a515..0000000 --- a/tests/authentication/test_authentication_manager.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Tests for user authentication management.""" - -from datetime import datetime, timedelta -from unittest import TestCase -from unittest.mock import Mock, patch - -import jwt -import requests -from requests.exceptions import HTTPError - -from keystone_client.authentication import AuthenticationManager, JWT -from tests import API_HOST, API_PASSWORD, API_USER - - -def create_token(access_expires: datetime, refresh_expires: datetime) -> JWT: - """Create a JWT token. - - Args: - access_expires: The expiration datetime for the access token. - refresh_expires: The expiration datetime for the refresh token. - - Returns: - A JWT instance with the given expiration dates. - """ - - return JWT( - access=jwt.encode({'exp': access_expires.timestamp()}, 'secret'), - refresh=jwt.encode({'exp': refresh_expires.timestamp()}, 'secret') - ) - - -class IsAuthenticated(TestCase): - """Tests for the `is_authenticated` method.""" - - def test_not_authenticated(self) -> None: - """Test the return value is `false` when the manager has no JWT data.""" - - manager = AuthenticationManager(API_HOST) - self.assertIsNone(manager.jwt) - self.assertFalse(manager.is_authenticated()) - - def test_valid_jwt(self) -> None: - """Test the return value is `True` when the JWT token is not expired.""" - - manager = AuthenticationManager(API_HOST) - manager.jwt = create_token( - access_expires=datetime.now() + timedelta(hours=1), - refresh_expires=datetime.now() + timedelta(days=1) - ) - - self.assertTrue(manager.is_authenticated()) - - def test_refreshable_jwt(self) -> None: - """Test the return value is `True` when the JWT token expired but refreshable.""" - - manager = AuthenticationManager(API_HOST) - manager.jwt = create_token( - access_expires=datetime.now() - timedelta(hours=1), - refresh_expires=datetime.now() + timedelta(days=1) - ) - - self.assertTrue(manager.is_authenticated()) - - def test_expired_jwt(self) -> None: - """Test the return value is `False` when the JWT token is expired.""" - - manager = AuthenticationManager(API_HOST) - manager.jwt = create_token( - access_expires=datetime.now() - timedelta(days=1), - refresh_expires=datetime.now() - timedelta(hours=1) - ) - - self.assertFalse(manager.is_authenticated()) - - -class GetAuthHeaders(TestCase): - """Tests for the `get_auth_headers` method.""" - - def test_not_authenticated(self) -> None: - """Test the returned headers are empty when not authenticated.""" - - manager = AuthenticationManager(API_HOST) - headers = manager.get_auth_headers() - self.assertEqual(dict(), headers) - - def test_headers_match_jwt(self) -> None: - """Test the returned data matches the JWT token.""" - - manager = AuthenticationManager(API_HOST) - manager.jwt = create_token( - access_expires=datetime.now() + timedelta(hours=1), - refresh_expires=datetime.now() + timedelta(days=1) - ) - - headers = manager.get_auth_headers() - self.assertEqual(f"Bearer {manager.jwt.access}", headers["Authorization"]) - - -class Login(TestCase): - """Test session authentication via the `login` method.""" - - def test_with_correct_credentials(self) -> None: - """Test users are successfully logged in/out when providing correct credentials.""" - - manager = AuthenticationManager(API_HOST) - self.assertFalse(manager.is_authenticated()) - - manager.login(API_USER, API_PASSWORD) - self.assertTrue(manager.is_authenticated()) - - def test_with_incorrect_credentials(self) -> None: - """Test an error is raised when authenticating with invalid credentials.""" - - manager = AuthenticationManager(API_HOST) - with self.assertRaises(HTTPError) as error: - manager.login('foo', 'bar') - self.assertEqual(401, error.response.status_code) - - @patch('requests.post') - def test_jwt_credentials_are_cached(self, mock_post: Mock) -> None: - """Test JWT credentials are cached after a successful login.""" - - # Mock response for successful login - mock_response = Mock() - mock_response.json.return_value = { - 'access': 'fake_access_token', - 'refresh': 'fake_refresh_token' - } - mock_response.raise_for_status = Mock() - mock_post.return_value = mock_response - - # Call the login method - manager = AuthenticationManager(API_HOST) - manager.login(API_USER, API_PASSWORD) - - # Assertions to check if tokens are set correctly - self.assertIsNotNone(manager.jwt) - self.assertEqual(manager.jwt.access, 'fake_access_token') - self.assertEqual(manager.jwt.refresh, 'fake_refresh_token') - - @patch('requests.post') - def test_login_network_error(self, mock_post: Mock) -> None: - """Test network errors during login.""" - - mock_post.side_effect = requests.ConnectionError() - manager = AuthenticationManager(API_HOST) - with self.assertRaises(requests.ConnectionError): - manager.login(API_USER, API_PASSWORD) - - -class Logout(TestCase): - """Test session invalidation via the `logout` method.""" - - def setUp(self) -> None: - """Authenticate a new `AuthenticationManager` instance.""" - - self.manager = AuthenticationManager(API_HOST) - self.manager.login(API_USER, API_PASSWORD) - - def test_user_is_logged_out(self) -> None: - """Test the credentials are cleared and the user is logged out.""" - - self.manager.logout() - self.assertFalse(self.manager.is_authenticated()) - self.assertIsNone(self.manager.jwt) - - @patch('requests.post') - def test_blacklist_request_sent(self, mock_post: Mock) -> None: - """Test a blacklist request is sent to the API server.""" - - refresh_token = self.manager.jwt.refresh - blacklist_url = self.manager.blacklist_url - - self.manager.logout() - mock_post.assert_called_once_with( - blacklist_url, - data={'refresh': refresh_token}, - timeout=None - ) - - self.assertIsNone(self.manager.jwt) - - @patch('requests.post') - def test_logout_failure(self, mock_post: Mock) -> None: - """Test an error is raised when the token blacklist request fails.""" - - mock_response = Mock() - mock_response.raise_for_status.side_effect = requests.HTTPError("Failed to blacklist token") - mock_post.return_value = mock_response - - with self.assertRaises(requests.HTTPError): - self.manager.logout() - - @patch('requests.post') - def test_logout_with_no_jwt(self, mock_post: Mock) -> None: - """Test the function exits silently when already not authenticated.""" - - AuthenticationManager(API_HOST).logout() - mock_post.assert_not_called() - - -class Refresh(TestCase): - """Test credential refreshing via the `refresh` method.""" - - def test_refresh_while_not_authenticated(self) -> None: - """Test the refresh call exits silently when not authenticated.""" - - manager = AuthenticationManager(API_HOST) - self.assertFalse(manager.is_authenticated()) - self.assertIsNone(manager.jwt) - - with patch('requests.post') as mock_post: - manager.refresh() - mock_post.assert_not_called() - - def test_refresh_with_valid_access_token(self) -> None: - """Test the refresh call exits silently when credentials are not expired.""" - - manager = AuthenticationManager(API_HOST) - manager.jwt = create_token( - access_expires=datetime.now() + timedelta(hours=1), - refresh_expires=datetime.now() + timedelta(days=1) - ) - - with patch('requests.post') as mock_post: - manager.refresh() - mock_post.assert_not_called() - - def test_refresh_with_valid_access_token_force(self) -> None: - """Test the refresh call refreshes valid credentials `force=True`.""" - - manager = AuthenticationManager(API_HOST) - manager.jwt = create_token( - access_expires=datetime.now() + timedelta(hours=1), - refresh_expires=datetime.now() + timedelta(days=1) - ) - refresh_token = manager.jwt.refresh - - with patch('requests.post') as mock_post: - manager.refresh(force=True) - mock_post.assert_called_once_with( - manager.refresh_url, - data={'refresh': refresh_token}, - timeout=None - ) - - @patch('requests.post') - def test_refresh_with_expired_access_token(self, mock_post: Mock) -> None: - """Test refreshing when the access token is expired.""" - - # Mock a session with an expired token - manager = AuthenticationManager(API_HOST) - manager.jwt = create_token( - access_expires=datetime.now() - timedelta(days=1), - refresh_expires=datetime.now() + timedelta(days=1) - ) - refresh_token = manager.jwt.refresh - - # Mock response for successful refresh - mock_response = Mock() - mock_response.json.return_value = {"refresh": "new_refresh_token"} - mock_post.return_value = mock_response - - manager.refresh() - mock_post.assert_called_once_with( - manager.refresh_url, - data={'refresh': refresh_token}, - timeout=None - ) - - # Check if refresh token was updated - self.assertEqual(manager.jwt.refresh, "new_refresh_token") - - @patch('requests.post') - def test_refresh_with_expired_refresh_token(self, mock_post: Mock) -> None: - """Test refreshing when the refresh token is expired.""" - - # Mock a session with an expired token - manager = AuthenticationManager(API_HOST) - manager.jwt = create_token( - access_expires=datetime.now() - timedelta(days=1), - refresh_expires=datetime.now() - timedelta(days=1) - ) - - with self.assertRaisesRegex(RuntimeError, "Refresh token has expired. Login again to continue."): - manager.refresh() - - mock_post.assert_not_called() - - def test_refresh_error(self) -> None: - """Test an HTTP error is raised when the credential refresh fails.""" - - manager = AuthenticationManager(API_HOST) - manager.login(API_USER, API_PASSWORD) - - with patch('requests.post') as mock_post, self.assertRaises(requests.HTTPError): - mock_post.side_effect = requests.HTTPError() - manager.refresh(force=True) diff --git a/tests/authentication/test_jwt.py b/tests/authentication/test_jwt.py deleted file mode 100644 index 2e8acf0..0000000 --- a/tests/authentication/test_jwt.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tests the handling of JWT credentials.""" - -from datetime import datetime, timedelta -from unittest import TestCase - -import jwt - -from keystone_client.authentication import JWT - - -class BaseParsingTests: - """Base class containing reusable tests for token parsing.""" - - algorithm: str - - @classmethod - def setUpClass(cls) -> None: - """Test the parsing of JWT data.""" - - # Build a JWT - cls.access_expiration = datetime.now() + timedelta(hours=1) - cls.access_token = jwt.encode({'exp': cls.access_expiration.timestamp()}, 'secret', algorithm=cls.algorithm) - - cls.refresh_expiration = datetime.now() + timedelta(days=1) - cls.refresh_token = jwt.encode({'exp': cls.refresh_expiration.timestamp()}, 'secret', algorithm=cls.algorithm) - - cls.jwt = JWT(cls.access_token, cls.refresh_token, cls.algorithm) - - def test_access_token(self) -> None: - """Test the access token is parsed correctly.""" - - self.assertEqual(self.access_token, self.jwt.access) - self.assertEqual(self.access_expiration, self.jwt.access_expiration) - - def test_refresh_token(self) -> None: - """Test the refresh token is parsed correctly.""" - - self.assertEqual(self.refresh_token, self.jwt.refresh) - self.assertEqual(self.refresh_expiration, self.jwt.refresh_expiration) - - -class HS256Parsing(BaseParsingTests, TestCase): - """Test JWT token parsing using the HS256 algorithm.""" - - algorithm = 'HS256' diff --git a/tests/client/test_HttpClient.py b/tests/client/test_HttpClient.py new file mode 100644 index 0000000..f8df45e --- /dev/null +++ b/tests/client/test_HttpClient.py @@ -0,0 +1,212 @@ +"""Test the sending of HTTP requests.""" + +from unittest import TestCase +from unittest.mock import Mock, patch +from urllib.parse import urljoin + +import requests +from requests import HTTPError + +from keystone_client.client import HTTPClient +from tests import API_HOST, API_PASSWORD, API_USER + + +class Url(TestCase): + """Tests for the `url` property.""" + + def test_trailing_slash_enforced(self) -> None: + """Test the URL is returned with a single trailing slash.""" + + base_url = 'https://test.domain.com' + expected_url = base_url + '/' + + # Test for various numbers of trailing slashes provided at init + self.assertEqual(expected_url, HTTPClient(base_url).url) + self.assertEqual(expected_url, HTTPClient(base_url + '/').url) + self.assertEqual(expected_url, HTTPClient(base_url + '////').url) + + +class Login(TestCase): + """Test session authentication via the `login` method.""" + + def test_with_correct_credentials(self) -> None: + """Test users are successfully logged in/out when providing correct credentials.""" + + client = HTTPClient(API_HOST) + self.assertFalse(client.is_authenticated()) + + client.login(API_USER, API_PASSWORD) + self.assertTrue(client.is_authenticated()) + + def test_with_incorrect_credentials(self) -> None: + """Test an error is raised when authenticating with invalid credentials.""" + + client = HTTPClient(API_HOST) + with self.assertRaises(HTTPError) as error: + client.login('foo', 'bar') + self.assertEqual(401, error.exception.response.status_code) + + def test_user_already_logged_in(self) -> None: + """Test the method succeeds when re-logging in a successful user""" + + client = HTTPClient(API_HOST) + self.assertFalse(client.is_authenticated()) + + client.login(API_USER, API_PASSWORD) + client.login(API_USER, API_PASSWORD) + self.assertTrue(client.is_authenticated()) + + @patch('requests.Session.request') + def test_errors_are_forwarded(self, mock_request: Mock) -> None: + """Test errors are forwarded to the user during login.""" + + mock_request.side_effect = requests.ConnectionError() + + client = HTTPClient(API_HOST) + with self.assertRaises(requests.ConnectionError): + client.login(API_USER, API_PASSWORD) + + +class Logout(TestCase): + """Test session invalidation via the `logout` method.""" + + def setUp(self) -> None: + """Authenticate a new `AuthenticationManager` instance.""" + + self.client = HTTPClient(API_HOST) + self.client.login(API_USER, API_PASSWORD) + + def test_user_is_logged_out(self) -> None: + """Test the credentials are cleared and the user is logged out.""" + + self.assertTrue(self.client.is_authenticated()) + self.client.logout() + self.assertFalse(self.client.is_authenticated()) + + def test_user_already_logged_out(self) -> None: + """Test the `logout` method exits silents when the user is not authenticated""" + + client = HTTPClient(API_HOST) + self.assertFalse(client.is_authenticated()) + client.logout() + self.assertFalse(client.is_authenticated()) + + @patch('requests.Session.request') + def test_errors_are_forwarded(self, mock_request: Mock) -> None: + """Test errors are forwarded to the user during logout.""" + + mock_request.side_effect = requests.ConnectionError() + + with self.assertRaises(requests.ConnectionError): + self.client.login(API_USER, API_PASSWORD) + + +@patch('requests.Session.request') +class BaseHttpMethodTests: + """Base class for HTTP method tests with common setup and assertions.""" + + client_method_name: str + client_method_args: dict[str, str] + request_type: str + request_endpoint = "test/endpoint" + + def setUp(self) -> None: + """Set a client instance for each test case.""" + + self.client = HTTPClient(API_HOST) + self.method_to_test = getattr(self.client, self.client_method_name) + + # Setup mock objects + self.mock_response = Mock() + self.mock_response.raise_for_status = Mock() + + def assert_http_request_called(self) -> None: + """Assert that the request was called with expected arguments.""" + + self.mock_request.assert_called_with( + method=self.request_type, + url=urljoin(self.client.url, self.request_endpoint), + headers=self.client._csrf_headers(), + **self.client_method_args + ) + self.mock_response.raise_for_status.assert_called_once() + + def test_unauthenticated_request(self, mock_request: Mock) -> None: + """Test the HTTP method for a successful, unauthenticated request.""" + + self.mock_request = mock_request + self.mock_request.return_value = self.mock_response + + self.method_to_test(self.request_endpoint, **self.client_method_args) + self.assert_http_request_called() + + def test_authenticated_request(self, mock_request: Mock) -> None: + """Test the HTTP method for a successful, authenticated request.""" + + self.client.login(API_USER, API_PASSWORD) + self.mock_request = mock_request + self.mock_request.return_value = self.mock_response + + self.method_to_test(self.request_endpoint, **self.client_method_args) + self.assert_http_request_called() + + def test_http_error(self, mock_request: Mock) -> None: + """Test the HTTP method for a failed request.""" + + self.mock_response.raise_for_status.side_effect = requests.HTTPError("Error") + self.mock_request = mock_request + self.mock_request.return_value = self.mock_response + + with self.assertRaises(requests.HTTPError): + self.method_to_test(self.request_endpoint, **self.client_method_args) + + self.assert_http_request_called() + + def test_connection_error(self, mock_request: Mock) -> None: + """Test that a connection error is raised.""" + + self.mock_request = mock_request + self.mock_request.side_effect = requests.ConnectionError("Connection error") + + with self.assertRaises(requests.ConnectionError): + self.method_to_test(self.request_endpoint, **self.client_method_args) + + +class HttpGet(BaseHttpMethodTests, TestCase): + """Tests for the `http_get` method.""" + + request_type = 'get' + client_method_name = 'http_get' + client_method_args = {'params': {"key": "value"}, 'timeout': 10} + + +class HttpPost(BaseHttpMethodTests, TestCase): + """Tests for the `http_post` method.""" + + request_type = 'post' + client_method_name = 'http_post' + client_method_args = {'data': {"key": "value"}, 'timeout': 10} + + +class HttpPatch(BaseHttpMethodTests, TestCase): + """Tests for the `http_patch` method.""" + + request_type = 'patch' + client_method_name = 'http_patch' + client_method_args = {'data': {"key": "value"}, 'timeout': 10} + + +class HttpPut(BaseHttpMethodTests, TestCase): + """Tests for the `http_put` method.""" + + request_type = 'put' + client_method_name = 'http_put' + client_method_args = {'data': {"key": "value"}, 'timeout': 10} + + +class HttpDelete(BaseHttpMethodTests, TestCase): + """Tests for the `http_delete` method.""" + + request_type = 'delete' + client_method_name = 'http_delete' + client_method_args = {'timeout': 10} diff --git a/tests/client/test_keystone_client.py b/tests/client/test_KeystoneClient.py similarity index 99% rename from tests/client/test_keystone_client.py rename to tests/client/test_KeystoneClient.py index d1ec546..ee0e475 100644 --- a/tests/client/test_keystone_client.py +++ b/tests/client/test_KeystoneClient.py @@ -6,8 +6,7 @@ from requests import HTTPError from keystone_client import KeystoneClient -from tests import API_HOST, API_PASSWORD -from .. import API_USER +from tests import API_HOST, API_PASSWORD, API_USER class APIVersion(TestCase): diff --git a/tests/client/test_http_client.py b/tests/client/test_http_client.py deleted file mode 100644 index 3389e99..0000000 --- a/tests/client/test_http_client.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Test the sending of HTTP requests.""" - -from unittest import TestCase -from unittest.mock import Mock, patch -from urllib.parse import urljoin - -import requests - -from keystone_client.client import HTTPClient -from .. import API_HOST, API_PASSWORD, API_USER - - -class Url(TestCase): - """Tests for the `url` property.""" - - def test_trailing_slash_removed(self) -> None: - """Test extra trailing slashes are removed from URLs provided at init.""" - - base_url = 'https://test.domain.com' - expected_url = base_url + '/' - - # Test for various numbers of trailing slashes provided at init - self.assertEqual(expected_url, HTTPClient(base_url).url) - self.assertEqual(expected_url, HTTPClient(base_url + '/').url) - self.assertEqual(expected_url, HTTPClient(base_url + '////').url) - - -@patch('requests.request') -class BaseHttpMethodTests: - """Base class for HTTP method tests with common setup and assertions.""" - - client_method: str - request_type: str - request_params: dict[str, str] - endpoint_str = "test/endpoint" - - def setUp(self) -> None: - """Set a client instance for each test case.""" - - self.client = HTTPClient(API_HOST) - self.method_to_test = getattr(self.client, self.client_method) - - # Setup mock objects - self.mock_response = Mock() - self.mock_response.raise_for_status = Mock() - - def assert_http_request_called(self) -> None: - """Assert that the request was called with expected arguments.""" - - self.mock_request.assert_called_once_with( - self.request_type, - urljoin(self.client.url, self.endpoint_str), - headers=self.client._auth.get_auth_headers(), - **self.request_params - ) - self.mock_response.raise_for_status.assert_called_once() - - def test_unauthenticated_request(self, mock_request: Mock) -> None: - """Test the HTTP method for a successful, unauthenticated request.""" - - self.mock_request = mock_request - self.mock_request.return_value = self.mock_response - - self.method_to_test(self.endpoint_str, **self.request_params) - self.assert_http_request_called() - - def test_authenticated_request(self, mock_request: Mock) -> None: - """Test the HTTP method for a successful, authenticated request.""" - - self.client.login(API_USER, API_PASSWORD) - self.mock_request = mock_request - self.mock_request.return_value = self.mock_response - - self.method_to_test(self.endpoint_str, **self.request_params) - self.assert_http_request_called() - - def test_http_error(self, mock_request: Mock) -> None: - """Test the HTTP method for a failed request.""" - - self.mock_response.raise_for_status.side_effect = requests.HTTPError("Error") - self.mock_request = mock_request - self.mock_request.return_value = self.mock_response - - with self.assertRaises(requests.HTTPError): - self.method_to_test(self.endpoint_str, **self.request_params) - - self.assert_http_request_called() - - def test_connection_error(self, mock_request: Mock) -> None: - """Test that a connection error is raised.""" - - self.mock_request = mock_request - self.mock_request.side_effect = requests.ConnectionError("Connection error") - - with self.assertRaises(requests.ConnectionError): - self.method_to_test(self.endpoint_str, **self.request_params) - - -class HttpGet(BaseHttpMethodTests, TestCase): - """Tests for the `http_get` method.""" - - request_type = 'get' - client_method = 'http_get' - request_params = {'params': {"key": "value"}, 'timeout': 10} - - -class HttpPost(BaseHttpMethodTests, TestCase): - """Tests for the `http_post` method.""" - - request_type = 'post' - client_method = 'http_post' - request_params = {'data': {"key": "value"}, 'timeout': 10} - - -class HttpPatch(BaseHttpMethodTests, TestCase): - """Tests for the `http_patch` method.""" - - request_type = 'patch' - client_method = 'http_patch' - request_params = {'data': {"key": "value"}, 'timeout': 10} - - -class HttpPut(BaseHttpMethodTests, TestCase): - """Tests for the `http_put` method.""" - - request_type = 'put' - client_method = 'http_put' - request_params = {'data': {"key": "value"}, 'timeout': 10} - - -class HttpDelete(BaseHttpMethodTests, TestCase): - """Tests for the `http_delete` method.""" - - request_type = 'delete' - client_method = 'http_delete' - request_params = {'timeout': 10} diff --git a/tests/schema/test_endpoint.py b/tests/schema/test_Endpoint.py similarity index 100% rename from tests/schema/test_endpoint.py rename to tests/schema/test_Endpoint.py From bb915763de735101205e082f0ee5362b95a76e6e Mon Sep 17 00:00:00 2001 From: Daniel Perrefort Date: Thu, 24 Oct 2024 14:20:02 -0400 Subject: [PATCH 4/7] Adds support for new teams based membership structure (#58) * Migrate from research groups to teams * Fix endpoint typo --- keystone_client/client.py | 13 +++++++++---- keystone_client/schema.py | 3 ++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/keystone_client/client.py b/keystone_client/client.py index 951eca5..6130a7e 100644 --- a/keystone_client/client.py +++ b/keystone_client/client.py @@ -263,10 +263,15 @@ def __new__(cls, *args, **kwargs) -> KeystoneClient: new.update_request = new._update_factory(cls.schema.requests) new.delete_request = new._delete_factory(cls.schema.requests) - new.create_research_group = new._create_factory(cls.schema.research_groups) - new.retrieve_research_group = new._retrieve_factory(cls.schema.research_groups) - new.update_research_group = new._update_factory(cls.schema.research_groups) - new.delete_research_group = new._delete_factory(cls.schema.research_groups) + new.create_team = new._create_factory(cls.schema.teams) + new.retrieve_team = new._retrieve_factory(cls.schema.teams) + new.update_team = new._update_factory(cls.schema.teams) + new.delete_team = new._delete_factory(cls.schema.teams) + + new.create_membership = new._create_factory(cls.schema.memberships) + new.retrieve_membership = new._retrieve_factory(cls.schema.memberships) + new.update_membership = new._update_factory(cls.schema.memberships) + new.delete_membership = new._delete_factory(cls.schema.memberships) new.create_user = new._create_factory(cls.schema.users) new.retrieve_user = new._retrieve_factory(cls.schema.users) diff --git a/keystone_client/schema.py b/keystone_client/schema.py index a316f2c..1d9a591 100644 --- a/keystone_client/schema.py +++ b/keystone_client/schema.py @@ -38,5 +38,6 @@ class Schema: allocations: Endpoint = Endpoint("allocations/allocations") clusters: Endpoint = Endpoint("allocations/clusters") requests: Endpoint = Endpoint("allocations/requests") - research_groups: Endpoint = Endpoint("users/researchgroups") + teams: Endpoint = Endpoint("users/teams") + memberships: Endpoint = Endpoint("users/membership") users: Endpoint = Endpoint("users/users") From af6367d19d2d46d04f4dc9d74bca15e979e94ff7 Mon Sep 17 00:00:00 2001 From: Daniel Perrefort Date: Fri, 1 Nov 2024 09:48:44 -0400 Subject: [PATCH 5/7] Update README.md (#59) --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index 5c42119..6ca62bc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,3 @@ # Keystone Python Client Official Python client for ineracting with the Keystone API. - -## Resources - -- [Keystone-API](https://github.com/better-hpc/keystone-api): Backend REST API for managing HPC allocations and resources. -- [Keystone-Web](https://github.com/better-hpc/keystone-web): Website frontend for HPC administration and self-service. -- [Keystone-Python-Client](https://github.com/better-hpc/keystone-python-client): A light-weight Python client for Keystone API. -- [Keystone-Docs](https://github.com/better-hpc/keystone-docs): Documentation for the Keystone project and its components. From b876cdab954c98f3c67ba8a43d3a997454caf3a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Nov 2024 13:07:17 -0500 Subject: [PATCH 6/7] Bump coverage from 7.6.1 to 7.6.4 in the python group (#60) Bumps the python group with 1 update: [coverage](https://github.com/nedbat/coveragepy). Updates `coverage` from 7.6.1 to 7.6.4 - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.6.1...7.6.4) --- updated-dependencies: - dependency-name: coverage dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 138 ++++++++++++++++++++++++---------------------------- 1 file changed, 64 insertions(+), 74 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9192f9f..b598d80 100644 --- a/poetry.lock +++ b/poetry.lock @@ -112,83 +112,73 @@ files = [ [[package]] name = "coverage" -version = "7.6.1" +version = "7.6.4" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, - {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, - {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, - {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, - {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, - {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, - {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, - {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, - {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, - {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, - {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, - {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, - {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, - {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, - {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, - {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, - {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, - {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, + {file = "coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07"}, + {file = "coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0"}, + {file = "coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72"}, + {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51"}, + {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491"}, + {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b"}, + {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea"}, + {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a"}, + {file = "coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa"}, + {file = "coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172"}, + {file = "coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b"}, + {file = "coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25"}, + {file = "coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546"}, + {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b"}, + {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e"}, + {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718"}, + {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db"}, + {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522"}, + {file = "coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf"}, + {file = "coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19"}, + {file = "coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2"}, + {file = "coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117"}, + {file = "coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613"}, + {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27"}, + {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52"}, + {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2"}, + {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1"}, + {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5"}, + {file = "coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17"}, + {file = "coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08"}, + {file = "coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9"}, + {file = "coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba"}, + {file = "coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c"}, + {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06"}, + {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f"}, + {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b"}, + {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21"}, + {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a"}, + {file = "coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e"}, + {file = "coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963"}, + {file = "coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f"}, + {file = "coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806"}, + {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11"}, + {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3"}, + {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a"}, + {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc"}, + {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70"}, + {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef"}, + {file = "coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e"}, + {file = "coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1"}, + {file = "coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3"}, + {file = "coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c"}, + {file = "coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076"}, + {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376"}, + {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0"}, + {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858"}, + {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111"}, + {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901"}, + {file = "coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09"}, + {file = "coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f"}, + {file = "coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e"}, + {file = "coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73"}, ] [package.extras] From c91496edf9826e4804afad80c1258afa81eefd58 Mon Sep 17 00:00:00 2001 From: Daniel Perrefort Date: Tue, 5 Nov 2024 13:09:08 -0500 Subject: [PATCH 7/7] Disable attestations (#61) Support for PyPI attestations is still buggy and fails in CI. Recent changes to CI dependencies enable it by default. This PR manually disables them. --- .github/workflows/Publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/Publish.yml b/.github/workflows/Publish.yml index 6e15bb6..e9750b3 100644 --- a/.github/workflows/Publish.yml +++ b/.github/workflows/Publish.yml @@ -36,6 +36,7 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: print-hash: true + attestations: false trigger-docs: name: Trigger Docs